diff --git a/product_catch_weight/__init__.py b/product_catch_weight/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/product_catch_weight/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/product_catch_weight/__manifest__.py b/product_catch_weight/__manifest__.py
new file mode 100644
index 00000000..76d3cea2
--- /dev/null
+++ b/product_catch_weight/__manifest__.py
@@ -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,
+}
diff --git a/product_catch_weight/models/__init__.py b/product_catch_weight/models/__init__.py
new file mode 100644
index 00000000..5e099bc5
--- /dev/null
+++ b/product_catch_weight/models/__init__.py
@@ -0,0 +1,4 @@
+from . import account_invoice
+from . import product
+from . import stock_patch
+from . import stock
diff --git a/product_catch_weight/models/account_invoice.py b/product_catch_weight/models/account_invoice.py
new file mode 100644
index 00000000..ee8c9f92
--- /dev/null
+++ b/product_catch_weight/models/account_invoice.py
@@ -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
\ No newline at end of file
diff --git a/product_catch_weight/models/product.py b/product_catch_weight/models/product.py
new file mode 100644
index 00000000..16cb4b91
--- /dev/null
+++ b/product_catch_weight/models/product.py
@@ -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')
diff --git a/product_catch_weight/models/stock.py b/product_catch_weight/models/stock.py
new file mode 100644
index 00000000..1ad5d3b6
--- /dev/null
+++ b/product_catch_weight/models/stock.py
@@ -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')
diff --git a/product_catch_weight/models/stock_patch.py b/product_catch_weight/models/stock_patch.py
new file mode 100644
index 00000000..fca04e4f
--- /dev/null
+++ b/product_catch_weight/models/stock_patch.py
@@ -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
diff --git a/product_catch_weight/tests/__init__.py b/product_catch_weight/tests/__init__.py
new file mode 100644
index 00000000..0edad729
--- /dev/null
+++ b/product_catch_weight/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_catch_weight
diff --git a/product_catch_weight/tests/test_catch_weight.py b/product_catch_weight/tests/test_catch_weight.py
new file mode 100644
index 00000000..1b13cb55
--- /dev/null
+++ b/product_catch_weight/tests/test_catch_weight.py
@@ -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))
+
diff --git a/product_catch_weight/views/account_invoice_views.xml b/product_catch_weight/views/account_invoice_views.xml
new file mode 100644
index 00000000..8eb57740
--- /dev/null
+++ b/product_catch_weight/views/account_invoice_views.xml
@@ -0,0 +1,69 @@
+
+
+
+ account.invoice.form.inherit
+ account.invoice
+
+
+
+
+
+
+
+
+
+ account.invoice.supplier.form.inherit
+ account.invoice
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Catch Weight |
+ CW Unit Price |
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+ /
+
+ |
+
+
+ |
+ |
+
+
+
+
+
+
+ |
+ |
+
+
+
+
\ No newline at end of file
diff --git a/product_catch_weight/views/stock_views.xml b/product_catch_weight/views/stock_views.xml
new file mode 100644
index 00000000..40e2d9dd
--- /dev/null
+++ b/product_catch_weight/views/stock_views.xml
@@ -0,0 +1,62 @@
+
+
+
+ stock.production.lot.form.inherit
+ stock.production.lot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stock.move.operations.form.inherit
+ stock.move
+
+
+
+
+
+
+ {'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}
+
+
+
+
+ stock.move.line.operations.tree.inherit
+ stock.move.line
+
+
+
+
+
+
+
+
+
+
+
+ product.template.common.form.inherit
+ product.template
+
+
+
+
+
+
+
+
\ No newline at end of file