Merge branch '11.0' into 11.0-test

This commit is contained in:
Jared Kipe
2018-10-31 15:57:42 -07:00
112 changed files with 10815 additions and 109 deletions

9
.gitmodules vendored
View File

@@ -29,3 +29,12 @@
[submodule "external/hibou-shipbox"]
path = external/hibou-shipbox
url = https://github.com/hibou-io/shipbox.git
[submodule "external/hibou-oca/purchase-workflow"]
path = external/hibou-oca/purchase-workflow
url = https://github.com/hibou-io/oca-purchase-workflow.git
[submodule "external/hibou-oca/stock-logistics-workflow"]
path = external/hibou-oca/stock-logistics-workflow
url = https://github.com/hibou-io/oca-stock-logistics-workflow.git
[submodule "external/hibou-oca/stock-logistics-warehouse"]
path = external/hibou-oca/stock-logistics-warehouse
url = https://github.com/hibou-io/stock-logistics-warehouse.git

View File

@@ -0,0 +1,28 @@
******************************
Hibou - Account Invoice Margin
******************************
Include a margin calculation on invoices.
For more information and add-ons, visit `Hibou.io <https://hibou.io/docs/hibou-odoo-suite-1/invoice-margin-156>`_.
=============
Main Features
=============
* Adds computed field `margin` to invoices to give the profitability by calculating the difference between the Unit Price and the Cost.
.. image:: https://user-images.githubusercontent.com/15882954/45578631-880c0000-b837-11e8-9c4d-d2f15c3c0592.png
:alt: 'Customer Invoice'
:width: 988
:align: left
=======
License
=======
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/11.0/LICENSE>`_.
Copyright Hibou Corp. 2018

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,13 @@
{'name': 'US WA State SalesTax API',
'version': '10.0.1.0.0',
'category': 'Tools',
'depends': ['account',
],
'author': 'Hibou Corp.',
'license': 'AGPL-3',
'website': 'https://hibou.io/',
'data': ['views/account_fiscal_position_view.xml',
],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,2 @@
from . import account_fiscal_position
from . import wa_tax_request

View File

@@ -0,0 +1,78 @@
from odoo import api, fields, models
from odoo.exceptions import ValidationError
from .wa_tax_request import WATaxRequest
class AccountFiscalPosition(models.Model):
_inherit = 'account.fiscal.position'
is_us_wa = fields.Boolean(string='Use WA State API')
wa_base_tax_id = fields.Many2one('account.tax', string='WA Base/Error Tax')
@api.multi
def map_tax(self, taxes, product=None, partner=None):
if not taxes or not self.is_us_wa or partner is None:
return super(AccountFiscalPosition, self).map_tax(taxes)
AccountTax = self.env['account.tax'].sudo()
result = AccountTax.browse()
for tax in taxes:
# step 1: If we were to save the location code on the partner we might not have to do this
request = WATaxRequest()
res = request.get_rate(partner)
wa_tax = None
if not request.is_success(res):
# Cache.
wa_tax = AccountTax.search([
('wa_location_zips', 'like', '%' + partner.zip + '%'),
('amount_type', '=', 'percent'),
('type_tax_use', '=', 'sale')], limit=1)
if not wa_tax:
result |= self.wa_base_tax_id
continue
# step 2: Find or create tax
if not wa_tax:
wa_tax = AccountTax.search([
('wa_location_code', '=', res['location_code']),
('amount', '=', res['rate']),
('amount_type', '=', 'percent'),
('type_tax_use', '=', 'sale')], limit=1)
if not wa_tax:
wa_tax = AccountTax.create({
'name': '%s - WA Tax %s %%' % (res['location_code'], res['rate']),
'wa_location_code': res['location_code'],
'amount': res['rate'],
'amount_type': 'percent',
'type_tax_use': 'sale',
'account_id': self.wa_base_tax_id.account_id.id,
'refund_account_id': self.wa_base_tax_id.refund_account_id.id
})
if not wa_tax.wa_location_zips:
wa_tax.wa_location_zips = partner.zip
elif not wa_tax.wa_location_zips.find(partner.zip) >= 0:
zips = wa_tax.wa_location_zips.split(',')
zips.append(partner.zip)
wa_tax.wa_location_zips = zips.append(',')
# step 3: Find or create mapping
tax_line = self.tax_ids.filtered(lambda x: x.tax_src_id.id == tax.id and x.tax_dest_id.id == wa_tax.id)
if not tax_line:
tax_line = self.env['account.fiscal.position.tax'].sudo().create({
'position_id': self.id,
'tax_src_id': tax.id,
'tax_dest_id': wa_tax.id,
})
result |= tax_line.tax_dest_id
return result
class AccountTax(models.Model):
_inherit = 'account.tax'
wa_location_code = fields.Integer('WA Location Code')
wa_location_zips = fields.Char('WA Location ZIPs', default='')

View File

@@ -0,0 +1,74 @@
from urllib.request import urlopen, quote
from urllib.error import HTTPError
from ssl import _create_unverified_context
from logging import getLogger
from odoo.exceptions import ValidationError
_logger = getLogger(__name__)
class WATaxRequest(object):
def __init__(self):
pass
def get_rate(self, partner):
# https://webgis.dor.wa.gov/webapi/addressrates.aspx/?output=text\&addr=test\&city=Marysville\&zip=98270
if not all((partner.street, partner.city, partner.zip)):
raise ValidationError('WATaxRequest impossible without Street, City and ZIP.')
url = 'https://webgis.dor.wa.gov/webapi/addressrates.aspx?output=text&addr=' + quote(partner.street) + \
'&city=' + quote(partner.city) + '&zip=' + quote(partner.zip)
_logger.info(url)
try:
response = urlopen(url, context=_create_unverified_context())
response_body = response.read()
_logger.info(response_body)
except HTTPError as e:
_logger.warn('Error on request: ' + str(e))
response_body = ''
return self._parse_rate(response_body)
def is_success(self, result):
'''
ADDRESS = 0,
LATLON = 0,
PLUS4 = 1,
ADDRESS_STANARDIZED = 2,
PLUS4_STANARDIZED = 3,
ADDRESS_CHANGED = 4,
ZIPCODE = 5,
ADDRESS_NOT_FOUND = 6,
LATLON_NOT_FOUND = 7,
POI = 8,
ERROR = 9
internal parse_error = 100
'''
if 'result_code' not in result or result['result_code'] >= 9 or result['result_code'] < 0:
return False
return True
def _parse_rate(self, response_body):
# 'LocationCode=1704 Rate=0.100 ResultCode=0'
# {
# 'result_code': 0,
# 'location_code': '1704',
# 'rate': '10.00',
# }
res = {'result_code': 100}
if len(response_body) > 200:
# this likely means that they returned an HTML page
return res
body_parts = response_body.decode().split(' ')
for part in body_parts:
if part.find('ResultCode=') >= 0:
res['result_code'] = int(part[len('ResultCode='):])
elif part.find('Rate=') >= 0:
res['rate'] = '%.2f' % (float(part[len('Rate='):]) * 100.0)
elif part.find('LocationCode=') >= 0:
res['location_code'] = part[len('LocationCode='):]
elif part.find('debughint=') >= 0:
res['debug_hint'] = part[len('debughint='):]
return res

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_position_us_wa_inherit_from_view" model="ir.ui.view">
<field name="name">account.fiscal.position.form.inherit</field>
<field name="model">account.fiscal.position</field>
<field name="inherit_id" ref="account.view_account_position_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='active']" position="after">
<field name="is_us_wa"/>
<field name="wa_base_tax_id" attrs="{'invisible': [('is_us_wa', '=', False)]}" />
</xpath>
</field>
</record>
<record id="view_tax_form" model="ir.ui.view">
<field name="name">account.tax.form.inherit</field>
<field name="model">account.tax</field>
<field name="inherit_id" ref="account.view_tax_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='description']" position="after">
<field name="wa_location_code" />
<field name="wa_location_zips" />
</xpath>
</field>
</record>
</odoo>

1
auditlog Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/server-tools/auditlog

View File

@@ -0,0 +1 @@
external/hibou-oca/connector-magento/connector_magento_product_by_sku

View File

@@ -5,14 +5,14 @@
<record model="ir.cron" id="ir_cron_import_sale_orders" forcecreate="True">
<field name="name">Walmart - Import Sales Orders</field>
<field eval="False" name="active"/>
<field name="state">code</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field eval="False" name="doall"/>
<field ref="connector_walmart.model_walmart_backend" name="model_id"/>
<field eval="'_scheduler_import_sale_orders'" name="function"/>
<field eval="'()'" name="args"/>
<field name="code">model._scheduler_import_sale_orders()</field>
</record>
<record id="excep_wrong_total_amount" model="exception.rule">

View File

@@ -11,7 +11,7 @@ from ...components.api.walmart import Walmart
_logger = getLogger(__name__)
IMPORT_DELTA_BUFFER = 60 # seconds
IMPORT_DELTA_BUFFER = 600 # seconds
class WalmartBackend(models.Model):

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,24 @@
{
'name': 'Delivery Hibou',
'summary': 'Adds underlying pinnings for things like "RMA Return Labels"',
'version': '11.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Stock',
'license': 'AGPL-3',
'images': [],
'website': "https://hibou.io",
'description': """
This is a collection of "typical" carrier needs, and a bridge into Hibou modules like `delivery_partner` and `sale_planner`.
""",
'depends': [
'delivery',
'delivery_partner',
],
'demo': [],
'data': [
'views/delivery_views.xml',
'views/stock_views.xml',
],
'auto_install': False,
'installable': True,
}

View File

@@ -0,0 +1,2 @@
from . import delivery
from . import stock

View File

@@ -0,0 +1,152 @@
from odoo import fields, models
from odoo.addons.stock.models.stock_move import PROCUREMENT_PRIORITIES
class DeliveryCarrier(models.Model):
_inherit = 'delivery.carrier'
automatic_insurance_value = fields.Float(string='Automatic Insurance Value',
help='Will be used during shipping to determine if the '
'picking\'s value warrants insurance being added.')
procurement_priority = fields.Selection(PROCUREMENT_PRIORITIES,
string='Procurement Priority',
help='Priority for this carrier. Will affect pickings '
'and procurements related to this carrier.')
def get_insurance_value(self, order=None, picking=None):
value = 0.0
if order:
if order.order_line:
value = sum(order.order_line.filtered(lambda l: l.type != 'service').mapped('price_subtotal'))
else:
return value
if picking:
value = picking.declared_value()
if picking.require_insurance == 'no':
value = 0.0
elif picking.require_insurance == 'auto' and self.automatic_insurance_value and self.automatic_insurance_value > value:
value = 0.0
return value
def get_third_party_account(self, order=None, picking=None):
if order and order.shipping_account_id:
return order.shipping_account_id
if picking and picking.shipping_account_id:
return picking.shipping_account_id
return None
def get_order_name(self, order=None, picking=None):
if order:
return order.name
if picking:
if picking.sale_id:
return picking.sale_id.name # + ' - ' + picking.name
return picking.name
return ''
def get_attn(self, order=None, picking=None):
if order:
return order.client_order_ref
if picking and picking.sale_id:
return picking.sale_id.client_order_ref
# If Picking has a reference, decide what it is.
return False
def _classify_picking(self, picking):
if picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'supplier' and picking.location_dest_id.usage == 'customer':
return 'dropship'
elif picking.picking_type_id.code == 'incoming' and picking.location_id.usage == 'customer' and picking.location_dest_id.usage == 'supplier':
return 'dropship_in'
elif picking.picking_type_id.code == 'incoming':
return 'in'
return 'out'
# Shipper Company
def get_shipper_company(self, order=None, picking=None):
"""
Shipper Company: The `res.partner` that provides the name of where the shipment is coming from.
"""
if order:
return order.company_id.partner_id
if picking:
return getattr(self, ('_get_shipper_company_%s' % (self._classify_picking(picking),)),
self._get_shipper_company_out)(picking)
return None
def _get_shipper_company_dropship(self, picking):
return picking.company_id.partner_id
def _get_shipper_company_dropship_in(self, picking):
return picking.company_id.partner_id
def _get_shipper_company_in(self, picking):
return picking.company_id.partner_id
def _get_shipper_company_out(self, picking):
return picking.company_id.partner_id
# Shipper Warehouse
def get_shipper_warehouse(self, order=None, picking=None):
"""
Shipper Warehouse: The `res.partner` that is basically the physical address a shipment is coming from.
"""
if order:
return order.warehouse_id.partner_id
if picking:
return getattr(self, ('_get_shipper_warehouse_%s' % (self._classify_picking(picking),)),
self._get_shipper_warehouse_out)(picking)
return None
def _get_shipper_warehouse_dropship(self, picking):
return picking.partner_id
def _get_shipper_warehouse_dropship_in(self, picking):
if picking.sale_id:
picking.sale_id.partner_shipping_id
return self._get_shipper_warehouse_dropship_in_no_sale(picking)
def _get_shipper_warehouse_dropship_in_no_sale(self, picking):
return picking.company_id.partner_id
def _get_shipper_warehouse_in(self, picking):
return picking.partner_id
def _get_shipper_warehouse_out(self, picking):
return picking.picking_type_id.warehouse_id.partner_id
# Recipient
def get_recipient(self, order=None, picking=None):
"""
Recipient: The `res.partner` receiving the shipment.
"""
if order:
return order.partner_shipping_id
if picking:
return getattr(self, ('_get_recipient_%s' % (self._classify_picking(picking),)),
self._get_recipient_out)(picking)
return None
def _get_recipient_dropship(self, picking):
if picking.sale_id:
return picking.sale_id.partner_shipping_id
return picking.sale_id.partner_shipping_id
def _get_recipient_dropship_no_sale(self, picking):
return picking.company_id.partner_id
def _get_recipient_dropship_in(self, picking):
return picking.picking_type_id.warehouse_id.partner_id
def _get_recipient_in(self, picking):
return picking.picking_type_id.warehouse_id.partner_id
def _get_recipient_out(self, picking):
return picking.partner_id

View File

@@ -0,0 +1,50 @@
from odoo import api, fields, models
class StockPicking(models.Model):
_inherit = 'stock.picking'
shipping_account_id = fields.Many2one('partner.shipping.account', string='Shipping Account')
require_insurance = fields.Selection([
('auto', 'Automatic'),
('yes', 'Yes'),
('no', 'No'),
], string='Require Insurance', default='auto',
help='If your carrier supports it, auto should be calculated off of the "Automatic Insurance Value" field.')
@api.one
@api.depends('move_lines.priority', 'carrier_id')
def _compute_priority(self):
if self.carrier_id.procurement_priority:
self.priority = self.carrier_id.procurement_priority
else:
super(StockPicking, self)._compute_priority()
@api.model
def create(self, values):
origin = values.get('origin')
if origin and not values.get('shipping_account_id'):
so = self.env['sale.order'].search([('name', '=', str(origin))], limit=1)
if so and so.shipping_account_id:
values['shipping_account_id'] = so.shipping_account_id.id
res = super(StockPicking, self).create(values)
return res
def declared_value(self):
self.ensure_one()
cost = sum([(l.product_id.standard_price * l.qty_done) for l in self.move_line_ids] or [0.0])
if not cost:
# Assume Full Value
cost = sum([(l.product_id.standard_price * l.product_uom_qty) for l in self.move_lines] or [0.0])
return cost
class StockMove(models.Model):
_inherit = 'stock.move'
def _prepare_procurement_values(self):
res = super(StockMove, self)._prepare_procurement_values()
res['priority'] = self.picking_id.priority or self.priority
return res

View File

@@ -0,0 +1 @@
from . import test_delivery_hibou

View File

@@ -0,0 +1,160 @@
from odoo.tests import common
class TestDeliveryHibou(common.TransactionCase):
def setUp(self):
super(TestDeliveryHibou, self).setUp()
self.partner = self.env.ref('base.res_partner_address_13')
self.product = self.env.ref('product.product_product_7')
# Create Shipping Account
self.shipping_account = self.env['partner.shipping.account'].create({
'name': '123123',
'delivery_type': 'other',
})
# Create Carrier
self.delivery_product = self.env['product.product'].create({
'name': 'Test Carrier1 Delivery',
'type': 'service',
})
self.carrier = self.env['delivery.carrier'].create({
'name': 'Test Carrier1',
'product_id': self.delivery_product.id,
})
def test_delivery_hibou(self):
# Assign a new shipping account
self.partner.shipping_account_id = self.shipping_account
# Assign values to new Carrier
test_insurance_value = 600
test_procurement_priority = '2'
self.carrier.automatic_insurance_value = test_insurance_value
self.carrier.procurement_priority = test_procurement_priority
sale_order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'partner_shipping_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'carrier_id': self.carrier.id,
'shipping_account_id': self.shipping_account.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
})]
})
sale_order.get_delivery_price()
sale_order.set_delivery_line()
sale_order.action_confirm()
# Make sure 3rd party Shipping Account is set.
self.assertEqual(sale_order.shipping_account_id, self.shipping_account)
self.assertTrue(sale_order.picking_ids)
# Priority coming from Carrier procurement_priority
self.assertEqual(sale_order.picking_ids.priority, test_procurement_priority)
# 3rd party Shipping Account copied from Sale Order
self.assertEqual(sale_order.picking_ids.shipping_account_id, self.shipping_account)
self.assertEqual(sale_order.carrier_id.get_third_party_account(order=sale_order), self.shipping_account)
# Test attn
test_ref = 'TEST100'
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), False)
sale_order.client_order_ref = test_ref
self.assertEqual(sale_order.carrier_id.get_attn(order=sale_order), test_ref)
# The picking should get this ref as well
self.assertEqual(sale_order.picking_ids.carrier_id.get_attn(picking=sale_order.picking_ids), test_ref)
# Test order_name
self.assertEqual(sale_order.carrier_id.get_order_name(order=sale_order), sale_order.name)
# The picking should get the same 'order_name'
self.assertEqual(sale_order.picking_ids.carrier_id.get_order_name(picking=sale_order.picking_ids), sale_order.name)
def test_carrier_hibou_out(self):
test_insurance_value = 4000
self.carrier.automatic_insurance_value = test_insurance_value
picking_out = self.env.ref('stock.outgoing_shipment_main_warehouse')
self.assertEqual(picking_out.state, 'assigned')
picking_out.carrier_id = self.carrier
# This relies heavily on the 'stock' demo data.
# Should only have a single move_line_ids and it should not be done at all.
self.assertEqual(picking_out.move_line_ids.mapped('qty_done'), [0.0])
self.assertEqual(picking_out.move_line_ids.mapped('product_uom_qty'), [15.0])
self.assertEqual(picking_out.move_line_ids.mapped('product_id.standard_price'), [3300.0])
# The 'value' is assumed to be all of the product value from the initial demand.
self.assertEqual(picking_out.declared_value(), 15.0 * 3300.0)
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), picking_out.declared_value())
# Workflow where user explicitly opts out of insurance on the picking level.
picking_out.require_insurance = 'no'
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
picking_out.require_insurance = 'auto'
# Lets choose to only delivery one piece at the moment.
# This does not meet the minimum on the carrier to have insurance value.
picking_out.move_line_ids.qty_done = 1.0
self.assertEqual(picking_out.declared_value(), 3300.0)
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 0.0)
# Workflow where user opts in to insurance.
picking_out.require_insurance = 'yes'
self.assertEqual(picking_out.carrier_id.get_insurance_value(picking=picking_out), 3300.0)
picking_out.require_insurance = 'auto'
# Test with picking having 3rd party account.
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), None)
picking_out.shipping_account_id = self.shipping_account
self.assertEqual(picking_out.carrier_id.get_third_party_account(picking=picking_out), self.shipping_account)
# Shipment Time Methods!
self.assertEqual(picking_out.carrier_id._classify_picking(picking=picking_out), 'out')
self.assertEqual(picking_out.carrier_id.get_shipper_company(picking=picking_out),
picking_out.company_id.partner_id)
self.assertEqual(picking_out.carrier_id.get_shipper_warehouse(picking=picking_out),
picking_out.picking_type_id.warehouse_id.partner_id)
self.assertEqual(picking_out.carrier_id.get_recipient(picking=picking_out),
picking_out.partner_id)
# This picking has no `sale_id`
# Right now ATTN requires a sale_id, which this picking doesn't have (none of the stock ones do)
self.assertEqual(picking_out.carrier_id.get_attn(picking=picking_out), False)
self.assertEqual(picking_out.carrier_id.get_order_name(picking=picking_out), picking_out.name)
def test_carrier_hibou_in(self):
picking_in = self.env.ref('stock.incomming_shipment1')
self.assertEqual(picking_in.state, 'assigned')
picking_in.carrier_id = self.carrier
# This relies heavily on the 'stock' demo data.
# Should only have a single move_line_ids and it should not be done at all.
self.assertEqual(picking_in.move_line_ids.mapped('qty_done'), [0.0])
self.assertEqual(picking_in.move_line_ids.mapped('product_uom_qty'), [35.0])
self.assertEqual(picking_in.move_line_ids.mapped('product_id.standard_price'), [55.0])
self.assertEqual(picking_in.carrier_id._classify_picking(picking=picking_in), 'in')
self.assertEqual(picking_in.carrier_id.get_shipper_company(picking=picking_in),
picking_in.company_id.partner_id)
self.assertEqual(picking_in.carrier_id.get_shipper_warehouse(picking=picking_in),
picking_in.partner_id)
self.assertEqual(picking_in.carrier_id.get_recipient(picking=picking_in),
picking_in.picking_type_id.warehouse_id.partner_id)

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_delivery_carrier_form" model="ir.ui.view">
<field name="name">hibou.delivery.carrier.form</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='integration_level']" position="after">
<field name="automatic_insurance_value"/>
<field name="procurement_priority"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_withcarrier_out_form" model="ir.ui.view">
<field name="name">hibou.delivery.stock.picking_withcarrier.form.view</field>
<field name="model">stock.picking</field>
<field name="inherit_id" ref="delivery.view_picking_withcarrier_out_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='carrier_id']" position="before">
<field name="require_insurance" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
<field name="shipping_account_id" attrs="{'readonly': [('state', 'in', ('done', 'cancel'))]}"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,26 @@
{
'name': 'Stamps.com (USPS) Shipping',
'summary': 'Send your shippings through Stamps.com and track them online.',
'version': '11.0.1.0.0',
'author': "Hibou Corp.",
'category': 'Warehouse',
'license': 'AGPL-3',
'images': [],
'website': "https://hibou.io",
'description': """
Stamps.com (USPS) Shipping
==========================
Send your shippings through Stamps.com and track them online.
""",
'depends': [
'delivery',
],
'demo': [],
'data': [
'views/delivery_stamps_view.xml',
],
'auto_install': False,
'installable': True,
}

View File

@@ -0,0 +1 @@
from . import delivery_stamps

View File

@@ -0,0 +1,31 @@
Copyright (c) 2014 by Jonathan Zempel.
Some rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* The names of the contributors may not be used to endorse or
promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
"""
stamps
~~~~~~
Stamps.com API.
:copyright: 2014 by Jonathan Zempel.
:license: BSD, see LICENSE for more details.
"""
__author__ = "Jonathan Zempel"
__license__ = "BSD"
__version__ = "0.9.1"

View File

@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""
stamps.config
~~~~~~~~~~~~~
Stamps.com configuration.
:copyright: 2014 by Jonathan Zempel.
:license: BSD, see LICENSE for more details.
"""
from configparser import NoOptionError, NoSectionError, SafeConfigParser
from urllib.request import pathname2url
from urllib.parse import urljoin
import os
VERSION = 49
class StampsConfiguration(object):
"""Stamps service configuration. The service configuration may be provided
directly via parameter values, or it can be read from a configuration file.
If no parameters are given, the configuration will attempt to read from a
``'.stamps.cfg'`` file in the user's HOME directory. Alternately, a
configuration filename can be passed to the constructor.
Here is a sample configuration (by default the constructor reads from a
``'default'`` section)::
[default]
integration_id = XXXXXXXX-1111-2222-3333-YYYYYYYYYYYY
username = stampy
password = secret
:param integration_id: Default `None`. Unique ID, provided by Stamps.com,
that represents your application.
:param username: Default `None`. Stamps.com account username.
:param password: Default `None`. Stamps.com password.
:param wsdl: Default `None`. WSDL URI. Use ``'testing'`` to use the test
server WSDL.
:param port: Default `None`. The name of the WSDL port to use.
:param file_name: Default `None`. Optional configuration file name.
:param section: Default ``'default'``. The configuration section to use.
"""
def __init__(self, integration_id=None, username=None, password=None,
wsdl=None, port=None, file_name=None, section="default"):
parser = SafeConfigParser()
if file_name:
parser.read([file_name])
else:
parser.read([os.path.expanduser("~/.stamps.cfg")])
self.integration_id = self.__get(parser, section, "integration_id",
integration_id)
self.username = self.__get(parser, section, "username", username)
self.password = self.__get(parser, section, "password", password)
self.wsdl = self.__get(parser, section, "wsdl", wsdl)
self.port = self.__get(parser, section, "port", port)
if self.wsdl is None or wsdl == "testing":
file_path = os.path.abspath(__file__)
directory_path = os.path.dirname(file_path)
if wsdl == "testing":
file_name = "stamps_v{0}.test.wsdl".format(VERSION)
else:
file_name = "stamps_v{0}.wsdl".format(VERSION)
wsdl = os.path.join(directory_path, "wsdls", file_name)
self.wsdl = urljoin("file:", pathname2url(wsdl))
if self.port is None:
self.port = "SwsimV{0}Soap12".format(VERSION)
assert self.integration_id
assert self.username
assert self.password
assert self.wsdl
assert self.port
@staticmethod
def __get(parser, section, name, default):
"""Get a configuration value for the named section.
:param parser: The configuration parser.
:param section: The section for the given name.
:param name: The name of the value to retrieve.
"""
if default:
vars = {name: default}
else:
vars = None
try:
ret_val = parser.get(section, name, vars=vars)
except (NoSectionError, NoOptionError):
ret_val = default
return ret_val

View File

@@ -0,0 +1,298 @@
# -*- coding: utf-8 -*-
"""
stamps.services
~~~~~~~~~~~~~~~
Stamps.com services.
:copyright: 2014 by Jonathan Zempel.
:license: BSD, see LICENSE for more details.
"""
from decimal import Decimal
from logging import getLogger
from re import compile
from suds import WebFault
from suds.bindings.document import Document
from suds.client import Client
from suds.plugin import MessagePlugin
from suds.sax.element import Element
from suds.sudsobject import asdict
from suds.xsd.sxbase import XBuiltin
from suds.xsd.sxbuiltin import Factory
PATTERN_HEX = r"[0-9a-fA-F]"
PATTERN_ID = r"{hex}{{8}}-{hex}{{4}}-{hex}{{4}}-{hex}{{4}}-{hex}{{12}}".format(
hex=PATTERN_HEX)
RE_TRANSACTION_ID = compile(PATTERN_ID)
class AuthenticatorPlugin(MessagePlugin):
"""Handle message authentication.
:param credentials: Stamps API credentials.
:param wsdl: Configured service client.
"""
def __init__(self, credentials, client):
self.credentials = credentials
self.client = client
self.authenticator = None
def marshalled(self, context):
"""Add an authenticator token to the document before it is sent.
:param context: The current message context.
"""
body = context.envelope.getChild("Body")
operation = body[0]
if operation.name in ("AuthenticateUser", "RegisterAccount"):
pass
elif self.authenticator:
namespace = operation.namespace()
element = Element("Authenticator", ns=namespace)
element.setText(self.authenticator)
operation.insert(element)
else:
document = Document(self.client.wsdl)
method = self.client.service.AuthenticateUser.method
parameter = document.param_defs(method)[0]
element = document.mkparam(method, parameter, self.credentials)
operation.insert(element)
def unmarshalled(self, context):
"""Store the authenticator token for the next call.
:param context: The current message context.
"""
if hasattr(context.reply, "Authenticator"):
self.authenticator = context.reply.Authenticator
del context.reply.Authenticator
else:
self.authenticator = None
return context
class BaseService(object):
"""Base service.
:param configuration: API configuration.
"""
def __init__(self, configuration):
Factory.maptag("decimal", XDecimal)
self.client = Client(configuration.wsdl)
credentials = self.create("Credentials")
credentials.IntegrationID = configuration.integration_id
credentials.Username = configuration.username
credentials.Password = configuration.password
self.plugin = AuthenticatorPlugin(credentials, self.client)
self.client.set_options(plugins=[self.plugin], port=configuration.port)
self.logger = getLogger("stamps")
def call(self, method, **kwargs):
"""Call the given web service method.
:param method: The name of the web service operation to call.
:param kwargs: Method keyword-argument parameters.
"""
self.logger.debug("%s(%s)", method, kwargs)
instance = getattr(self.client.service, method)
try:
ret_val = instance(**kwargs)
except WebFault as error:
self.logger.warning("Retry %s", method, exc_info=True)
self.plugin.authenticator = None
try: # retry with a re-authenticated user.
ret_val = instance(**kwargs)
except WebFault as error:
self.logger.exception("%s retry failed", method)
self.plugin.authenticator = None
raise error
return ret_val
def create(self, wsdl_type):
"""Create an object of the given WSDL type.
:param wsdl_type: The WSDL type to create an object for.
"""
return self.client.factory.create(wsdl_type)
class StampsService(BaseService):
"""Stamps.com service.
"""
def add_postage(self, amount, transaction_id=None):
"""Add postage to the account.
:param amount: The amount of postage to purchase.
:param transaction_id: Default `None`. ID that may be used to retry the
purchase of this postage.
"""
account = self.get_account()
control = account.AccountInfo.PostageBalance.ControlTotal
return self.call("PurchasePostage", PurchaseAmount=amount,
ControlTotal=control, IntegratorTxID=transaction_id)
def create_add_on(self):
"""Create a new add-on object.
"""
return self.create("AddOnV7")
def create_customs(self):
"""Create a new customs object.
"""
return self.create("CustomsV3")
def create_array_of_customs_lines(self):
"""Create a new array of customs objects.
"""
return self.create("ArrayOfCustomsLine")
def create_customs_lines(self):
"""Create new customs lines.
"""
return self.create("CustomsLine")
def create_address(self):
"""Create a new address object.
"""
return self.create("Address")
def create_purchase_status(self):
"""Create a new purchase status object.
"""
return self.create("PurchaseStatus")
def create_registration(self):
"""Create a new registration object.
"""
ret_val = self.create("RegisterAccount")
ret_val.IntegrationID = self.plugin.credentials.IntegrationID
ret_val.UserName = self.plugin.credentials.Username
ret_val.Password = self.plugin.credentials.Password
return ret_val
def create_shipping(self):
"""Create a new shipping object.
"""
return self.create("RateV18")
def get_address(self, address):
"""Get a shipping address.
:param address: Address instance to get a clean shipping address for.
"""
return self.call("CleanseAddress", Address=address)
def get_account(self):
"""Get account information.
"""
return self.call("GetAccountInfo")
def get_label(self, from_address, to_address, rate, transaction_id, image_type=None,
customs=None, sample=False):
"""Get a shipping label.
:param from_address: The shipping 'from' address.
:param to_address: The shipping 'to' address.
:param rate: A rate instance for the shipment.
:param transaction_id: ID that may be used to retry/rollback the
purchase of this label.
:param customs: A customs instance for international shipments.
:param sample: Default ``False``. Get a sample label without postage.
"""
return self.call("CreateIndicium", IntegratorTxID=transaction_id,
Rate=rate, From=from_address, To=to_address, ImageType=image_type, Customs=customs,
SampleOnly=sample)
def get_postage_status(self, transaction_id):
"""Get postage purchase status.
:param transaction_id: The transaction ID returned by
:meth:`add_postage`.
"""
return self.call("GetPurchaseStatus", TransactionID=transaction_id)
def get_rates(self, shipping):
"""Get shipping rates.
:param shipping: Shipping instance to get rates for.
"""
rates = self.call("GetRates", Rate=shipping)
if rates.Rates:
ret_val = [rate for rate in rates.Rates.Rate]
else:
ret_val = []
return ret_val
def get_tracking(self, transaction_id):
"""Get tracking events for a shipment.
:param transaction_id: The transaction ID (or tracking number) returned
by :meth:`get_label`.
"""
if RE_TRANSACTION_ID.match(transaction_id):
arguments = dict(StampsTxID=transaction_id)
else:
arguments = dict(TrackingNumber=transaction_id)
return self.call("TrackShipment", **arguments)
def register_account(self, registration):
"""Register a new account.
:param registration: Registration instance.
"""
arguments = asdict(registration)
return self.call("RegisterAccount", **arguments)
def remove_label(self, transaction_id):
"""Cancel a shipping label.
:param transaction_id: The transaction ID (or tracking number) returned
by :meth:`get_label`.
"""
if RE_TRANSACTION_ID.match(transaction_id):
arguments = dict(StampsTxID=transaction_id)
else:
arguments = dict(TrackingNumber=transaction_id)
return self.call("CancelIndicium", **arguments)
class XDecimal(XBuiltin):
"""Represents an XSD decimal type.
"""
def translate(self, value, topython=True):
"""Translate between string and decimal values.
:param value: The value to translate.
:param topython: Default `True`. Determine whether to translate the
value for python.
"""
if topython:
if isinstance(value, str) and len(value):
ret_val = Decimal(value)
else:
ret_val = None
else:
if isinstance(value, (int, float, Decimal)):
ret_val = str(value)
else:
ret_val = value
return ret_val

View File

@@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
"""
stamps.tests
~~~~~~~~~~~~
Stamps.com API tests.
:copyright: 2014 by Jonathan Zempel.
:license: BSD, see LICENSE for more details.
"""
from .config import StampsConfiguration
from .services import StampsService
from datetime import date, datetime
from time import sleep
from unittest import TestCase
import logging
import os
logging.basicConfig()
logging.getLogger("suds.client").setLevel(logging.DEBUG)
file_path = os.path.abspath(__file__)
directory_path = os.path.dirname(file_path)
file_name = os.path.join(directory_path, "tests.cfg")
CONFIGURATION = StampsConfiguration(wsdl="testing", file_name=file_name)
def get_rate(service):
"""Get a test rate.
:param service: Instance of the stamps service.
"""
ret_val = service.create_shipping()
ret_val.ShipDate = date.today().isoformat()
ret_val.FromZIPCode = "94107"
ret_val.ToZIPCode = "20500"
ret_val.PackageType = "Package"
rate = service.get_rates(ret_val)[0]
ret_val.Amount = rate.Amount
ret_val.ServiceType = rate.ServiceType
ret_val.DeliverDays = rate.DeliverDays
ret_val.DimWeighting = rate.DimWeighting
ret_val.Zone = rate.Zone
ret_val.RateCategory = rate.RateCategory
ret_val.ToState = rate.ToState
add_on = service.create_add_on()
add_on.AddOnType = "US-A-DC"
ret_val.AddOns.AddOnV7.append(add_on)
return ret_val
def get_from_address(service):
"""Get a test 'from' address.
:param service: Instance of the stamps service.
"""
address = service.create_address()
address.FullName = "Pickwick & Weller"
address.Address1 = "300 Brannan St."
address.Address2 = "Suite 405"
address.City = "San Francisco"
address.State = "CA"
return service.get_address(address).Address
def get_to_address(service):
"""Get a test 'to' address.
:param service: Instance of the stamps service.
"""
address = service.create_address()
address.FullName = "POTUS"
address.Address1 = "1600 Pennsylvania Avenue NW"
address.City = "Washington"
address.State = "DC"
return service.get_address(address).Address
class StampsTestCase(TestCase):
initialized = False
def setUp(self):
if not StampsTestCase.initialized:
self.service = StampsService(CONFIGURATION)
StampsTestCase.initalized = True
def _test_0(self):
"""Test account registration.
"""
registration = self.service.create_registration()
type = self.service.create("CodewordType")
registration.Codeword1Type = type.Last4SocialSecurityNumber
registration.Codeword1 = 1234
registration.Codeword2Type = type.Last4DriversLicense
registration.Codeword2 = 1234
registration.PhysicalAddress = get_from_address(self.service)
registration.MachineInfo.IPAddress = "127.0.0.1"
registration.Email = "sws-support@stamps.com"
type = self.service.create("AccountType")
registration.AccountType = type.OfficeBasedBusiness
result = self.service.register_account(registration)
print result
def _test_1(self):
"""Test postage purchase.
"""
transaction_id = datetime.now().isoformat()
result = self.service.add_postage(10, transaction_id=transaction_id)
transaction_id = result.TransactionID
status = self.service.create_purchase_status()
seconds = 4
while result.PurchaseStatus in (status.Pending, status.Processing):
seconds = 32 if seconds * 2 >= 32 else seconds * 2
print "Waiting {0:d} seconds to get status...".format(seconds)
sleep(seconds)
result = self.service.get_postage_status(transaction_id)
print result
def test_2(self):
"""Test label generation.
"""
self.service = StampsService(CONFIGURATION)
rate = get_rate(self.service)
from_address = get_from_address(self.service)
to_address = get_to_address(self.service)
transaction_id = datetime.now().isoformat()
label = self.service.get_label(from_address, to_address, rate,
transaction_id=transaction_id)
self.service.get_tracking(label.StampsTxID)
self.service.get_tracking(label.TrackingNumber)
self.service.remove_label(label.StampsTxID)
print label
def test_3(self):
"""Test authentication retry.
"""
self.service.get_account()
authenticator = self.service.plugin.authenticator
self.service.get_account()
self.service.plugin.authenticator = authenticator
result = self.service.get_account()
print result

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
from datetime import date
from logging import getLogger
from urllib.request import urlopen
from suds import WebFault
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from .api.config import StampsConfiguration
from .api.services import StampsService
_logger = getLogger(__name__)
STAMPS_PACKAGE_TYPES = [
'Unknown',
'Postcard',
'Letter',
'Large Envelope or Flat',
'Thick Envelope',
'Package',
'Flat Rate Box',
'Small Flat Rate Box',
'Large Flat Rate Box',
'Flat Rate Envelope',
'Flat Rate Padded Envelope',
'Large Package',
'Oversized Package',
'Regional Rate Box A',
'Regional Rate Box B',
'Legal Flat Rate Envelope',
'Regional Rate Box C',
]
class ProductPackaging(models.Model):
_inherit = 'product.packaging'
package_carrier_type = fields.Selection(selection_add=[('stamps', 'Stamps.com')])
stamps_cubic_pricing = fields.Boolean(string="Stamps.com Use Cubic Pricing")
class ProviderStamps(models.Model):
_inherit = 'delivery.carrier'
delivery_type = fields.Selection(selection_add=[('stamps', 'Stamps.com (USPS)')])
stamps_integration_id = fields.Char(string='Stamps.com Integration ID', groups='base.group_system')
stamps_username = fields.Char(string='Stamps.com Username', groups='base.group_system')
stamps_password = fields.Char(string='Stamps.com Password', groups='base.group_system')
stamps_service_type = fields.Selection([('US-FC', 'First-Class'),
('US-PM', 'Priority'),
],
required=True, string="Service Type", default="US-PM")
stamps_default_packaging_id = fields.Many2one('product.packaging', string='Default Package Type')
stamps_image_type = fields.Selection([('Auto', 'Auto'),
('Png', 'PNG'),
('Gif', 'GIF'),
('Pdf', 'PDF'),
('Epl', 'EPL'),
('Jpg', 'JPG'),
('PrintOncePdf', 'Print Once PDF'),
('EncryptedPngUrl', 'Encrypted PNG URL'),
('Zpl', 'ZPL'),
('AZpl', 'AZPL'),
('BZpl', 'BZPL'),
],
required=True, string="Image Type", default="Pdf")
def _stamps_package_type(self, package=None):
if not package:
return self.stamps_default_packaging_id.shipper_package_code
return package.packaging_id.shipper_package_code if package.packaging_id.shipper_package_code in STAMPS_PACKAGE_TYPES else 'Package'
def _stamps_package_is_cubic_pricing(self, package=None):
if not package:
return self.stamps_default_packaging_id.stamps_cubic_pricing
return package.packaging_id.stamps_cubic_pricing
def _stamps_package_dimensions(self, package=None):
if not package:
package_type = self.stamps_default_packaging_id
else:
package_type = package.packaging_id
return package_type.length, package_type.width, package_type.height
def _get_stamps_service(self):
sudoself = self.sudo()
config = StampsConfiguration(integration_id=sudoself.stamps_integration_id,
username=sudoself.stamps_username,
password=sudoself.stamps_password)
return StampsService(configuration=config)
def _stamps_convert_weight(self, weight):
""" weight always expressed in KG """
if self.stamps_default_packaging_id.max_weight and self.stamps_default_packaging_id.max_weight < weight:
raise ValidationError('Stamps cannot ship for weight: ' + str(weight) + 'kgs.')
weight_in_pounds = weight * 2.20462
return weight_in_pounds
def _get_stamps_shipping_for_order(self, service, order, date_planned):
weight = sum([(line.product_id.weight * line.product_qty) for line in order.order_line]) or 0.0
weight = self._stamps_convert_weight(weight)
if not all((order.warehouse_id.partner_id.zip, order.partner_shipping_id.zip)):
raise ValidationError('Stamps needs ZIP. From: ' + str(order.warehouse_id.partner_id.zip) + ' To: ' + str(order.partner_shipping_id.zip))
ret_val = service.create_shipping()
ret_val.ShipDate = date_planned.split()[0] if date_planned else date.today().isoformat()
ret_val.FromZIPCode = order.warehouse_id.partner_id.zip
ret_val.ToZIPCode = order.partner_shipping_id.zip
ret_val.PackageType = self._stamps_package_type()
ret_val.ServiceType = self.stamps_service_type
ret_val.WeightLb = weight
return ret_val
def _get_order_for_picking(self, picking):
if picking.sale_id:
return picking.sale_id
return None
def _get_company_for_order(self, order):
company = order.company_id
if order.team_id and order.team_id.subcompany_id:
company = order.team_id.subcompany_id.company_id
elif order.analytic_account_id and order.analytic_account_id.subcompany_id:
company = order.analytic_account_id.subcompany_id.company_id
return company
def _get_company_for_picking(self, picking):
order = self._get_order_for_picking(picking)
if order:
return self._get_company_for_order(order)
return picking.company_id
def _stamps_get_addresses_for_picking(self, picking):
company = self._get_company_for_picking(picking)
from_ = picking.picking_type_id.warehouse_id.partner_id
to = picking.partner_id
return company, from_, to
def _stamps_get_shippings_for_picking(self, service, picking):
ret = []
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
if not all((from_partner.zip, to_partner.zip)):
raise ValidationError('Stamps needs ZIP. From: ' + str(from_partner.zip) + ' To: ' + str(to_partner.zip))
for package in picking.package_ids:
weight = self._stamps_convert_weight(package.shipping_weight)
l, w, h = self._stamps_package_dimensions(package=package)
ret_val = service.create_shipping()
ret_val.ShipDate = date.today().isoformat()
ret_val.FromZIPCode = from_partner.zip
ret_val.ToZIPCode = to_partner.zip
ret_val.PackageType = self._stamps_package_type(package=package)
ret_val.CubicPricing = self._stamps_package_is_cubic_pricing(package=package)
ret_val.Length = l
ret_val.Width = w
ret_val.Height = h
ret_val.ServiceType = self.stamps_service_type
ret_val.WeightLb = weight
ret.append((package.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
if not ret:
weight = self._stamps_convert_weight(picking.shipping_weight)
l, w, h = self._stamps_package_dimensions()
ret_val = service.create_shipping()
ret_val.ShipDate = date.today().isoformat()
ret_val.FromZIPCode = from_partner.zip
ret_val.ToZIPCode = to_partner.zip
ret_val.PackageType = self._stamps_package_type()
ret_val.CubicPricing = self._stamps_package_is_cubic_pricing()
ret_val.Length = l
ret_val.Width = w
ret_val.Height = h
ret_val.ServiceType = self.stamps_service_type
ret_val.WeightLb = weight
ret.append((picking.name + ret_val.ShipDate + str(ret_val.WeightLb), ret_val))
return ret
def stamps_get_shipping_price_from_so(self, orders):
res = self.stamps_get_shipping_price_for_plan(orders, date.today().isoformat())
return map(lambda r: r[0] if r else 0.0, res)
def stamps_get_shipping_price_for_plan(self, orders, date_planned):
res = []
service = self._get_stamps_service()
for order in orders:
shipping = self._get_stamps_shipping_for_order(service, order, date_planned)
rates = service.get_rates(shipping)
if rates and len(rates) >= 1:
rate = rates[0]
price = float(rate.Amount)
if order.currency_id.name != 'USD':
quote_currency = self.env['res.currency'].search([('name', '=', 'USD')], limit=1)
price = quote_currency.compute(rate.Amount, order.currency_id)
delivery_days = rate.DeliverDays
if delivery_days.find('-') >= 0:
delivery_days = delivery_days.split('-')
transit_days = int(delivery_days[-1])
else:
transit_days = int(delivery_days)
date_delivered = None
if date_planned and transit_days > 0:
date_delivered = self.calculate_date_delivered(date_planned, transit_days)
res = res + [(price, transit_days, date_delivered)]
continue
res = res + [(0.0, 0, None)]
return res
def stamps_rate_shipment(self, order):
self.ensure_one()
result = {
'success': False,
'price': 0.0,
'error_message': 'Error Retrieving Response from Stamps.com',
'warning_message': False
}
date_planned = None
if self.env.context.get('date_planned'):
date_planned = self.env.context.get('date_planned')
rate = self.stamps_get_shipping_price_for_plan(order, date_planned)
if rate:
price, transit_time, date_delivered = rate[0]
result.update({
'success': True,
'price': price,
'error_message': False,
'transit_time': transit_time,
'date_delivered': date_delivered,
})
return result
return result
def stamps_send_shipping(self, pickings):
res = []
service = self._get_stamps_service()
for picking in pickings:
package_labels = []
shippings = self._stamps_get_shippings_for_picking(service, picking)
company, from_partner, to_partner = self._stamps_get_addresses_for_picking(picking)
from_address = service.create_address()
from_address.FullName = company.partner_id.name
from_address.Address1 = from_partner.street
if from_partner.street2:
from_address.Address2 = from_partner.street2
from_address.City = from_partner.city
from_address.State = from_partner.state_id.code
from_address = service.get_address(from_address).Address
to_address = service.create_address()
to_address.FullName = to_partner.name
to_address.Address1 = to_partner.street
if to_partner.street2:
to_address.Address2 = to_partner.street2
to_address.City = to_partner.city
to_address.State = to_partner.state_id.code
to_address = service.get_address(to_address).Address
try:
for txn_id, shipping in shippings:
rates = service.get_rates(shipping)
if rates and len(rates) >= 1:
rate = rates[0]
shipping.Amount = rate.Amount
shipping.ServiceType = rate.ServiceType
shipping.DeliverDays = rate.DeliverDays
shipping.DimWeighting = rate.DimWeighting
shipping.Zone = rate.Zone
shipping.RateCategory = rate.RateCategory
shipping.ToState = rate.ToState
add_on = service.create_add_on()
add_on.AddOnType = 'US-A-DC'
add_on2 = service.create_add_on()
add_on2.AddOnType = 'SC-A-HP'
shipping.AddOns.AddOnV7 = [add_on, add_on2]
label = service.get_label(from_address, to_address, shipping,
transaction_id=txn_id, image_type=self.stamps_image_type)
package_labels.append((txn_id, label))
# self.service.get_tracking(label.StampsTxID)
# self.service.get_tracking(label.TrackingNumber)
# self.service.remove_label(label.StampsTxID)
# print label
except WebFault as e:
_logger.warn(e)
if package_labels:
for name, label in package_labels:
body = 'Cancelling due to error: ' + str(label.TrackingNumber)
try:
service.remove_label(label.TrackingNumber)
except WebFault as e:
raise ValidationError(e)
else:
picking.message_post(body=body)
raise ValidationError('Error on full shipment. Attempted to cancel any previously shipped.')
raise ValidationError('Error on shipment. ' + str(e))
else:
carrier_price = 0.0
tracking_numbers = []
for name, label in package_labels:
body = 'Shipment created into Stamps.com <br/> <b>Tracking Number : <br/>' + label.TrackingNumber + '</b>'
tracking_numbers.append(label.TrackingNumber)
carrier_price += float(label.Rate.Amount)
url = label.URL
response = urlopen(url)
attachment = response.read()
picking.message_post(body=body, attachments=[('LabelStamps-%s.%s' % (label.TrackingNumber, self.stamps_image_type), attachment)])
shipping_data = {'exact_price': carrier_price, 'tracking_number': ','.join(tracking_numbers)}
res = res + [shipping_data]
return res
def stamps_get_tracking_link(self, pickings):
res = []
for picking in pickings:
ref = picking.carrier_tracking_ref
res = res + ['https://tools.usps.com/go/TrackConfirmAction_input?qtc_tLabels1=%s' % ref]
return res
def stamps_cancel_shipment(self, picking):
service = self._get_stamps_service()
try:
service.remove_label(picking.carrier_tracking_ref)
picking.message_post(body=_(u'Shipment N° %s has been cancelled' % picking.carrier_tracking_ref))
picking.write({'carrier_tracking_ref': '',
'carrier_price': 0.0})
except WebFault as e:
raise ValidationError(e)

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_delivery_carrier_form_with_provider_stamps" model="ir.ui.view">
<field name="name">delivery.carrier.form.provider.stamps</field>
<field name="model">delivery.carrier</field>
<field name="inherit_id" ref="delivery.view_delivery_carrier_form"/>
<field name="arch" type="xml">
<xpath expr="//page[@name='destination']" position='before'>
<page string="Stamps.com Configuration" attrs="{'invisible': [('delivery_type', '!=', 'stamps')]}">
<group>
<group>
<field name="stamps_integration_id" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
<field name="stamps_username" attrs="{'required': [('delivery_type', '=', 'stamps')]}" />
<field name="stamps_password" attrs="{'required': [('delivery_type', '=', 'stamps')]}" password="True"/>
</group>
<group>
<field name="stamps_service_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
<field name="stamps_default_packaging_id" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
<field name="stamps_image_type" attrs="{'required': [('delivery_type', '==', 'stamps')]}"/>
</group>
</group>
</page>
</xpath>
</field>
</record>
<record id="product_packaging_delivery_form" model="ir.ui.view">
<field name="name">stamps.product.packaging.form.delivery</field>
<field name="model">product.packaging</field>
<field name="inherit_id" ref="delivery.product_packaging_delivery_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='max_weight']" position='after'>
<field name="stamps_cubic_pricing"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,21 @@
{
'name': 'HR Expense Recruitment',
'version': '11.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'category': 'Human Resources',
'summary': 'Assign Recruitment to expenses for reporting.',
'description': """
Assign Recruitment to expenses for reporting.
""",
'website': 'https://hibou.io/',
'depends': [
'hr_expense',
'hr_recruitment',
],
'data': [
'views/hr_expense_views.xml',
'views/hr_job_views.xml'
],
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1,2 @@
from . import hr_expense_job
from . import hr_job

View File

@@ -0,0 +1,7 @@
from odoo import api, fields, models
class HRExpenseJob(models.Model):
_inherit = 'hr.expense'
job_id = fields.Many2one('hr.job', string='Job')

View File

@@ -0,0 +1,18 @@
from odoo import api, fields, models
class HRJob(models.Model):
_inherit = 'hr.job'
company_currency = fields.Many2one('res.currency', string='Currency',
related='company_id.currency_id', readonly=True)
expense_total_amount = fields.Float(string='Expenses Total',
compute='_compute_expense_total_amount',
compute_sudo=True)
expense_ids = fields.One2many('hr.expense', 'job_id', string='Expenses')
@api.multi
@api.depends('expense_ids.total_amount')
def _compute_expense_total_amount(self):
for job in self:
job.expense_total_amount = sum(job.expense_ids.mapped('total_amount'))

View File

@@ -0,0 +1 @@
from . import test_expenses

View File

@@ -0,0 +1,16 @@
from odoo.tests import common
class TestJobExpense(common.TransactionCase):
def test_fields(self):
job = self.env['hr.job'].create({'name': 'Test Job'})
expense = self.env['hr.expense'].create({
'name': 'Test Expense',
'product_id': self.env['product.product'].search([('can_be_expensed', '=', True)], limit=1).id,
'unit_amount': 34.0,
})
self.assertFalse(job.expense_ids)
self.assertEqual(job.expense_total_amount, 0.0)
expense.job_id = job
self.assertTrue(job.expense_ids)
self.assertEqual(job.expense_total_amount, 34.0)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_hr_expense_form_inherit" model="ir.ui.view">
<field name="name">hr.expense.form.inherit</field>
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.hr_expense_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='employee_id']" position="after">
<field name="job_id"/>
</xpath>
</field>
</record>
<record id="view_hr_expense_filter_inherit" model="ir.ui.view">
<field name="name">hr.expense.filter.inherit</field>
<field name="model">hr.expense</field>
<field name="inherit_id" ref="hr_expense.view_hr_expense_filter"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="job_id"/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_hr_job_form_inherit" model="ir.ui.view">
<field name="name">hr.job.form.inherit</field>
<field name="model">hr.job</field>
<field name="inherit_id" ref="hr.view_hr_job_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet/div[@name='button_box']" position="inside">
<field name="company_currency" invisible="1"/>
<button name="%(hr_expense.hr_expense_actions_all)d" class="oe_stat_button" icon="fa-money" type="action" context="{'search_default_job_id': active_id}">
<div class="o_stat_info">
<field name="expense_total_amount" widget="monetary" nolabel="1" options="{'currency_field': 'company_currency'}"/>
<span class="o_stat_text"> Expenses</span>
</div>
</button>
</xpath>
</field>
</record>
</odoo>

View File

@@ -39,10 +39,11 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
context = dict(self._context or {})
active_ids = context.get('active_ids', [])
payslip = self.env['hr.payslip'].browse(active_ids)
amount = 0.0
for line in payslip.move_id.line_ids:
if line.account_id.internal_type == 'payable' and line.partner_id.id == payslip.employee_id.address_home_id.id:
amount += abs(line.balance)
amount = -sum(payslip.move_id.line_ids.filtered(lambda l: (
l.account_id.internal_type == 'payable'
and l.partner_id.id == payslip.employee_id.address_home_id.id
and not l.reconciled)
).mapped('balance'))
return amount
@api.model
@@ -142,7 +143,7 @@ class HrPayrollRegisterPaymentWizard(models.TransientModel):
if line.account_id.internal_type == 'payable':
account_move_lines_to_reconcile |= line
for line in payslip.move_id.line_ids:
if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id:
if line.account_id.internal_type == 'payable' and line.partner_id.id == self.partner_id.id and not line.reconciled:
account_move_lines_to_reconcile |= line
account_move_lines_to_reconcile.reconcile()

4
hr_payroll_timesheet/__init__.py Executable file → Normal file
View File

@@ -1,3 +1 @@
# -*- coding: utf-8 -*-
from . import hr_payslip
from . import hr_contract
from . import models

View File

@@ -1,18 +1,16 @@
# -*- coding: utf-8 -*-
{
'name': 'Timesheets on Payslips',
'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.',
'version': '11.0.0.0.0',
'description': 'Get Timesheet hours onto Employee Payslips.',
'version': '11.0.1.0.0',
'website': 'https://hibou.io/',
'author': 'Hibou Corp. <hello@hibou.io>',
'license': 'AGPL-3',
'category': 'Human Resources',
'data': [
'hr_contract_view.xml',
'views/hr_contract_view.xml',
],
'depends': [
'hr_payroll',
'hr_timesheet_attendance',
'hr_timesheet',
],
}

View File

@@ -0,0 +1,2 @@
from . import hr_contract
from . import hr_payslip

View File

@@ -0,0 +1,7 @@
from odoo import fields, models
class HrContract(models.Model):
_inherit = 'hr.contract'
paid_hourly_timesheet = fields.Boolean(string="Paid Hourly Timesheet", default=False)

View File

@@ -0,0 +1,99 @@
from datetime import datetime
from collections import defaultdict
from odoo import api, models
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
@api.model
def get_worked_day_lines(self, contracts, date_from, date_to):
work = []
for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet):
# Only run on 'paid hourly timesheet' contracts.
res = self._get_worked_day_lines_hourly_timesheet(contract, date_from, date_to)
if res:
work.append(res)
res = super(HrPayslip, self).get_worked_day_lines(contracts.filtered(lambda c: not c.paid_hourly_timesheet), date_from, date_to)
res.extend(work)
return res
def _get_worked_day_lines_hourly_timesheet(self, contract, date_from, date_to):
"""
This would be a common hook to extend or break out more functionality, like pay rate based on project.
Note that you will likely need to aggregate similarly in hour_break_down() and hour_break_down_week()
:param contract: `hr.contract`
:param date_from: str
:param date_to: str
:return: dict of values for `hr.payslip.worked_days`
"""
values = {
'name': 'Timesheet',
'sequence': 15,
'code': 'TS',
'number_of_days': 0.0,
'number_of_hours': 0.0,
'contract_id': contract.id,
}
valid_ts = [
# ('is_timesheet', '=', True),
# 'is_timesheet' is computed if there is a project_id associated with the entry
('project_id', '!=', False),
('employee_id', '=', contract.employee_id.id),
('date', '>=', date_from),
('date', '<=', date_to),
]
days = set()
for ts in self.env['account.analytic.line'].search(valid_ts):
if ts.unit_amount:
ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT)
ts_iso = ts_date.isocalendar()
if ts_iso not in days:
values['number_of_days'] += 1
days.add(ts_iso)
values['number_of_hours'] += ts.unit_amount
values['number_of_hours'] = round(values['number_of_hours'], 2)
return values
@api.multi
def hour_break_down(self, code):
"""
:param code: what kind of worked days you need aggregated
:return: dict: keys are isocalendar tuples, values are hours.
"""
self.ensure_one()
if code == 'TS':
timesheets = self.env['account.analytic.line'].search([
# ('is_timesheet', '=', True),
# 'is_timesheet' is computed if there is a project_id associated with the entry
('project_id', '!=', False),
('employee_id', '=', self.employee_id.id),
('date', '>=', self.date_from),
('date', '<=', self.date_to),
])
day_values = defaultdict(float)
for ts in timesheets:
if ts.unit_amount:
ts_date = datetime.strptime(ts.date, DEFAULT_SERVER_DATE_FORMAT)
ts_iso = ts_date.isocalendar()
day_values[ts_iso] += ts.unit_amount
return day_values
elif hasattr(super(HrPayslip, self), 'hour_break_down'):
return super(HrPayslip, self).hour_break_down(code)
@api.multi
def hours_break_down_week(self, code):
"""
:param code: hat kind of worked days you need aggregated
:return: dict: keys are isocalendar weeks, values are hours.
"""
days = self.hour_break_down(code)
weeks = defaultdict(float)
for isoday, hours in days.items():
weeks[isoday[1]] += hours
return weeks

View File

@@ -0,0 +1 @@
from . import test_payslip_timesheet

View File

@@ -0,0 +1,87 @@
from odoo.tests import common
from odoo import fields
class TestPayslipTimesheet(common.TransactionCase):
def setUp(self):
super(TestPayslipTimesheet, self).setUp()
self.employee = self.env['hr.employee'].create({
'birthday': '1985-03-14',
'country_id': self.ref('base.us'),
'department_id': self.ref('hr.dep_rd'),
'gender': 'male',
'name': 'Jared'
})
self.contract = self.env['hr.contract'].create({
'name': 'test',
'employee_id': self.employee.id,
'type_id': self.ref('hr_contract.hr_contract_type_emp'),
'struct_id': self.ref('hr_payroll.structure_base'),
'resource_calendar_id': self.ref('resource.resource_calendar_std'),
'wage': 21.50,
'date_start': '2018-01-01',
'state': 'open',
'paid_hourly_timesheet': True,
'schedule_pay': 'monthly',
})
self.project = self.env['project.project'].create({
'name': 'Timesheets',
})
def test_payslip_timesheet(self):
self.assertTrue(self.contract.paid_hourly_timesheet)
from_date = '2018-01-01'
to_date = '2018-01-31'
# Day 1
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'project_id': self.project.id,
'date': '2018-01-01',
'unit_amount': 5.0,
})
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'project_id': self.project.id,
'date': '2018-01-01',
'unit_amount': 3.0,
})
# Day 2
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'project_id': self.project.id,
'date': '2018-01-02',
'unit_amount': 1.0,
})
# Make one that should be excluded.
self.env['account.analytic.line'].create({
'employee_id': self.employee.id,
'project_id': self.project.id,
'date': '2017-01-01',
'unit_amount': 5.0,
})
# Create slip like a batch run.
slip_data = self.env['hr.payslip'].onchange_employee_id(from_date, to_date, self.employee.id, contract_id=False)
res = {
'employee_id': self.employee.id,
'name': slip_data['value'].get('name'),
'struct_id': slip_data['value'].get('struct_id'),
'contract_id': slip_data['value'].get('contract_id'),
'input_line_ids': [(0, 0, x) for x in slip_data['value'].get('input_line_ids')],
'worked_days_line_ids': [(0, 0, x) for x in slip_data['value'].get('worked_days_line_ids')],
'date_from': from_date,
'date_to': to_date,
'company_id': self.employee.company_id.id,
}
payslip = self.env['hr.payslip'].create(res)
payslip.compute_sheet()
self.assertTrue(payslip.worked_days_line_ids)
timesheet_line = payslip.worked_days_line_ids.filtered(lambda l: l.code == 'TS')
self.assertTrue(timesheet_line)
self.assertEqual(timesheet_line.number_of_days, 2.0)
self.assertEqual(timesheet_line.number_of_hours, 9.0)

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_contract_form_inherit" model="ir.ui.view">
<field name="name">hr.contract.form.inherit</field>
<field name="model">hr.contract</field>
<field name="inherit_id" ref="hr_contract.hr_contract_view_form"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='advantages']" position="after">
<field name="paid_hourly_timesheet"/>
</xpath>
<xpath expr="//div[@name='wage']/span" position="replace">
<span attrs="{'invisible': [('paid_hourly_timesheet', '=', True)]}">/ pay period</span>
<span attrs="{'invisible': [('paid_hourly_timesheet', '=', False)]}">/ hour</span>
</xpath>
</data>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import hr_payslip

View File

@@ -0,0 +1,15 @@
{
'name': 'Payroll Timesheet Holidays',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Human Resources',
'sequence': 95,
'summary': 'Holiday Pay',
'description': """
Simplifies getting approved Holiday Leaves onto an employee Payslip.
""",
'website': 'https://hibou.io/',
'depends': ['hr_payroll_timesheet', 'hr_holidays'],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,52 @@
from odoo import models, api
from odoo.addons.hr_holidays.models.hr_holidays import HOURS_PER_DAY
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
@api.model
def get_worked_day_lines(self, contracts, date_from, date_to):
leaves = {}
for contract in contracts.filtered(lambda c: c.paid_hourly_timesheet):
for leave in self._fetch_valid_leaves_timesheet(contract.employee_id.id, date_from, date_to):
leave_code = self._create_leave_code(leave.holiday_status_id.name)
if leave_code in leaves:
leaves[leave_code]['number_of_days'] += leave.number_of_days_temp
leaves[leave_code]['number_of_hours'] += leave.number_of_days_temp * HOURS_PER_DAY
else:
leaves[leave_code] = {
'name': leave.holiday_status_id.name,
'sequence': 15,
'code': leave_code,
'number_of_days': leave.number_of_days_temp,
'number_of_hours': leave.number_of_days_temp * HOURS_PER_DAY,
'contract_id': contract.id,
}
res = super(HrPayslip, self).get_worked_day_lines(contracts, date_from, date_to)
res.extend(leaves.values())
return res
@api.multi
def action_payslip_done(self):
for slip in self.filtered(lambda s: s.contract_id.paid_hourly_timesheet):
leaves = self._fetch_valid_leaves_timesheet(slip.employee_id.id, slip.date_from, slip.date_to)
leaves.write({'payslip_status': True})
return super(HrPayslip, self).action_payslip_done()
def _fetch_valid_leaves_timesheet(self, employee_id, date_from, date_to):
valid_leaves = [
('employee_id', '=', employee_id),
('state', '=', 'validate'),
('date_from', '>=', date_from),
('date_from', '<=', date_to),
('payslip_status', '=', False),
('type', '=', 'remove'),
]
return self.env['hr.holidays'].search(valid_leaves)
def _create_leave_code(self, name):
return 'L_' + name.replace(' ', '_')

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import hr_payslip
from . import hr_contract

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
{
'name': 'Timesheets on Payslips',
'description': 'Get Timesheet and Attendence numbers onto Employee Payslips.',
'version': '11.0.0.0.0',
'website': 'https://hibou.io/',
'author': 'Hibou Corp. <hello@hibou.io>',
'license': 'AGPL-3',
'category': 'Human Resources',
'data': [
'hr_contract_view.xml',
],
'depends': [
'hr_payroll',
'hr_timesheet_attendance',
],
}

26
mrp_production_add/README.rst Executable file
View File

@@ -0,0 +1,26 @@
*******************************
Hibou - MRP Production Add Item
*******************************
Allows a user to add a new item to an in-progress Manufacturing Order (including generating PO procurements).
For more information and add-ons, visit `Hibou.io <https://hibou.io/>`_.
.. image:: https://cloud.githubusercontent.com/assets/744550/20810612/2f3eb514-b7bf-11e6-838f-6d6efb8f7484.png
:alt: 'MRP Production Add'
:width: 988
:align: left
=============
Main Features
=============
* Button above existing Consumed Materials to add new product.
* Uses existing procurement group and routes to procure additional items.
=======
Licence
=======
Please see `LICENSE <https://github.com/hibou-io/hibou-odoo-suite/blob/master/LICENSE>`_.
Copyright Hibou Corp. 2016.

