[IMP] {purchase, sale}_stock, product: packaging revamp
1. add packaging to PO lines 2. packaging on PO/SO lines can be propagate to MO 3. add package type to packaging 4. on picking types, we can choose to only reserve full packaging. That means if you want 1 pallet(100 units) and you have 50 units in stock. It won't be reserved. 5. suggest suitable packaging for PO/SO/MO line according to the product qty Task-2357259 PR #68654 UPG PR odoo/upgrade#2444
This commit is contained in:
@@ -9,7 +9,7 @@ from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
from odoo.tools import float_compare
|
||||
from odoo.tools import float_compare, float_round
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -719,17 +719,59 @@ class ProductProduct(models.Model):
|
||||
class ProductPackaging(models.Model):
|
||||
_name = "product.packaging"
|
||||
_description = "Product Packaging"
|
||||
_order = 'sequence'
|
||||
_order = 'product_id, sequence, id'
|
||||
_check_company_auto = True
|
||||
|
||||
name = fields.Char('Package Type', required=True)
|
||||
name = fields.Char('Product Packaging', required=True)
|
||||
sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.")
|
||||
product_id = fields.Many2one('product.product', string='Product', check_company=True)
|
||||
qty = fields.Float('Contained Quantity', help="Quantity of products contained in the packaging.")
|
||||
qty = fields.Float('Contained Quantity', default=1, help="Quantity of products contained in the packaging.")
|
||||
barcode = fields.Char('Barcode', copy=False, help="Barcode used for packaging identification. Scan this packaging barcode from a transfer in the Barcode app to move all the contained units")
|
||||
product_uom_id = fields.Many2one('uom.uom', related='product_id.uom_id', readonly=True)
|
||||
company_id = fields.Many2one('res.company', 'Company', index=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('positive_qty', 'CHECK(qty > 0)', 'Contained Quantity should be positive.')
|
||||
]
|
||||
|
||||
def _check_qty(self, product_qty, uom_id, rounding_method="HALF-UP"):
|
||||
"""Check if product_qty in given uom is a multiple of the packaging qty.
|
||||
If not, rounding the product_qty to closest multiple of the packaging qty
|
||||
according to the rounding_method "UP", "HALF-UP or "DOWN".
|
||||
"""
|
||||
self.ensure_one()
|
||||
default_uom = self.product_id.uom_id
|
||||
packaging_qty = default_uom._compute_quantity(self.qty, uom_id)
|
||||
# We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity
|
||||
# per package might be a float, leading to incorrect results. For example:
|
||||
# 8 % 1.6 = 1.5999999999999996
|
||||
# 5.4 % 1.8 = 2.220446049250313e-16
|
||||
if (
|
||||
product_qty
|
||||
and packaging_qty
|
||||
and float_compare(
|
||||
product_qty / packaging_qty,
|
||||
float_round(product_qty / packaging_qty, precision_rounding=1.0),
|
||||
precision_rounding=default_uom.rounding
|
||||
)
|
||||
!= 0
|
||||
):
|
||||
return float_round(
|
||||
product_qty / packaging_qty, precision_rounding=1.0, rounding_method=rounding_method
|
||||
) * packaging_qty
|
||||
return product_qty
|
||||
|
||||
def _find_suitable_product_packaging(self, product_qty, uom_id):
|
||||
""" try find in `self` if a packaging's qty in given uom is a divisor of
|
||||
the given product_qty. If so, return the one with greatest divisor.
|
||||
"""
|
||||
packagings = self.sorted(lambda p: p.qty, reverse=True)
|
||||
for packaging in packagings:
|
||||
new_qty = packaging._check_qty(product_qty, uom_id)
|
||||
if new_qty == product_qty:
|
||||
return packaging
|
||||
return self.env['product.packaging']
|
||||
|
||||
|
||||
class SupplierInfo(models.Model):
|
||||
_name = "product.supplierinfo"
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
colspan="4"
|
||||
attrs="{'invisible':['|', ('type', 'not in', ['product', 'consu']), ('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}"
|
||||
groups="product.group_stock_packaging">
|
||||
<field name="packaging_ids" nolabel="1" context="{'tree_view_ref':'product.product_packaging_tree_view2', 'form_view_ref':'product.product_packaging_form_view2'}"/>
|
||||
<field name="packaging_ids" nolabel="1" context="{'tree_view_ref':'product.product_packaging_tree_view2'}"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
@@ -268,7 +268,7 @@
|
||||
</group>
|
||||
<group name="packaging" string="Packaging" groups="product.group_stock_packaging">
|
||||
<field name="packaging_ids" nolabel="1"
|
||||
context="{'tree_view_ref':'product.product_packaging_tree_view2', 'form_view_ref':'product.product_packaging_form_view2'}"/>
|
||||
context="{'tree_view_ref':'product.product_packaging_tree_view2'}"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
@@ -501,13 +501,14 @@
|
||||
<field name="name">product.packaging.tree.view</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Product Packagings">
|
||||
<tree string="Product Packagings" name="packaging">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="product_id"/>
|
||||
<field name="name" string="Packaging"/>
|
||||
<field name="qty"/>
|
||||
<field name="product_uom_id" options="{'no_open': True, 'no_create': True}" groups="uom.group_uom"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="barcode" optional="hide"/>
|
||||
<field name="company_id" groups="base.group_multi_company" optional="hide"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
@@ -519,7 +520,9 @@
|
||||
<field name="inherit_id" ref="product.product_packaging_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_id']" position="replace"/>
|
||||
<xpath expr="//field[@name='sequence']" position="replace"/>
|
||||
<xpath expr="//tree[@name='packaging']" position="attributes">
|
||||
<attribute name="editable">bottom</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
'views/portal_templates.xml',
|
||||
'report/purchase_order_templates.xml',
|
||||
'report/purchase_quotation_templates.xml',
|
||||
'views/product_packaging_views.xml',
|
||||
],
|
||||
'demo': [
|
||||
'data/purchase_demo.xml',
|
||||
|
||||
@@ -94,3 +94,9 @@ class ProductSupplierinfo(models.Model):
|
||||
@api.onchange('name')
|
||||
def _onchange_name(self):
|
||||
self.currency_id = self.name.property_purchase_currency_id.id or self.env.company.currency_id.id
|
||||
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
purchase = fields.Boolean("Purchase", default=True, help="If true, the packaging can be used for purchase orders")
|
||||
|
||||
@@ -12,7 +12,7 @@ from werkzeug.urls import url_encode
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
|
||||
from odoo.tools.float_utils import float_is_zero
|
||||
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
|
||||
from odoo.exceptions import AccessError, UserError, ValidationError
|
||||
from odoo.tools.misc import formatLang, get_lang
|
||||
|
||||
@@ -885,6 +885,8 @@ class PurchaseOrderLine(models.Model):
|
||||
partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True)
|
||||
currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True)
|
||||
date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True)
|
||||
product_packaging_id = fields.Many2one('product.packaging', string='Packaging', domain="[('purchase', '=', True), ('product_id', '=', product_id)]", check_company=True)
|
||||
product_packaging_qty = fields.Float('Packaging Quantity')
|
||||
|
||||
display_type = fields.Selection([
|
||||
('line_section', "Section"),
|
||||
@@ -1151,6 +1153,51 @@ class PurchaseOrderLine(models.Model):
|
||||
|
||||
self.price_unit = price_unit
|
||||
|
||||
@api.onchange('product_id', 'product_qty', 'product_uom')
|
||||
def _onchange_suggest_packaging(self):
|
||||
# remove packaging if not match the product
|
||||
if self.product_packaging_id.product_id != self.product_id:
|
||||
self.product_packaging_id = False
|
||||
# suggest biggest suitable packaging
|
||||
if self.product_id and self.product_qty and self.product_uom:
|
||||
self.product_packaging_id = self.product_id.packaging_ids.filtered('purchase')._find_suitable_product_packaging(self.product_qty, self.product_uom)
|
||||
|
||||
@api.onchange('product_packaging_id')
|
||||
def _onchange_product_packaging_id(self):
|
||||
if self.product_packaging_id and self.product_qty:
|
||||
newqty = self.product_packaging_id._check_qty(self.product_qty, self.product_uom, "UP")
|
||||
if float_compare(newqty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Warning'),
|
||||
'message': _(
|
||||
"This product is packaged by %(pack_size).2f %(pack_name)s. You should purchase %(quantity).2f %(unit)s.",
|
||||
pack_size=self.product_packaging_id.qty,
|
||||
pack_name=self.product_id.uom_id.name,
|
||||
quantity=newqty,
|
||||
unit=self.product_uom.name
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@api.onchange('product_packaging_id', 'product_uom', 'product_qty')
|
||||
def _onchange_update_product_packaging_qty(self):
|
||||
if not self.product_packaging_id:
|
||||
self.product_packaging_qty = 0
|
||||
else:
|
||||
packaging_uom = self.product_packaging_id.product_uom_id
|
||||
packaging_uom_qty = self.product_uom._compute_quantity(self.product_qty, packaging_uom)
|
||||
self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding)
|
||||
|
||||
@api.onchange('product_packaging_qty')
|
||||
def _onchange_product_packaging_qty(self):
|
||||
if self.product_packaging_id:
|
||||
packaging_uom = self.product_packaging_id.product_uom_id
|
||||
qty_per_packaging = self.product_packaging_id.qty
|
||||
product_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom)
|
||||
if float_compare(product_qty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||||
self.product_qty = product_qty
|
||||
|
||||
@api.depends('product_uom', 'product_qty', 'product_id.uom_id')
|
||||
def _compute_product_uom_qty(self):
|
||||
for line in self:
|
||||
|
||||
@@ -173,3 +173,45 @@ class TestPurchase(AccountTestInvoicingCommon):
|
||||
'<p> - product_b from 2020-06-06 to %(today)s</p>' % {'today': fields.Date.today()},
|
||||
activity.note,
|
||||
)
|
||||
|
||||
def test_onchange_packaging_00(self):
|
||||
"""Create a PO and use packaging. Check we suggested suitable packaging
|
||||
according to the product_qty. Also check product_qty or product_packaging
|
||||
are correctly calculated when one of them changed.
|
||||
"""
|
||||
packaging_single = self.env['product.packaging'].create({
|
||||
'name': "I'm a packaging",
|
||||
'product_id': self.product_a.id,
|
||||
'qty': 1.0,
|
||||
})
|
||||
packaging_dozen = self.env['product.packaging'].create({
|
||||
'name': "I'm also a packaging",
|
||||
'product_id': self.product_a.id,
|
||||
'qty': 12.0,
|
||||
})
|
||||
|
||||
po = self.env['purchase.order'].create({
|
||||
'partner_id': self.partner_a.id,
|
||||
})
|
||||
po_form = Form(po)
|
||||
with po_form.order_line.new() as line:
|
||||
line.product_id = self.product_a
|
||||
line.product_qty = 1.0
|
||||
po_form.save()
|
||||
self.assertEqual(po.order_line.product_packaging_id, packaging_single)
|
||||
self.assertEqual(po.order_line.product_packaging_qty, 1.0)
|
||||
with po_form.order_line.edit(0) as line:
|
||||
line.product_packaging_qty = 2.0
|
||||
po_form.save()
|
||||
self.assertEqual(po.order_line.product_qty, 2.0)
|
||||
|
||||
|
||||
with po_form.order_line.edit(0) as line:
|
||||
line.product_qty = 24.0
|
||||
po_form.save()
|
||||
self.assertEqual(po.order_line.product_packaging_id, packaging_dozen)
|
||||
self.assertEqual(po.order_line.product_packaging_qty, 2.0)
|
||||
with po_form.order_line.edit(0) as line:
|
||||
line.product_packaging_qty = 1.0
|
||||
po_form.save()
|
||||
self.assertEqual(po.order_line.product_qty, 12)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="product_packaging_form_view_purchase" model="ir.ui.view">
|
||||
<field name="name">product.packaging.form.view.purchase</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='group_product']" position="inside">
|
||||
<field name="purchase"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_packaging_tree_view_purchase" model="ir.ui.view">
|
||||
<field name="name">product.packaging.tree.view.purchase</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_uom_id']" position="after">
|
||||
<field name="purchase" optional="show"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -247,6 +247,8 @@
|
||||
'required': [('display_type', '=', False)]
|
||||
}"
|
||||
force_save="1" optional="show"/>
|
||||
<field name="product_packaging_qty" attrs="{'invisible': ['|', ('product_id', '=', False), ('product_packaging_id', '=', False)]}" groups="product.group_stock_packaging" optional="show"/>
|
||||
<field name="product_packaging_id" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" groups="product.group_stock_packaging" optional="show"/>
|
||||
<field name="price_unit" attrs="{'readonly': [('invoice_lines', '!=', [])]}"/>
|
||||
<field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use','=','purchase'), ('company_id', '=', parent.company_id)]" context="{'default_type_tax_use': 'purchase', 'search_view_ref': 'account.account_tax_view_search'}" options="{'no_create': True}" optional="show"/>
|
||||
<field name="price_subtotal" widget="monetary"/>
|
||||
@@ -270,6 +272,7 @@
|
||||
<field name="qty_received_method" invisible="1"/>
|
||||
<field name="qty_received" string="Received Quantity" attrs="{'invisible': [('parent.state', 'not in', ('purchase', 'done'))], 'readonly': [('qty_received_method', '!=', 'manual')]}"/>
|
||||
<field name="qty_invoiced" string="Billed Quantity" attrs="{'invisible': [('parent.state', 'not in', ('purchase', 'done'))]}"/>
|
||||
<field name="product_packaging_id" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" groups="product.group_stock_packaging" />
|
||||
<field name="price_unit"/>
|
||||
<field name="taxes_id" widget="many2many_tags" domain="[('type_tax_use', '=', 'purchase'), ('company_id', '=', parent.company_id)]" options="{'no_create': True}"/>
|
||||
</group>
|
||||
|
||||
@@ -147,6 +147,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box"
|
||||
id="stock_packaging_purchase"
|
||||
title="Ability to select a package type in purchase orders and to force a quantity that is a multiple of the number of units per package.">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="group_stock_packaging"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="group_stock_packaging"/>
|
||||
<div class="text-muted">
|
||||
Purchase products by multiple of unit # per package
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -487,6 +487,7 @@ class PurchaseOrderLine(models.Model):
|
||||
'warehouse_id': self.order_id.picking_type_id.warehouse_id.id,
|
||||
'product_uom_qty': product_uom_qty,
|
||||
'product_uom': product_uom.id,
|
||||
'product_packaging_id': self.product_packaging_id.id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
|
||||
@@ -27,3 +27,30 @@ class TestPurchaseOrderProcess(PurchaseTestCommon):
|
||||
|
||||
# Check that order is cancelled.
|
||||
self.assertEqual(po_edit_with_user.state, 'cancel', 'Purchase: PO state should be "Cancel')
|
||||
|
||||
def test_01_packaging_propagation(self):
|
||||
"""Create a PO with lines using packaging, check the packaging propagate
|
||||
to its move.
|
||||
"""
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Product with packaging',
|
||||
'type': 'product',
|
||||
})
|
||||
|
||||
packaging = self.env['product.packaging'].create({
|
||||
'name': 'box',
|
||||
'product_id': product.id,
|
||||
})
|
||||
|
||||
po = self.env['purchase.order'].create({
|
||||
'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).id,
|
||||
'order_line': [
|
||||
(0, 0, {
|
||||
'product_id': product.id,
|
||||
'product_qty': 1.0,
|
||||
'product_uom': product.uom_id.id,
|
||||
'product_packaging_id': packaging.id,
|
||||
})],
|
||||
})
|
||||
po.button_confirm()
|
||||
self.assertEqual(po.order_line.move_ids.product_packaging_id, packaging)
|
||||
|
||||
@@ -36,6 +36,7 @@ This module contains all the common features of Sales Management and eCommerce.
|
||||
'views/payment_templates.xml',
|
||||
'views/payment_views.xml',
|
||||
'views/product_views.xml',
|
||||
'views/product_packaging_views.xml',
|
||||
'views/utm_campaign_views.xml',
|
||||
'wizard/sale_order_cancel_views.xml',
|
||||
'wizard/sale_payment_link_views.xml',
|
||||
|
||||
@@ -71,3 +71,8 @@ class ProductAttributeCustomValue(models.Model):
|
||||
_sql_constraints = [
|
||||
('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.")
|
||||
]
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = 'product.packaging'
|
||||
|
||||
sales = fields.Boolean("Sales", default=True, help="If true, the packaging can be used for sales orders")
|
||||
|
||||
@@ -9,7 +9,7 @@ from odoo import api, fields, models, SUPERUSER_ID, _
|
||||
from odoo.exceptions import AccessError, UserError, ValidationError
|
||||
from odoo.tools.misc import formatLang, get_lang
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_is_zero, float_compare
|
||||
from odoo.tools import float_is_zero, float_compare, float_round
|
||||
|
||||
|
||||
from werkzeug.urls import url_encode
|
||||
@@ -1351,6 +1351,9 @@ class SaleOrderLine(models.Model):
|
||||
('line_section', "Section"),
|
||||
('line_note', "Note")], default=False, help="Technical field for UX purpose.")
|
||||
|
||||
product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True)
|
||||
product_packaging_qty = fields.Float('Packaging Quantity')
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_product_uom_readonly(self):
|
||||
for line in self:
|
||||
@@ -1441,6 +1444,51 @@ class SaleOrderLine(models.Model):
|
||||
else:
|
||||
line.qty_delivered_manual = 0.0
|
||||
|
||||
@api.onchange('product_id', 'product_uom_qty', 'product_uom')
|
||||
def _onchange_suggest_packaging(self):
|
||||
# remove packaging if not match the product
|
||||
if self.product_packaging_id.product_id != self.product_id:
|
||||
self.product_packaging_id = False
|
||||
# suggest biggest suitable packaging
|
||||
if self.product_id and self.product_uom_qty and self.product_uom:
|
||||
self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom)
|
||||
|
||||
@api.onchange('product_packaging_id')
|
||||
def _onchange_product_packaging_id(self):
|
||||
if self.product_packaging_id and self.product_uom_qty:
|
||||
newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP")
|
||||
if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Warning'),
|
||||
'message': _(
|
||||
"This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.",
|
||||
pack_size=self.product_packaging_id.qty,
|
||||
pack_name=self.product_id.uom_id.name,
|
||||
quantity=newqty,
|
||||
unit=self.product_uom.name
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty')
|
||||
def _onchange_update_product_packaging_qty(self):
|
||||
if not self.product_packaging_id:
|
||||
self.product_packaging_qty = False
|
||||
else:
|
||||
packaging_uom = self.product_packaging_id.product_uom_id
|
||||
packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom)
|
||||
self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding)
|
||||
|
||||
@api.onchange('product_packaging_qty')
|
||||
def _onchange_product_packaging_qty(self):
|
||||
if self.product_packaging_id:
|
||||
packaging_uom = self.product_packaging_id.product_uom_id
|
||||
qty_per_packaging = self.product_packaging_id.qty
|
||||
product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom)
|
||||
if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0:
|
||||
self.product_uom_qty = product_uom_qty
|
||||
|
||||
@api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type')
|
||||
def _compute_untaxed_amount_invoiced(self):
|
||||
""" Compute the untaxed amount already invoiced from the sale order line, taking the refund attached
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.exceptions import UserError, AccessError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests import tagged, Form
|
||||
from odoo.tools import float_compare
|
||||
|
||||
from .common import TestSaleCommon
|
||||
@@ -543,3 +543,48 @@ class TestSaleOrder(TestSaleCommon):
|
||||
sale_order.user_id = self.user_not_in_team
|
||||
sale_order.onchange_user_id()
|
||||
self.assertEqual(sale_order.team_id.id, self.crm_team1.id, 'Should not reset the team to default')
|
||||
|
||||
def test_onchange_packaging_00(self):
|
||||
"""Create a SO and use packaging. Check we suggested suitable packaging
|
||||
according to the product_qty. Also check product_qty or product_packaging
|
||||
are correctly calculated when one of them changed.
|
||||
"""
|
||||
partner = self.env['res.partner'].create({'name': "I'm a partner"})
|
||||
product_tmpl = self.env['product.template'].create({'name': "I'm a product"})
|
||||
product = product_tmpl.product_variant_id
|
||||
packaging_single = self.env['product.packaging'].create({
|
||||
'name': "I'm a packaging",
|
||||
'product_id': product.id,
|
||||
'qty': 1.0,
|
||||
})
|
||||
packaging_dozen = self.env['product.packaging'].create({
|
||||
'name': "I'm also a packaging",
|
||||
'product_id': product.id,
|
||||
'qty': 12.0,
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': partner.id,
|
||||
})
|
||||
so_form = Form(so)
|
||||
with so_form.order_line.new() as line:
|
||||
line.product_id = product
|
||||
line.product_uom_qty = 1.0
|
||||
so_form.save()
|
||||
self.assertEqual(so.order_line.product_packaging_id, packaging_single)
|
||||
self.assertEqual(so.order_line.product_packaging_qty, 1.0)
|
||||
with so_form.order_line.edit(0) as line:
|
||||
line.product_packaging_qty = 2.0
|
||||
so_form.save()
|
||||
self.assertEqual(so.order_line.product_uom_qty, 2.0)
|
||||
|
||||
|
||||
with so_form.order_line.edit(0) as line:
|
||||
line.product_uom_qty = 24.0
|
||||
so_form.save()
|
||||
self.assertEqual(so.order_line.product_packaging_id, packaging_dozen)
|
||||
self.assertEqual(so.order_line.product_packaging_qty, 2.0)
|
||||
with so_form.order_line.edit(0) as line:
|
||||
line.product_packaging_qty = 1.0
|
||||
so_form.save()
|
||||
self.assertEqual(so.order_line.product_uom_qty, 12)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="product_packaging_form_view_sale" model="ir.ui.view">
|
||||
<field name="name">product.packaging.form.view.sale</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='group_product']" position="inside">
|
||||
<field name="sales"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_packaging_tree_view_sale" model="ir.ui.view">
|
||||
<field name="name">product.packaging.tree.view.sale</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='product_uom_id']" position="after">
|
||||
<field name="sales" optional="show"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -407,6 +407,7 @@
|
||||
<div name="invoiced_qty" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}">
|
||||
<field name="qty_invoiced" attrs="{'invisible': [('parent.state', 'not in', ['sale', 'done'])]}"/>
|
||||
</div>
|
||||
<field name="product_packaging_id" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" groups="product.group_stock_packaging" />
|
||||
<field name="price_unit"/>
|
||||
<field name="tax_id" widget="many2many_tags" options="{'no_create': True}" context="{'search_view_ref': 'account.account_tax_view_search'}" domain="[('type_tax_use','=','sale'),('company_id','=',parent.company_id)]"
|
||||
attrs="{'readonly': [('qty_invoiced', '>', 0)]}"/>
|
||||
@@ -555,6 +556,8 @@
|
||||
optional="hide"
|
||||
attrs="{'readonly': [('parent.state', 'not in', ['draft', 'sent', 'sale'])]}"
|
||||
/>
|
||||
<field name="product_packaging_qty" attrs="{'invisible': ['|', ('product_id', '=', False), ('product_packaging_id', '=', False)]}" groups="product.group_stock_packaging" optional="show"/>
|
||||
<field name="product_packaging_id" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" groups="product.group_stock_packaging" optional="show"/>
|
||||
<field
|
||||
name="price_unit"
|
||||
attrs="{'readonly': [('qty_invoiced', '>', 0)]}"
|
||||
|
||||
@@ -261,7 +261,6 @@ class SaleOrderLine(models.Model):
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
|
||||
product_packaging = fields.Many2one( 'product.packaging', string='Package', default=False, check_company=True)
|
||||
route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
|
||||
move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
|
||||
product_type = fields.Selection(related='product_id.type')
|
||||
@@ -461,11 +460,6 @@ class SaleOrderLine(models.Model):
|
||||
def _onchange_product_id_set_customer_lead(self):
|
||||
self.customer_lead = self.product_id.sale_delay
|
||||
|
||||
@api.onchange('product_packaging')
|
||||
def _onchange_product_packaging(self):
|
||||
if self.product_packaging:
|
||||
return self._check_package()
|
||||
|
||||
@api.onchange('product_uom_qty')
|
||||
def _onchange_product_uom_qty(self):
|
||||
# When modifying a one2many, _origin doesn't guarantee that its values will be the ones
|
||||
@@ -507,6 +501,7 @@ class SaleOrderLine(models.Model):
|
||||
'partner_id': self.order_id.partner_shipping_id.id,
|
||||
'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
|
||||
'company_id': self.order_id.company_id,
|
||||
'product_packaging_id': self.product_packaging_id.id,
|
||||
})
|
||||
return values
|
||||
|
||||
@@ -597,38 +592,6 @@ class SaleOrderLine(models.Model):
|
||||
pickings_to_confirm.action_confirm()
|
||||
return True
|
||||
|
||||
def _check_package(self):
|
||||
default_uom = self.product_id.uom_id
|
||||
pack = self.product_packaging
|
||||
qty = self.product_uom_qty
|
||||
q = default_uom._compute_quantity(pack.qty, self.product_uom)
|
||||
# We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity
|
||||
# per package might be a float, leading to incorrect results. For example:
|
||||
# 8 % 1.6 = 1.5999999999999996
|
||||
# 5.4 % 1.8 = 2.220446049250313e-16
|
||||
if (
|
||||
qty
|
||||
and q
|
||||
and float_compare(
|
||||
qty / q, float_round(qty / q, precision_rounding=1.0), precision_rounding=0.001
|
||||
)
|
||||
!= 0
|
||||
):
|
||||
newqty = qty - (qty % q) + q
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Warning'),
|
||||
'message': _(
|
||||
"This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.",
|
||||
pack_size=pack.qty,
|
||||
pack_name=default_uom.name,
|
||||
quantity=newqty,
|
||||
unit=self.product_uom.name
|
||||
),
|
||||
},
|
||||
}
|
||||
return {}
|
||||
|
||||
def _update_line_quantity(self, values):
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
|
||||
|
||||
@@ -1033,3 +1033,30 @@ class TestSaleStock(TestSaleCommon, ValuationReconciliationTestCommon):
|
||||
sale_order4.action_confirm()
|
||||
self.assertTrue(sale_order4.picking_ids)
|
||||
self.assertEqual(sale_order4.picking_ids.state, 'assigned')
|
||||
|
||||
def test_packaging_propagation(self):
|
||||
"""Create a SO with lines using packaging, check the packaging propagate
|
||||
to its move.
|
||||
"""
|
||||
product = self.env['product.product'].create({
|
||||
'name': 'Product with packaging',
|
||||
'type': 'product',
|
||||
})
|
||||
|
||||
packaging = self.env['product.packaging'].create({
|
||||
'name': 'box',
|
||||
'product_id': product.id,
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_a.id,
|
||||
'order_line': [
|
||||
(0, 0, {
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': 1.0,
|
||||
'product_uom': product.uom_id.id,
|
||||
'product_packaging_id': packaging.id,
|
||||
})],
|
||||
})
|
||||
so.action_confirm()
|
||||
self.assertEqual(so.order_line.move_ids.product_packaging_id, packaging)
|
||||
|
||||
@@ -41,12 +41,6 @@
|
||||
<field name="show_json_popover" invisible="1"/>
|
||||
<field string=" " name="json_popover" widget="stock_rescheduling_popover" attrs="{'invisible': [('show_json_popover', '=', False)]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//page/field[@name='order_line']/form/group/group/field[@name='price_unit']" position="before">
|
||||
<field name="product_packaging" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" domain="[('product_id','=',product_id)]" groups="product.group_stock_packaging" />
|
||||
</xpath>
|
||||
<xpath expr="//page/field[@name='order_line']/tree/field[@name='price_unit']" position="before">
|
||||
<field name="product_packaging" attrs="{'invisible': [('product_id', '=', False)]}" context="{'default_product_id': product_id, 'tree_view_ref':'product.product_packaging_tree_view', 'form_view_ref':'product.product_packaging_form_view'}" domain="[('product_id','=',product_id)]" groups="product.group_stock_packaging" optional="show"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='order_line']/form/group/group/field[@name='analytic_tag_ids']" position="before">
|
||||
<field name="route_id" groups="stock.group_adv_location" options="{'no_create': True}"/>
|
||||
</xpath>
|
||||
|
||||
@@ -934,6 +934,11 @@ class ProductCategory(models.Model):
|
||||
'stock.location.route', string='Total routes', compute='_compute_total_route_ids',
|
||||
readonly=True)
|
||||
putaway_rule_ids = fields.One2many('stock.putaway.rule', 'category_id', 'Putaway Rules')
|
||||
packaging_reserve_method = fields.Selection([
|
||||
('full', 'Reserve Only Full Packagings'),
|
||||
('partial', 'Reserve Partial Packagings'),], string="Reserve Packagings", default='partial',
|
||||
help="Reserve Only Full Packagings: will not reserve partial packagings. If customer orders 2 pallets of 1000 units each and you only have 1600 in stock, then only 1000 will be reserved\n"
|
||||
"Reserve Partial Packagings: allow reserving partial packagings. If customer orders 2 pallets of 1000 units each and you only have 1600 in stock, then 1600 will be reserved")
|
||||
|
||||
def _compute_total_route_ids(self):
|
||||
for category in self:
|
||||
@@ -944,6 +949,11 @@ class ProductCategory(models.Model):
|
||||
routes |= base_cat.route_ids
|
||||
category.total_route_ids = routes
|
||||
|
||||
class ProductPackaging(models.Model):
|
||||
_inherit = "product.packaging"
|
||||
|
||||
package_type_id = fields.Many2one('stock.package.type', 'Package Type')
|
||||
|
||||
|
||||
class UoM(models.Model):
|
||||
_inherit = 'uom.uom'
|
||||
|
||||
@@ -175,6 +175,7 @@ class StockMove(models.Model):
|
||||
lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False)
|
||||
reservation_date = fields.Date('Date to Reserve', compute='_compute_reservation_date', store=True,
|
||||
help="This is a technical field for calculating when a move should be reserved")
|
||||
product_packaging_id = fields.Many2one('product.packaging', 'Packaging', domain="[('product_id', '=', product_id)]", check_company=True)
|
||||
|
||||
@api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state')
|
||||
def _compute_display_assign_serial(self):
|
||||
@@ -781,7 +782,8 @@ class StockMove(models.Model):
|
||||
return [
|
||||
'product_id', 'price_unit', 'procure_method', 'location_id', 'location_dest_id',
|
||||
'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id',
|
||||
'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline'
|
||||
'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline',
|
||||
'product_packaging_id',
|
||||
]
|
||||
|
||||
@api.model
|
||||
@@ -896,6 +898,15 @@ class StockMove(models.Model):
|
||||
if product:
|
||||
self.description_picking = product._get_description(self.picking_type_id)
|
||||
|
||||
@api.onchange('product_id', 'product_qty', 'product_uom')
|
||||
def _onchange_suggest_packaging(self):
|
||||
# remove packaging if not match the product
|
||||
if self.product_packaging_id.product_id != self.product_id:
|
||||
self.product_packaging_id = False
|
||||
# suggest biggest suitable packaging
|
||||
if self.product_id and self.product_qty and self.product_uom:
|
||||
self.product_packaging_id = self.product_id.packaging_ids._find_suitable_product_packaging(self.product_qty, self.product_uom)
|
||||
|
||||
@api.onchange('lot_ids')
|
||||
def _onchange_lot_ids(self):
|
||||
quantity_done = sum(ml.product_uom_id._compute_quantity(ml.qty_done, self.product_uom) for ml in self.move_line_ids.filtered(lambda ml: not ml.lot_id and ml.lot_name))
|
||||
@@ -1242,6 +1253,10 @@ class StockMove(models.Model):
|
||||
if not owner_id:
|
||||
owner_id = self.env['res.partner']
|
||||
|
||||
# do full packaging reservation when it's needed
|
||||
if self.product_packaging_id and self.product_id.product_tmpl_id.categ_id.packaging_reserve_method == "full":
|
||||
available_quantity = self.product_packaging_id._check_qty(available_quantity, self.product_id.uom_id, "DOWN")
|
||||
|
||||
taken_quantity = min(available_quantity, need)
|
||||
|
||||
# `taken_quantity` is in the quants unit of measure. There's a possibility that the move's
|
||||
|
||||
@@ -313,6 +313,7 @@ class StockRule(models.Model):
|
||||
'description_picking': picking_description,
|
||||
'priority': values.get('priority', "0"),
|
||||
'orderpoint_id': values.get('orderpoint_id') and values['orderpoint_id'].id,
|
||||
'product_packaging_id': values.get('product_packaging_id', False),
|
||||
}
|
||||
for field in self._get_custom_move_fields():
|
||||
if field in values:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<field name="route_ids" widget="many2many_tags" groups="stock.group_adv_location"/>
|
||||
<field name="total_route_ids" widget="many2many_tags" groups="stock.group_adv_location" attrs="{'invisible': [('parent_id', '=', False)]}"/>
|
||||
<field name="removal_strategy_id" options="{'no_create': True}"/>
|
||||
<field name="packaging_reserve_method" widget="radio" groups="product.group_stock_packaging"/>
|
||||
</group>
|
||||
</group>
|
||||
</field>
|
||||
@@ -159,6 +160,28 @@
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_packaging_tree_view" model="ir.ui.view">
|
||||
<field name="name">product.packaging.tree.view.stock</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_tree_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='name']" position="after">
|
||||
<field name="package_type_id" groups="stock.group_tracking_lot"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="product_packaging_form_view" model="ir.ui.view">
|
||||
<field name="name">product.packaging.form.view.stock</field>
|
||||
<field name="model">product.packaging</field>
|
||||
<field name="inherit_id" ref="product.product_packaging_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//label[@for='qty']" position="before">
|
||||
<field name="package_type_id" groups="stock.group_tracking_lot"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Product Variant -->
|
||||
|
||||
<record id="product_search_form_view_stock" model="ir.ui.view">
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<field name="product_id"/>
|
||||
<field name="location_id" options="{'no_create': True}" string="From"/>
|
||||
<field name="location_dest_id" options="{'no_create': True}" string="To"/>
|
||||
<field name="product_packaging_id" optional="hide" groups="product.group_stock_packaging"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="product_uom" options="{'no_open': True, 'no_create': True}" string="Unit of Measure" groups="uom.group_uom"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
<field name="date_deadline" optional="hide"/>
|
||||
<field name="is_initial_demand_editable" invisible="1"/>
|
||||
<field name="is_quantity_done_editable" invisible="1"/>
|
||||
<field name="product_packaging_id" groups="product.group_stock_packaging"/>
|
||||
<field name="product_uom_qty" string="Demand" attrs="{'column_invisible': [('parent.immediate_transfer', '=', True)], 'readonly': ['|', ('is_initial_demand_editable', '=', False), '&', '&', ('show_operations', '=', True), ('is_locked', '=', True), ('is_initial_demand_editable', '=', False)]}"/>
|
||||
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecast_availability', '<', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}"/>
|
||||
<button type="object" name="action_product_forecast_report" icon="fa-area-chart" attrs="{'invisible': ['|', ('forecast_availability', '>=', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}" class="text-danger"/>
|
||||
|
||||
Reference in New Issue
Block a user