[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:
yhu-odoo
2021-05-06 13:36:28 +00:00
parent bf26c3c221
commit 688d655305
26 changed files with 427 additions and 57 deletions
+46 -4
View File
@@ -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"
+8 -5
View File
@@ -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>
+1
View File
@@ -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',
+6
View File
@@ -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")
+48 -1
View File
@@ -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:
+42
View File
@@ -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>
+3
View File
@@ -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>
+1
View File
@@ -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)
+1
View File
@@ -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',
+5
View File
@@ -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")
+49 -1
View File
@@ -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
+46 -1
View File
@@ -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>
+3
View File
@@ -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', '&gt;', 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', '&gt;', 0)]}"
+1 -38
View File
@@ -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>
+10
View File
@@ -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'
+16 -1
View File
@@ -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
+1
View File
@@ -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:
+23
View File
@@ -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">
+1
View File
@@ -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), '&amp;', '&amp;', ('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', '&lt;', 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', '&gt;=', 0), '|', ('parent.immediate_transfer', '=', True), ('parent.picking_type_code', '=', 'outgoing')]}" class="text-danger"/>