1
mrp_production_add/__init__.py Executable file
View File

@@ -0,0 +1 @@
from . import wizard

View File

@@ -0,0 +1,17 @@
{
'name': 'MRP Production Add Item',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Manufacturing',
'summary': 'Add Items to an existing Production',
'description': """
This module allows a production order to add additional items that are not on the product's BoM.
""",
'website': 'https://hibou.io/',
'depends': ['mrp'],
'data': [
'wizard/additem_wizard_view.xml',
'views/mrp_production.xml',
],
'installable': True,
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data>
<record id="mrp_production_add_production_item_form_view" model="ir.ui.view">
<field name="name">mrp.production.add_production_item.form.view</field>
<field name="model">mrp.production</field>
<field name="inherit_id" ref="mrp.mrp_production_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='move_raw_ids']" position="before">
<group col="2" colspan="2">
<button name="%(action_add_production_item)d"
type="action"
attrs="{'invisible': [('state', 'in', ('cancel', 'done'))]}"
string="Add extra item"
class="oe_highlight"/>
</group>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1 @@
from . import additem_wizard

View File

@@ -0,0 +1,50 @@
from odoo import api, fields, models
from odoo.addons import decimal_precision as dp
from odoo.exceptions import UserError
class AddProductionItem(models.TransientModel):
_name = 'wiz.add.production.item'
_description = 'Add Production Item'
@api.model
def _default_production_id(self):
return self.env.context.get('active_id', False)
product_id = fields.Many2one('product.product', 'Product', required=True)
product_qty = fields.Float(
'Product Quantity', digits=dp.get_precision('Product Unit of Measure'),
required=True,
default=1.0)
product_uom_id = fields.Many2one('product.uom', 'Unit of Measure')
production_id = fields.Many2one(
'mrp.production', 'Production Order',
default=_default_production_id)
@api.onchange('product_id')
def _onchange_product_id(self):
for item in self:
if item.product_id:
item.product_uom_id = item.product_id.uom_id
else:
item.product_uom_id = False
@api.multi
def add_item(self):
for item in self:
if item.product_qty <= 0:
raise UserError('Please provide a positive quantity to add')
bom_line = self.env['mrp.bom.line'].new({
'product_id': item.product_id.id,
'product_qty': item.product_qty,
'bom_id': item.production_id.bom_id.id,
'product_uom_id': item.product_uom_id.id,
})
move = item.production_id._generate_raw_move(bom_line, {'qty': item.product_qty, 'parent_line': None})
item.production_id._adjust_procure_method()
move.write({'unit_factor': 0.0})
move._action_confirm()
return True

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_create_add_production_item" model="ir.ui.view">
<field name="name">view.create.add_production_item</field>
<field name="model">wiz.add.production.item</field>
<field name="type">form</field>
<field name="arch" type="xml">
<form>
<group colspan="4" col="4">
<separator string="Add Product" colspan="4"/>
<field name="product_id" colspan="2"/>
<field name="product_uom_id" colspan="2"/>
<field name="product_qty" colspan="2"/>
<field name="production_id" colspan="2"
invisible="context.get('active_id')"/>
</group>
<footer>
<button class="oe_highlight"
name="add_item"
string="Add"
type="object" />
or
<button class="oe_link"
special="cancel"
string="Cancel" />
</footer>
</form>
</field>
</record>
<record id="action_add_production_item" model="ir.actions.act_window">
<field name="name">Add Item View</field>
<field name="res_model">wiz.add.production.item</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="view_create_add_production_item" />
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,20 @@
{
'name': 'Product Catch Weight',
'version': '11.0.1.0.0',
'category': 'Warehouse',
'depends': [
'sale_stock',
'purchase',
],
'description': """
""",
'author': 'Hibou Corp.',
'license': 'AGPL-3',
'website': 'https://hibou.io/',
'data': [
'views/account_invoice_views.xml',
'views/stock_views.xml',
],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1,4 @@
from . import account_invoice
from . import product
from . import stock_patch
from . import stock

View File

@@ -0,0 +1,48 @@
from odoo import api, fields, models
import logging
_logger = logging.getLogger(__name__)
class AccountInvoiceLine(models.Model):
_inherit = 'account.invoice.line'
catch_weight = fields.Float(string='Catch Weight', digits=(10, 4), compute='_compute_price', store=True)
catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
@api.one
@api.depends('price_unit', 'discount', 'invoice_line_tax_ids', 'quantity',
'product_id', 'invoice_id.partner_id', 'invoice_id.currency_id', 'invoice_id.company_id',
'invoice_id.date_invoice', 'invoice_id.date')
def _compute_price(self):
currency = self.invoice_id and self.invoice_id.currency_id or None
price = self.price_unit * (1 - (self.discount or 0.0) / 100.0)
ratio = 1.0
qty_done_total = 0.0
catch_weight = 0.0
if self.invoice_id.type in ('out_invoice', 'out_refund'):
move_lines = self.sale_line_ids.mapped('move_ids.move_line_ids')
else:
move_lines = self.purchase_line_id.mapped('move_ids.move_line_ids')
for move_line in move_lines:
qty_done = move_line.qty_done
r = move_line.lot_id.catch_weight_ratio
ratio = ((ratio * qty_done_total) + (qty_done * r)) / (qty_done + qty_done_total)
qty_done_total += qty_done
catch_weight += move_line.lot_id.catch_weight
price = price * ratio
self.catch_weight = catch_weight
taxes = False
if self.invoice_line_tax_ids:
taxes = self.invoice_line_tax_ids.compute_all(price, currency, self.quantity, product=self.product_id,
partner=self.invoice_id.partner_id)
self.price_subtotal = price_subtotal_signed = taxes['total_excluded'] if taxes else self.quantity * price
self.price_total = taxes['total_included'] if taxes else self.price_subtotal
if self.invoice_id.currency_id and self.invoice_id.currency_id != self.invoice_id.company_id.currency_id:
price_subtotal_signed = self.invoice_id.currency_id.with_context(
date=self.invoice_id._get_currency_rate_date()).compute(price_subtotal_signed,
self.invoice_id.company_id.currency_id)
sign = self.invoice_id.type in ['in_refund', 'out_refund'] and -1 or 1
self.price_subtotal_signed = price_subtotal_signed * sign

View File

@@ -0,0 +1,7 @@
from odoo import api, fields, models
class ProductProduct(models.Model):
_inherit = 'product.template'
catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')

View File

@@ -0,0 +1,46 @@
from odoo import api, fields, models
class StockProductionLot(models.Model):
_inherit = 'stock.production.lot'
catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), compute='_compute_catch_weight_ratio')
catch_weight = fields.Float(string='Catch Weight', digits=(10, 4))
catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')
@api.depends('catch_weight')
def _compute_catch_weight_ratio(self):
for lot in self:
if not lot.catch_weight_uom_id:
lot.catch_weight_ratio = 1.0
else:
lot.catch_weight_ratio = lot.catch_weight_uom_id._compute_quantity(lot.catch_weight,
lot.product_id.uom_id,
rounding_method='DOWN')
class StockMove(models.Model):
_inherit = 'stock.move'
product_catch_weight_uom_id = fields.Many2one('product.uom', related="product_id.catch_weight_uom_id")
def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
vals = super(StockMove, self)._prepare_move_line_vals(quantity=quantity, reserved_quant=reserved_quant)
vals['catch_weight_uom_id'] = self.product_catch_weight_uom_id.id if self.product_catch_weight_uom_id else False
return vals
def action_show_details(self):
action = super(StockMove, self).action_show_details()
action['context']['show_catch_weight'] = bool(self.product_id.catch_weight_uom_id)
return action
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
catch_weight_ratio = fields.Float(string='Catch Weight Ratio', digits=(10, 6), default=1.0)
catch_weight = fields.Float(string='Catch Weight', digits=(10,4))
catch_weight_uom_id = fields.Many2one('product.uom', string='Catch Weight UOM')
lot_catch_weight = fields.Float(related='lot_id.catch_weight')
lot_catch_weight_uom_id = fields.Many2one('product.uom', related='product_id.catch_weight_uom_id')

