Several reports don't include product packing info on them. This is useful for customers to see that what they bought matches what they expected and for pickers who need to know which product packaging they should be selecting from stock. Reports this was added to are: - Sales orders, - purchase orders (including RFQs), - picking operations, - delivery slips. Note that we purposely exclude backordered moves since they weren't deemed to be necessary at the time this feature was created. Also includes light refactoring to remove repeated code for conversion of product qty to packaging qty and to make it easier to do this conversion in the future (i.e. can pass product.packaging without original record that it was assigned to) Task id 2927379 closes odoo/odoo#96858 Signed-off-by: Steve Van Essche <svs@odoo.com>
81 lines
4.1 KiB
Python
81 lines
4.1 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import ValidationError
|
|
from odoo.osv import expression
|
|
|
|
|
|
from odoo.tools import float_compare, float_round
|
|
|
|
|
|
class ProductPackaging(models.Model):
|
|
_name = "product.packaging"
|
|
_description = "Product Packaging"
|
|
_order = 'product_id, sequence, id'
|
|
_check_company_auto = 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, required=True, ondelete="cascade")
|
|
qty = fields.Float('Contained Quantity', default=1, digits='Product Unit of Measure', 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.'),
|
|
('barcode_uniq', 'unique(barcode)', 'A barcode can only be assigned to one packaging.'),
|
|
]
|
|
|
|
@api.constrains('barcode')
|
|
def _check_barcode_uniqueness(self):
|
|
""" With GS1 nomenclature, products and packagings use the same pattern. Therefore, we need
|
|
to ensure the uniqueness between products' barcodes and packagings' ones"""
|
|
domain = [('barcode', 'in', [b for b in self.mapped('barcode') if b])]
|
|
if self.env['product.product'].search(domain, order="id", limit=1):
|
|
raise ValidationError(_("A product already uses the barcode"))
|
|
|
|
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:
|
|
rounded_qty = float_round(product_qty / packaging_qty, precision_rounding=1.0,
|
|
rounding_method=rounding_method) * packaging_qty
|
|
return rounded_qty if float_compare(rounded_qty, product_qty, precision_rounding=default_uom.rounding) else product_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']
|
|
|
|
def _compute_qty(self, qty, qty_uom=False):
|
|
"""Returns the qty of this packaging that qty converts to.
|
|
A float is returned because there are edge cases where some users use
|
|
"part" of a packaging
|
|
|
|
:param qty: float of product quantity (given in product UoM if no qty_uom provided)
|
|
:param qty_uom: Optional uom of quantity
|
|
:returns: float of packaging qty
|
|
"""
|
|
self.ensure_one()
|
|
if qty_uom:
|
|
qty = qty_uom._compute_quantity(qty, self.product_uom_id)
|
|
return float_round(qty / self.qty, precision_rounding=self.product_uom_id.rounding)
|