View File

@@ -0,0 +1,115 @@
from odoo import fields
from odoo.exceptions import UserError
from odoo.tools.float_utils import float_round, float_compare, float_is_zero
from odoo.addons.stock.models.stock_move_line import StockMoveLine
def _action_done(self):
""" This method is called during a move's `action_done`. It'll actually move a quant from
the source location to the destination location, and unreserve if needed in the source
location.
This method is intended to be called on all the move lines of a move. This method is not
intended to be called when editing a `done` move (that's what the override of `write` here
is done.
"""
# First, we loop over all the move lines to do a preliminary check: `qty_done` should not
# be negative and, according to the presence of a picking type or a linked inventory
# adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink
# the line. It is mandatory in order to free the reservation and correctly apply
# `action_done` on the next move lines.
ml_to_delete = self.env['stock.move.line']
for ml in self:
# Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`.
uom_qty = float_round(ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP')
precision_digits = self.env['decimal.precision'].precision_get('Product Unit of Measure')
qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP')
if float_compare(uom_qty, qty_done, precision_digits=precision_digits) != 0:
raise UserError(_('The quantity done for the product "%s" doesn\'t respect the rounding precision \
defined on the unit of measure "%s". Please change the quantity done or the \
rounding precision of your unit of measure.') % (
ml.product_id.display_name, ml.product_uom_id.name))
qty_done_float_compared = float_compare(ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding)
if qty_done_float_compared > 0:
if ml.product_id.tracking != 'none':
picking_type_id = ml.move_id.picking_type_id
if picking_type_id:
if picking_type_id.use_create_lots:
# If a picking type is linked, we may have to create a production lot on
# the fly before assigning it to the move line if the user checked both
# `use_create_lots` and `use_existing_lots`.
if ml.lot_name and not ml.lot_id:
lot_catch_weight = ml.catch_weight_uom_id._compute_quantity(ml.catch_weight, ml.product_id.catch_weight_uom_id, rounding_method='DOWN')
lot = self.env['stock.production.lot'].create(
{'name': ml.lot_name, 'product_id': ml.product_id.id, 'catch_weight': lot_catch_weight}
)
ml.write({'lot_id': lot.id})
elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots:
# If the user disabled both `use_create_lots` and `use_existing_lots`
# checkboxes on the picking type, he's allowed to enter tracked
# products without a `lot_id`.
continue
elif ml.move_id.inventory_id:
# If an inventory adjustment is linked, the user is allowed to enter
# tracked products without a `lot_id`.
continue
if not ml.lot_id:
raise UserError(_('You need to supply a lot/serial number for %s.') % ml.product_id.name)
elif qty_done_float_compared < 0:
raise UserError(_('No negative quantities allowed'))
else:
ml_to_delete |= ml
ml_to_delete.unlink()
# Now, we can actually move the quant.
done_ml = self.env['stock.move.line']
for ml in self - ml_to_delete:
if ml.product_id.type == 'product':
Quant = self.env['stock.quant']
rounding = ml.product_uom_id.rounding
# if this move line is force assigned, unreserve elsewhere if needed
if not ml.location_id.should_bypass_reservation() and float_compare(ml.qty_done, ml.product_qty,
precision_rounding=rounding) > 0:
extra_qty = ml.qty_done - ml.product_qty
ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id,
package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=done_ml)
# unreserve what's been reserved
if not ml.location_id.should_bypass_reservation() and ml.product_id.type == 'product' and ml.product_qty:
try:
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id,
package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
except UserError:
Quant._update_reserved_quantity(ml.product_id, ml.location_id, -ml.product_qty, lot_id=False,
package_id=ml.package_id, owner_id=ml.owner_id, strict=True)
# move what's been actually done
quantity = ml.product_uom_id._compute_quantity(ml.qty_done, ml.move_id.product_id.uom_id,
rounding_method='HALF-UP')
available_qty, in_date = Quant._update_available_quantity(ml.product_id, ml.location_id, -quantity,
lot_id=ml.lot_id, package_id=ml.package_id,
owner_id=ml.owner_id)
if available_qty < 0 and ml.lot_id:
# see if we can compensate the negative quants with some untracked quants
untracked_qty = Quant._get_available_quantity(ml.product_id, ml.location_id, lot_id=False,
package_id=ml.package_id, owner_id=ml.owner_id,
strict=True)
if untracked_qty:
taken_from_untracked_qty = min(untracked_qty, abs(quantity))
Quant._update_available_quantity(ml.product_id, ml.location_id, -taken_from_untracked_qty,
lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id)
Quant._update_available_quantity(ml.product_id, ml.location_id, taken_from_untracked_qty,
lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id)
Quant._update_available_quantity(ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id,
package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date)
done_ml |= ml
# Reset the reserved quantity as we just moved it to the destination location.
(self - ml_to_delete).with_context(bypass_reservation_update=True).write({
'product_uom_qty': 0.00,
'date': fields.Datetime.now(),
})
StockMoveLine._action_done = _action_done

View File

@@ -0,0 +1 @@
from . import test_catch_weight

View File

@@ -0,0 +1,158 @@
import logging
# from odoo.addons.stock.tests.test_move2 import TestPickShip
from odoo import fields
from odoo.tests.common import TransactionCase
_logger = logging.getLogger(__name__)
class TestPicking(TransactionCase):
def setUp(self):
super(TestPicking, self).setUp()
self.nominal_weight = 50.0
self.partner1 = self.env.ref('base.res_partner_2')
self.stock_location = self.env.ref('stock.stock_location_stock')
self.ref_uom_id = self.env.ref('product.product_uom_kgm')
self.product_uom_id = self.env['product.uom'].create({
'name': '50 ref',
'category_id': self.ref_uom_id.category_id.id,
'uom_type': 'bigger',
'factor_inv': self.nominal_weight,
})
self.product1 = self.env['product.product'].create({
'name': 'Product 1',
'type': 'product',
'tracking': 'serial',
'list_price': 100.0,
'standard_price': 50.0,
'taxes_id': [(5, 0, 0)],
'uom_id': self.product_uom_id.id,
'uom_po_id': self.product_uom_id.id,
'catch_weight_uom_id': self.ref_uom_id.id,
})
# def test_creation(self):
# self.productA.tracking = 'serial'
# lot = self.env['stock.production.lot'].create({
# 'product_id': self.productA.id,
# 'name': '123456789',
# })
#
# lot.catch_weight_ratio = 0.8
# _logger.warn(lot.xxxcatch_weight_ratio)
# def test_delivery(self):
# self.productA.tracking = 'serial'
# picking_pick, picking_pack, picking_ship = self.create_pick_pack_ship()
# stock_location = self.env['stock.location'].browse(self.stock_location)
# lot = self.env['stock.production.lot'].create({
# 'product_id': self.productA.id,
# 'name': '123456789',
# 'catch_weight_ratio': 0.8,
# })
# self.env['stock.quant']._update_available_quantity(self.productA, stock_location, 1.0, lot_id=lot)
def test_so_invoice(self):
ref_weight = 45.0
lot = self.env['stock.production.lot'].create({
'product_id': self.product1.id,
'name': '123456789',
'catch_weight': ref_weight,
})
self.assertAlmostEqual(lot.catch_weight_ratio, ref_weight / self.nominal_weight)
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot)
so = self.env['sale.order'].create({
'partner_id': self.partner1.id,
'partner_invoice_id': self.partner1.id,
'partner_shipping_id': self.partner1.id,
'order_line': [(0, 0, {'product_id': self.product1.id})],
})
so.action_confirm()
self.assertTrue(so.state in ('sale', 'done'))
self.assertEqual(len(so.picking_ids), 1)
picking = so.picking_ids
self.assertEqual(picking.state, 'assigned')
self.assertEqual(picking.move_lines.move_line_ids.lot_id, lot)
picking.move_lines.move_line_ids.qty_done = 1.0
picking.button_validate()
self.assertEqual(picking.state, 'done')
inv_id = so.action_invoice_create()
inv = self.env['account.invoice'].browse(inv_id)
self.assertAlmostEqual(inv.amount_total, lot.catch_weight_ratio * self.product1.list_price)
def test_so_invoice2(self):
ref_weight1 = 45.0
ref_weight2 = 51.0
lot1 = self.env['stock.production.lot'].create({
'product_id': self.product1.id,
'name': '1-low',
'catch_weight': ref_weight1,
})
lot2 = self.env['stock.production.lot'].create({
'product_id': self.product1.id,
'name': '1-high',
'catch_weight': ref_weight2,
})
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot1)
self.env['stock.quant']._update_available_quantity(self.product1, self.stock_location, 1.0, lot_id=lot2)
so = self.env['sale.order'].create({
'partner_id': self.partner1.id,
'partner_invoice_id': self.partner1.id,
'partner_shipping_id': self.partner1.id,
'order_line': [(0, 0, {'product_id': self.product1.id, 'product_uom_qty': 2.0})],
})
so.action_confirm()
self.assertTrue(so.state in ('sale', 'done'))
self.assertEqual(len(so.picking_ids), 1)
picking = so.picking_ids
self.assertEqual(picking.state, 'assigned')
self.assertEqual(picking.move_lines.move_line_ids.mapped('lot_id'), lot1 + lot2)
for line in picking.move_lines.move_line_ids:
line.qty_done = 1.0
picking.button_validate()
self.assertEqual(picking.state, 'done')
inv_id = so.action_invoice_create()
inv = self.env['account.invoice'].browse(inv_id)
self.assertAlmostEqual(inv.amount_total, self.product1.list_price * (lot1.catch_weight_ratio + lot2.catch_weight_ratio))
def test_po_invoice(self):
ref_weight1 = 45.0
ref_weight2 = 51.0
weights = (ref_weight1, ref_weight2)
price = self.product1.standard_price
po = self.env['purchase.order'].create({
'partner_id': self.partner1.id,
'order_line': [(0, 0, {
'product_id': self.product1.id,
'product_qty': 2.0,
'name': 'Test',
'date_planned': fields.Datetime.now(),
'product_uom': self.product1.uom_po_id.id,
'price_unit': price,
})]
})
po.button_confirm()
self.assertEqual(po.state, 'purchase')
self.assertEqual(len(po.picking_ids), 1)
picking = po.picking_ids
for i, line in enumerate(picking.move_lines.move_line_ids):
line.write({'lot_name': str(i), 'qty_done': 1.0, 'catch_weight': weights[i]})
picking.button_validate()
self.assertEqual(picking.state, 'done')
inv = self.env['account.invoice'].create({
'type': 'in_invoice',
'partner_id': self.partner1.id,
'purchase_id': po.id,
})
inv.purchase_order_change()
self.assertEqual(len(inv.invoice_line_ids), 1)
self.assertEqual(inv.invoice_line_ids.quantity, 2.0)
self.assertAlmostEqual(inv.amount_total, price * sum(w / self.nominal_weight for w in weights))

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="invoice_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.form.inherit</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_form"/>
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='price_unit']" position="after">
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
</xpath>
</field>
</record>
<record id="invoice_supplier_form_inherit" model="ir.ui.view">
<field name="name">account.invoice.supplier.form.inherit</field>
<field name="model">account.invoice</field>
<field name="inherit_id" ref="account.invoice_supplier_form"/>
<field name="arch" type="xml">
<xpath expr="//tree/field[@name='price_unit']" position="after">
<field name="catch_weight" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
<field name="catch_weight_uom_id" readonly="1" attrs="{'invisible': [('catch_weight_uom_id', '=', False)]}"/>
</xpath>
</field>
</record>
<template id="report_invoice_document_inherit" name="report_invoice_document_catch_weight" inherit_id="account.report_invoice_document">
<xpath expr="//thead/tr/th[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<th class="text-right">Catch Weight</th>
<th class="text-right">CW Unit Price</th>
</t>
</xpath>
<xpath expr="//tbody/tr[1]/td[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<t t-if="l.catch_weight_uom_id">
<td class="text-right">
<strong t-field="l.catch_weight"/>
<span t-field="l.catch_weight_uom_id"/>
<hr style="padding: 0; margin: 0;"/>
<t t-if="o.type in ('out_invoice', 'out_refund')" t-set="lots" t-value="l.sale_line_ids.mapped('move_ids.move_line_ids.lot_id')"/>
<t t-else="" t-set="lots" t-value="l.purchase_line_id.mapped('move_ids.move_line_ids.lot_id')"/>
<ul class="list-unstyled">
<li t-foreach="lots" t-as="lot">
<span t-field="lot.name"/>: <span t-field="lot.catch_weight"/>
</li>
</ul>
</td>
<td class="text-right">
<span t-esc="'{:0.2f}'.format(l.uom_id._compute_price(l.price_unit, l.catch_weight_uom_id))"/>
/
<span t-field="l.catch_weight_uom_id"/>
</td>
</t>
<t t-else="">
<td/>
<td/>
</t>
</t>
</xpath>
<xpath expr="//tbody/tr[2]/td[4]" position="after">
<t t-if="o.invoice_line_ids.filtered(lambda l: l.catch_weight_uom_id)">
<td/>
<td/>
</t>
</xpath>
</template>
</odoo>

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="view_production_lot_form_inherit" model="ir.ui.view">
<field name="name">stock.production.lot.form.inherit</field>
<field name="model">stock.production.lot</field>
<field name="inherit_id" ref="stock.view_production_lot_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='product_id']" position="after">
<field name="catch_weight_ratio"/>
<field name="catch_weight"/>
<field name="catch_weight_uom_id" readonly="1"/>
</xpath>
</field>
</record>
<!--<record id="view_move_line_form_inherit" model="ir.ui.view">-->
<!--<field name="name">stock.move.line.form.inherit</field>-->
<!--<field name="model">stock.move.line</field>-->
<!--<field name="inherit_id" ref="stock.view_move_line_form" />-->
<!--<field name="arch" type="xml">-->
<!--<xpath expr="//field[@name='lot_name']" position="after">-->
<!--<field name="lot_catch_weight_ratio" readonly="1"/>-->
<!--</xpath>-->
<!--</field>-->
<!--</record>-->
<record id="view_stock_move_operations_inherit" model="ir.ui.view">
<field name="name">stock.move.operations.form.inherit</field>
<field name="model">stock.move</field>
<field name="inherit_id" ref="stock.view_stock_move_operations"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='location_dest_id']" position="after">
<field name="product_catch_weight_uom_id" invisible="1"/>
</xpath>
<xpath expr="//field[@name='move_line_ids']" position="attributes">
<attribute name="context">{'tree_view_ref': 'stock.view_stock_move_line_operation_tree', 'default_product_uom_id': product_uom, 'default_picking_id': picking_id, 'default_move_id': id, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_catch_weight_uom_id': product_catch_weight_uom_id}</attribute>
</xpath>
</field>
</record>
<record id="view_stock_move_line_operation_tree_inherit" model="ir.ui.view">
<field name="name">stock.move.line.operations.tree.inherit</field>
<field name="model">stock.move.line</field>
<field name="inherit_id" ref="stock.view_stock_move_line_operation_tree" />
<field name="arch" type="xml">
<xpath expr="//field[@name='lot_name']" position="after">
<field name="catch_weight" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
<field name="catch_weight_uom_id" invisible="not context.get('show_lots_text') or not context.get('show_catch_weight')"/>
<field name="lot_catch_weight" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
<field name="lot_catch_weight_uom_id" invisible="not context.get('show_lots_m2o') or not context.get('show_catch_weight')" readonly="1"/>
</xpath>
</field>
</record>
<record id="product_template_form_view_inherit" model="ir.ui.view">
<field name="name">product.template.common.form.inherit</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="product.product_template_form_view" />
<field name="arch" type="xml">
<xpath expr="//field[@name='uom_po_id']" position="after">
<field name="catch_weight_uom_id" attrs="{'invisible': [('tracking', '!=', 'serial')]}" help="Leave empty to not use catch weight."/>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1 @@
from . import models

View File

@@ -0,0 +1,20 @@
{
'name': 'Project Task Lines',
'version': '11.0.1.0.0',
'author': 'Hibou Corp. <hello@hibou.io>',
'website': 'https://hibou.io/',
'license': 'AGPL-3',
'category': 'Tools',
'complexity': 'easy',
'description': """
Adds "todo" lines onto Project Tasks, and improves sub-tasks.
""",
'depends': [
'project',
],
'data': [
'views/project_views.xml',
],
'installable': True,
'auto_install': False,
}

View File

@@ -0,0 +1 @@
from . import project

View File

@@ -0,0 +1,39 @@
from odoo import api, fields, models
class ProjectTask(models.Model):
_inherit = 'project.task'
line_ids = fields.One2many('project.task.line', 'task_id', string='Todo List')
subtask_count_done = fields.Integer(compute='_compute_subtask_count', string="Sub-task Done count")
@api.multi
def _compute_subtask_count(self):
for task in self:
task.subtask_count = self.search_count([('id', 'child_of', task.id), ('id', '!=', task.id)])
task.subtask_count_done = self.search_count([('id', 'child_of', task.id), ('id', '!=', task.id), ('stage_id.fold', '=', True)])
class ProjectTaskLine(models.Model):
_name = 'project.task.line'
_description = 'Task Todos'
_order = 'sequence, id desc'
task_id = fields.Many2one('project.task', required=True)
name = fields.Char(string='Name')
user_id = fields.Many2one('res.users', string='User')
sequence = fields.Integer(string='Sequence')
kanban_state = fields.Selection([
('normal', 'Grey'),
('done', 'Green'),
('blocked', 'Red')], string='Kanban State',
copy=False, default='normal', required=True,
help="A task's kanban state indicates special situations affecting it:\n"
" * Grey is the default situation\n"
" * Red indicates something is preventing the progress of this task\n"
" * Green indicates the task is complete")
@api.onchange('kanban_state')
def _onchange_kanban_state(self):
if self.kanban_state == 'done':
self.user_id = self.env.user

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!--<record id="edit_project_inherit" model="ir.ui.view">-->
<!--<field name="name">project.project.form.inherit</field>-->
<!--<field name="model">project.project</field>-->
<!--<field name="inherit_id" ref="project.edit_project" />-->
<!--<field name="arch" type="xml">-->
<!--<xpath expr="//notebook" position="inside">-->
<!--<page name="note_page" string="Notes">-->
<!--<field name="note" nolabel="1" type="html"/>-->
<!--<div class="oe_clear"/>-->
<!--</page>-->
<!--</xpath>-->
<!--</field>-->
<!--</record>-->
<record id="view_task_form2_inherit" model="ir.ui.view">
<field name="name">project.task.form.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2" />
<field name="arch" type="xml">
<xpath expr="//field[@name='subtask_count']" position="replace">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value"><field name="subtask_count_done" widget="statinfo" nolabel="1"/> / <field name="subtask_count" widget="statinfo" nolabel="1"/></span>
<span class="o_stat_text">Sub-Tasks</span>
</div>
</xpath>
<xpath expr="//page[@name='description_page']" position="after">
<page name="task_lines" string="Todo List">
<field name="line_ids" widget="one2many_list">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="kanban_state" widget="state_selection" string="Status"/>
<field name="user_id" string="Completed by" options="{'no_create': True, 'no_create_edit': True, 'no_open': True}"/>
</tree>
</field>
</page>
</xpath>
</field>
</record>
</odoo>

View File

@@ -6,6 +6,12 @@ from datetime import datetime, timedelta
class TestPurchaseBySaleHistory(common.TransactionCase):
def test_00_wizard(self):
wh1 = self.env.ref('stock.warehouse0')
wh2 = self.env['stock.warehouse'].create({
'name': 'WH2',
'code': 'twh2',
})
sale_partner = self.env.ref('base.res_partner_2')
purchase_partner = self.env['res.partner'].create({
'name': 'Purchase Partner',
@@ -74,12 +80,13 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
'history_start': history_start,
'history_end': history_end,
'procure_days': days,
'history_warehouse_ids': [(4, wh1.id, None)],
})
self.assertEqual(wiz.history_days, days)
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 3.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 3.0 + 3.0) # 3 from Sales History, 3 from Demand (from the sale)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 3.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 3.0 + 3.0)
# Make additional sales history...
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
@@ -96,9 +103,29 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 + 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 6.0)
# Make additional sales history in other warehouse
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
self.env['sale.order'].create({
'partner_id': sale_partner.id,
'date_order': sale_date,
'confirmation_date': sale_date,
'picking_policy': 'direct',
'warehouse_id': wh2.id,
'order_line': [
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
],
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 + 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 6.0)
# Make additional sales history that should NOT be counted...
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=61))
@@ -115,19 +142,39 @@ class TestPurchaseBySaleHistory(common.TransactionCase):
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 + 9.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 9.0)
# Test that the wizard will only use the existing PO line products now that we have lines.
po1.order_line.filtered(lambda l: l.product_id == product2).unlink()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 + 9.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 0.0)
# Plan for 1/2 the days of inventory
wiz.procure_days = days / 2.0
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 6.0 / 2.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 3.0 + 9.0)
# Cause Inventory on existing product to make sure we don't order it.
adjust_product11 = self.env['stock.inventory'].create({
'name': 'Product11',
'location_id': wh1.lot_stock_id.id,
'product_id': product11.id,
'filter': 'product',
})
adjust_product11.action_start()
adjust_product11.line_ids.create({
'inventory_id': adjust_product11.id,
'product_id': product11.id,
'product_qty': 100.0,
'location_id': wh1.lot_stock_id.id,
})
adjust_product11.action_done()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0) # Because we have so much in stock now.
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 0.0)

View File

@@ -16,6 +16,11 @@ class PurchaseBySaleHistory(models.TransientModel):
'and then multiplied by the days you wish to procure for.')
product_count = fields.Integer(string='Product Count', compute='_compute_product_count',
help='Products on the PO or that the Vendor provides.')
history_warehouse_ids = fields.Many2many('stock.warehouse', string='Warehouses',
help='Sales are calculated by these warehouses. '
'Current Inventory is summed from these warehouses. '
'If it is left blank then all warehouses and inventory '
'will be considered.')
@api.multi
@api.depends('history_start', 'history_end')
@@ -54,18 +59,48 @@ class PurchaseBySaleHistory(models.TransientModel):
return [r[0] for r in rows if r[0]]
def _sale_history(self, product_ids):
self.env.cr.execute("""SELECT product_id, sum(product_uom_qty)
FROM sale_report
WHERE date BETWEEN %s AND %s AND product_id IN %s
GROUP BY 1""", (self.history_start, self.history_end, tuple(product_ids)))
p_ids = tuple(product_ids)
if self.history_warehouse_ids:
wh_ids = tuple(self.history_warehouse_ids.ids)
self.env.cr.execute("""SELECT product_id, sum(product_uom_qty)
FROM sale_report
WHERE date BETWEEN %s AND %s AND product_id IN %s AND warehouse_id IN %s
GROUP BY 1""",
(self.history_start, self.history_end, p_ids, wh_ids))
else:
self.env.cr.execute("""SELECT product_id, sum(product_uom_qty)
FROM sale_report
WHERE date BETWEEN %s AND %s AND product_id IN %s
GROUP BY 1""",
(self.history_start, self.history_end, p_ids))
return self.env.cr.fetchall()
def _apply_history(self, history):
def _apply_history_product(self, product, history):
qty = ceil(history['sold_qty'] * self.procure_days / self.history_days)
history['buy_qty'] = max((0.0, qty - product.virtual_available))
def _apply_history(self, history, product_ids):
line_model = self.env['purchase.order.line']
updated_lines = line_model.browse()
for pid, sold_qty in history:
# Collect stock to consider against the sales demand.
product_model = self.env['product.product']
if self.history_warehouse_ids:
product_model = self.env['product.product']\
.with_context({'location': [wh.lot_stock_id.id for wh in self.history_warehouse_ids]})
products = product_model.browse(product_ids)
#product_available_stock = {p.id: p.virtual_available for p in products}
history_dict = {pid: {'sold_qty': sold_qty} for pid, sold_qty in history}
for p in products:
if p.id not in history_dict:
history_dict[p.id] = {'sold_qty': 0.0}
self._apply_history_product(p, history_dict[p.id])
for pid, history in history_dict.items():
# TODO: Should convert from Sale UOM to Purchase UOM
qty = ceil(sold_qty * self.procure_days / self.history_days)
qty = history.get('buy_qty', 0.0)
# Find line that already exists on PO
line = self.purchase_id.order_line.filtered(lambda l: l.product_id.id == pid)
if line:
@@ -95,6 +130,6 @@ class PurchaseBySaleHistory(models.TransientModel):
self.ensure_one()
history_product_ids = self._history_product_ids()
history = self._sale_history(history_product_ids)
self._apply_history(history)
self._apply_history(history, history_product_ids)

View File

@@ -13,6 +13,7 @@
<field name="history_start"/>
<field name="history_end"/>
<field name="history_days"/>
<field name="history_warehouse_ids" widget="many2many_tags"/>
</group>
<group>
<field name="procure_days"/>

View File

@@ -0,0 +1 @@
from . import wizard

View File

@@ -0,0 +1,20 @@
{
'name': 'Purchase by Sale History MRP',
'author': 'Hibou Corp. <hello@hibou.io>',
'version': '11.0.1.0.0',
'category': 'Purchases',
'sequence': 95,
'summary': 'Buy Raw materials based on sales from Finished products.',
'description': """
Buy Raw materials based on sales from Finished products.
""",
'website': 'https://hibou.io/',
'depends': [
'purchase_by_sale_history',
'mrp',
],
'data': [
],
'installable': True,
'application': False,
}

View File

@@ -0,0 +1 @@
from . import test_purchase_by_sale_history

View File

@@ -0,0 +1,203 @@
from odoo.addons.purchase_by_sale_history.tests import test_purchase_by_sale_history
from odoo import fields
from datetime import datetime, timedelta
import logging
_logger = logging.getLogger(__name__)
class MTestPurchaseBySaleHistoryMRP(test_purchase_by_sale_history.TestPurchaseBySaleHistory):
def test_00_wizard(self):
wh1 = self.env.ref('stock.warehouse0')
wh2 = self.env['stock.warehouse'].create({
'name': 'WH2',
'code': 'twh2',
})
sale_partner = self.env.ref('base.res_partner_2')
purchase_partner = self.env['res.partner'].create({
'name': 'Purchase Partner',
})
product11 = self.env['product.product'].create({
'name': 'Product 1',
'type': 'product',
})
product12 = self.env['product.product'].create({
'name': 'Product 1.1',
'type': 'product',
'product_tmpl_id': product11.product_tmpl_id.id,
})
product2 = self.env['product.product'].create({
'name': 'Product 2',
'type': 'product',
})
po1 = self.env['purchase.order'].create({
'partner_id': purchase_partner.id,
})
product_raw = self.env['product.product'].create({
'name': 'Product Raw',
'type': 'product',
})
_logger.warn('product_raw: ' + str(product_raw))
product11_bom = self.env['mrp.bom'].create({
'product_tmpl_id': product11.product_tmpl_id.id,
'product_qty': 1.0,
'product_uom_id': product11.uom_id.id,
'bom_line_ids': [(0, 0, {
'product_id': product_raw.id,
'product_qty': 2.0,
'product_uom_id': product_raw.uom_id.id,
})]
})
# Create initial wizard, it won't apply to any products because the PO is empty, and the vendor
# doesn't supply any products yet.
wiz = self.env['purchase.sale.history.make'].create({
'purchase_id': po1.id,
})
self.assertEqual(wiz.product_count, 0.0, 'There shouldn\'t be any products for this vendor yet.')
# Assign vendor to products created earlier.
self.env['product.supplierinfo'].create({
'name': purchase_partner.id,
'product_tmpl_id': product_raw.product_tmpl_id.id,
'product_id': product_raw.id,
})
self.env['product.supplierinfo'].create({
'name': purchase_partner.id,
'product_tmpl_id': product2.product_tmpl_id.id,
})
# New wizard picks up the correct number of products supplied by this vendor.
wiz = self.env['purchase.sale.history.make'].create({
'purchase_id': po1.id,
})
self.assertEqual(wiz.product_count, 2)
# Make some sales history...
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=30))
self.env['sale.order'].create({
'partner_id': sale_partner.id,
'date_order': sale_date,
'confirmation_date': sale_date,
'picking_policy': 'direct',
'order_line': [
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
],
}).action_confirm()
days = 60
history_start = fields.Date.to_string(datetime.now() - timedelta(days=days))
history_end = fields.Date.today()
wiz.write({
'history_start': history_start,
'history_end': history_end,
'procure_days': days,
'history_warehouse_ids': [(4, wh1.id, None)],
})
self.assertEqual(wiz.history_days, days)
wiz.action_confirm()
self.assertTrue(po1.order_line.filtered(lambda l: l.product_id == product_raw))
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 24.0) # x
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 3.0 + 3.0)
# Make additional sales history...
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
self.env['sale.order'].create({
'partner_id': sale_partner.id,
'date_order': sale_date,
'confirmation_date': sale_date,
'picking_policy': 'direct',
'order_line': [
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
],
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 48.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 6.0)
# Make additional sales history in other warehouse
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=15))
self.env['sale.order'].create({
'partner_id': sale_partner.id,
'date_order': sale_date,
'confirmation_date': sale_date,
'picking_policy': 'direct',
'warehouse_id': wh2.id,
'order_line': [
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
],
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 48.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 6.0)
# Make additional sales history that should NOT be counted...
sale_date = fields.Datetime.to_string(datetime.now() - timedelta(days=61))
self.env['sale.order'].create({
'partner_id': sale_partner.id,
'date_order': sale_date,
'confirmation_date': sale_date,
'picking_policy': 'direct',
'order_line': [
(0, 0, {'product_id': product11.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product12.id, 'product_uom_qty': 3.0}),
(0, 0, {'product_id': product2.id, 'product_uom_qty': 3.0}),
],
}).action_confirm()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 60.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 6.0 + 9.0)
# Test that the wizard will only use the existing PO line products now that we have lines.
po1.order_line.filtered(lambda l: l.product_id == product2).unlink()
# Plan for 1/2 the days of inventory
wiz.procure_days = days / 2.0
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 48.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
# Cause Inventory on existing product to make sure we don't order it.
adjust_product11 = self.env['stock.inventory'].create({
'name': 'Product11',
'location_id': wh1.lot_stock_id.id,
'product_id': product11.id,
'filter': 'product',
})
adjust_product11.action_start()
adjust_product11.line_ids.create({
'inventory_id': adjust_product11.id,
'product_id': product11.id,
'product_qty': 100.0,
'location_id': wh1.lot_stock_id.id,
})
adjust_product11.action_done()
wiz.action_confirm()
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product_raw).product_qty, 24.0) # No longer have needs on product11 but we still have them for product12
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product11).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product12).product_qty, 0.0)
self.assertEqual(po1.order_line.filtered(lambda l: l.product_id == product2).product_qty, 0.0)

View File

@@ -0,0 +1 @@
from . import purchase_by_sale_history

View File

@@ -0,0 +1,60 @@
from odoo import api, fields, models
from odoo.exceptions import UserError
class PurchaseBySaleHistory(models.TransientModel):
_inherit = 'purchase.sale.history.make'
def _apply_history_product(self, product, history):
# Override to recursively collect sales and forcast for products produced by this product.
bom_line_model = self.env['mrp.bom.line']
product_model = self.env['product.product']
if self.history_warehouse_ids:
product_model = self.env['product.product'] \
.with_context({'location': [wh.lot_stock_id.id for wh in self.history_warehouse_ids]})
visited_product_ids = set()
def bom_parent_product_ids(product):
if product.id in visited_product_ids:
# Cycle detected
return 0.0
visited_product_ids.add(product.id)
bom_lines = bom_line_model.search([('product_id', '=', product.id), ('bom_id.active', '=', True)])
if not bom_lines:
# Recursive Basecase
return 0.0
product_ids = set()
for line in bom_lines:
product_ids |= bom_parent_product_ids_line(line)
product_ids_dict = {}
for pid, ratio in product_ids:
if pid in product_ids_dict:
raise UserError('You cannot have two identical finished goods being created from different ratios.')
product_ids_dict[pid] = {'ratio': ratio}
history = self._sale_history(product_ids_dict.keys())
products = product_model.browse(product_ids_dict.keys())
for pid, sold_qty in history:
product_ids_dict[pid]['sold_qty'] = sold_qty
for p in products:
qty = product_ids_dict[p.id].get('sold_qty', 0.0) * self.procure_days / self.history_days
product_ids_dict[p.id]['buy_qty'] = max((0.0, qty - p.virtual_available))
product_ids_dict[p.id]['buy_qty'] += bom_parent_product_ids(p)
product_ids_dict[p.id]['buy_qty'] *= product_ids_dict[p.id].get('ratio', 1.0)
return sum(vals['buy_qty'] for vals in product_ids_dict.values())
def bom_parent_product_ids_line(line):
product_ids = set()
if line.bom_id.product_id:
product_ids.add((line.bom_id.product_id, line.product_qty))
else:
for p in line.bom_id.product_tmpl_id.product_variant_ids:
product_ids.add((p.id, line.product_qty))
return product_ids
super(PurchaseBySaleHistory, self)._apply_history_product(product, history)
history['buy_qty'] += bom_parent_product_ids(product)

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="purchase_sale_history_make_form" model="ir.ui.view">
<field name="name">purchase.sale.history.make.form</field>
<field name="model">purchase.sale.history.make</field>
<field name="arch" type="xml">
<form string="Fill PO From Sales History">
<sheet>
<field name="id" invisible="1"/>
<field name="purchase_id" invisible="1"/>
<group>
<group>
<field name="history_start"/>
<field name="history_end"/>
<field name="history_days"/>
<field name="history_warehouse_ids" widget="many2many_tags"/>
</group>
<group>
<field name="procure_days"/>
<field name="product_count"/>
</group>
</group>
</sheet>
<footer>
<button string='Run' name="action_confirm" type="object" class="btn-primary"/>
<button string="Cancel" class="btn-default" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="purchase_sale_history_make_action" model="ir.actions.act_window">
<field name="name">Fill PO From Sales History</field>
<field name="res_model">purchase.sale.history.make</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="purchase_sale_history_make_form"/>
<field name="target">new</field>
<field name="context">{'default_purchase_id': active_id}</field>
</record>
<!-- Button on Purchase Order to launch wizard -->
<record id="purchase_order_form_inherit" model="ir.ui.view">
<field name="name">purchase.order.form.inherit</field>
<field name="model">purchase.order</field>
<field name="inherit_id" ref="purchase.purchase_order_form"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='button_confirm']" position="after">
<button name="%(purchase_by_sale_history.purchase_sale_history_make_action)d" type="action" string="Fill by Sales" attrs="{'invisible': ['|', ('state', 'in', ('purchase', 'done', 'cancel')), ('partner_id', '=', False)]}"/>
</xpath>
</field>
</record>
</odoo>

1
purchase_exception Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_exception

1
purchase_minimum_amount Symbolic link
View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_minimum_amount

View File

@@ -0,0 +1 @@
external/hibou-oca/purchase-workflow/purchase_order_approval_block

View File

@@ -9,5 +9,18 @@ class StockPicking(models.Model):
def send_to_shipper(self):
res = False
for pick in self.filtered(lambda p: not p.carrier_tracking_ref):
# deliver full order if no items are done.
pick_has_no_done = sum(pick.move_line_ids.mapped('qty_done')) == 0
if pick_has_no_done:
pick._rma_complete()
res = super(StockPicking, pick).send_to_shipper()
if pick_has_no_done:
pick._rma_complete_reverse()
return res
def _rma_complete(self):
for line in self.move_line_ids:
line.qty_done = line.product_uom_qty
def _rma_complete_reverse(self):
self.move_line_ids.write({'qty_done': 0.0})

Some files were not shown because too many files have changed in this diff Show More