Skip to content

Commit e828faa

Browse files
committed
[FIX] purchase_stock: inflated unit cost due to rounding method
Before this commit: When calculating the `price_unit` for stock moves from purchase order lines, the `remaining_qty` and `remaining_value` could be imprecise. Standard float comparisons for these remaining amounts could lead to incorrect `price_unit` calculations if, for example, `remaining_qty` was a very small float near zero. This could result in inaccurate stock valuations, particularly when currency conversions were involved or when landed costs were applied. For example, 70.00000003 is rounded **up** to 70.00001 (with 5 digits), resulting in a quantity difference of 0.00001, which incorrectly inflates the unit cost. After this commit: Change the rounding method to 'HALF-UP' instead of the default 'UP' to improve precise result for quantities. Steps to reproduce: 1. Configure a product with AVCO real time. Set decimal precision for price and UoM to 5 digits. 2. Create a Purchase Order (e.g., 190 units @ $110/unit). 3. Receive 70 units and create a backorder 4. Create and post a bill for the initially received quantity. 5. Apply a landed cost to the picking of the first 70 units. 6. Create a draft bill for the remaining quantity on the PO. 7. Receive the remaining 120 units from the backorder. 8. The product's cost explodes opw-4705224 closes odoo#216661 X-original-commit: 7725b84 Signed-off-by: Tiffany Chang (tic) <[email protected]> Signed-off-by: Walravens Mathieu (wama) <[email protected]>
1 parent 06567fc commit e828faa

File tree

2 files changed

+122
-4
lines changed

2 files changed

+122
-4
lines changed

addons/purchase_stock/models/stock_move.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,19 @@ def _get_price_unit(self):
7575
invoice_line_value = adjusted_unit_price * invoice_line.quantity
7676
total_invoiced_value += invoice_line.currency_id._convert(
7777
invoice_line_value, order.currency_id, order.company_id, invoice_line.move_id.invoice_date, round=False)
78-
invoiced_qty += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_id.uom_id)
78+
invoiced_qty += invoice_line.product_uom_id._compute_quantity(invoice_line.quantity, line.product_id.uom_id, rounding_method="HALF-UP")
7979
# TODO currency check
8080
remaining_value = total_invoiced_value - receipt_value
8181
# TODO qty_received in product uom
82-
remaining_qty = invoiced_qty - line.product_uom._compute_quantity(received_qty, line.product_id.uom_id)
83-
if order.currency_id != order.company_id.currency_id and remaining_value and remaining_qty:
82+
remaining_qty = invoiced_qty - line.product_uom._compute_quantity(received_qty, line.product_id.uom_id, rounding_method="HALF-UP")
83+
has_remaining = (
84+
not order.currency_id.is_zero(remaining_value)
85+
and not float_is_zero(remaining_qty, precision_rounding=line.product_id.uom_id.rounding)
86+
)
87+
if order.currency_id != order.company_id.currency_id and has_remaining:
8488
# will be rounded during currency conversion
8589
price_unit = remaining_value / remaining_qty
86-
elif remaining_value and remaining_qty:
90+
elif has_remaining:
8791
price_unit = float_round(remaining_value / remaining_qty, precision_digits=price_unit_prec)
8892
else:
8993
price_unit = line._get_gross_price_unit()

addons/stock_landed_costs/tests/test_stock_landed_costs_purchase.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,3 +746,117 @@ def test_refund_landed_cost_creates_negative_valuation(self):
746746
lc.button_validate()
747747
self.assertEqual(lc.amount_total, -20)
748748
self.assertEqual(lc.stock_valuation_layer_ids.value, -20)
749+
750+
def test_landed_cost_avco_partial_bill_rounding(self):
751+
"""Tests landed cost calculation for an AVCO product with partial
752+
billing and backorders, ensuring correct stock valuation and handling
753+
of rounding with decimal precision.
754+
"""
755+
decimal_price = self.env.ref('product.decimal_price')
756+
decimal_price.digits = 5
757+
decimal_product_uom = self.env.ref('product.decimal_product_uom')
758+
decimal_product_uom.digits = 5
759+
760+
self.env.company.anglo_saxon_accounting = True
761+
self.product1.purchase_method = 'purchase'
762+
self.product1.categ_id.write({
763+
'property_stock_account_input_categ_id': self.company_data['default_account_stock_in'].id,
764+
'property_stock_account_output_categ_id': self.company_data['default_account_stock_out'].id,
765+
'property_stock_valuation_account_id': self.company_data['default_account_stock_valuation'].id,
766+
'property_valuation': 'real_time',
767+
'property_cost_method': 'average',
768+
})
769+
self.landed_cost.categ_id = self.product1.categ_id.id
770+
771+
purchase_order = self.env['purchase.order'].create({
772+
'partner_id': self.partner_a.id,
773+
'order_line': [
774+
(0, 0, {
775+
'name': self.product1.name,
776+
'product_id': self.product1.id,
777+
'product_qty': 190.0,
778+
'product_uom': self.product1.uom_po_id.id,
779+
'price_unit': 110.0,
780+
})
781+
],
782+
})
783+
purchase_order.button_confirm()
784+
785+
self.assertEqual(purchase_order.state, 'purchase')
786+
picking = purchase_order.picking_ids[0]
787+
picking.action_assign()
788+
789+
# Receive 70 items and create a backorder
790+
picking.move_ids.quantity = 70
791+
picking.button_validate()
792+
picking._action_done()
793+
794+
backorder_picking = purchase_order.picking_ids.filtered(lambda p: p.backorder_id == picking)
795+
self.assertTrue(backorder_picking, "Backorder picking was not created or not found.")
796+
797+
bill = self.env["account.move"].browse(purchase_order.action_create_invoice()["res_id"])
798+
bill.invoice_date = fields.Date.today()
799+
bill.invoice_line_ids.quantity = 70
800+
bill.action_post()
801+
802+
svl_initial_receipt = self.env['stock.valuation.layer'].search([
803+
('product_id', '=', self.product1.id),
804+
('stock_move_id', '=', picking.move_ids.id)
805+
])
806+
self.assertEqual(len(svl_initial_receipt), 1)
807+
self.assertAlmostEqual(svl_initial_receipt.quantity, 70)
808+
self.assertAlmostEqual(svl_initial_receipt.unit_cost, 110, msg="SVL unit cost for initial receipt should match PO price.")
809+
self.assertAlmostEqual(svl_initial_receipt.value, 70 * 110)
810+
self.assertAlmostEqual(self.product1.standard_price, 110, msg="Product AVCO should be 110 after first receipt.")
811+
self.assertAlmostEqual(purchase_order.order_line[0].qty_invoiced, 70)
812+
813+
# Add a landed cost to the first picking
814+
landed_cost = self.env['stock.landed.cost'].create({
815+
'picking_ids': [(6, 0, [picking.id])],
816+
'account_journal_id': self.stock_journal.id,
817+
'cost_lines': [(0, 0, {
818+
'name': 'landed cost',
819+
'split_method': 'equal',
820+
'price_unit': 95,
821+
'product_id': self.landed_cost.id,
822+
})],
823+
})
824+
landed_cost.compute_landed_cost()
825+
landed_cost.button_validate()
826+
827+
# Create a draft bill for the remaining 120 units
828+
bill2 = self.env["account.move"].browse(purchase_order.action_create_invoice()["res_id"])
829+
bill2.invoice_date = fields.Date.today()
830+
self.assertAlmostEqual(bill2.invoice_line_ids[0].quantity, 120, msg="Bill 2 should be for the remaining 120 units.")
831+
self.assertAlmostEqual(bill2.invoice_line_ids[0].price_unit, 110, msg="Bill 2 unit price should match PO price.")
832+
self.assertAlmostEqual(purchase_order.order_line[0].qty_invoiced, 190, msg="Total 190 units should be invoiced on PO line.")
833+
834+
# Receive the remaining 120 quantities in the backorder.
835+
backorder_picking.action_assign()
836+
backorder_picking.move_ids[0].quantity = 120
837+
backorder_picking.button_validate() # This should not create another backorder
838+
839+
# Check that the valuation layers of the backorder matches the bill
840+
svl_backorder_receipt = self.env['stock.valuation.layer'].search([
841+
('product_id', '=', self.product1.id),
842+
('stock_move_id', '=', backorder_picking.move_ids[0].id)
843+
])
844+
845+
self.assertEqual(len(svl_backorder_receipt), 1)
846+
self.assertAlmostEqual(svl_backorder_receipt.quantity, 120)
847+
# The unit cost for AVCO on receipt is taken from the purchase order line price.
848+
self.assertAlmostEqual(svl_backorder_receipt.unit_cost, 110, msg="SVL unit cost for backorder receipt should match PO price.")
849+
self.assertAlmostEqual(svl_backorder_receipt.value, 120 * 110)
850+
851+
# Final check on product's AVCO and total quantity/value
852+
# Total quantity received is 120 + 70 = 190
853+
self.assertAlmostEqual(self.product1.qty_available, 190)
854+
855+
# For AVCO, the standard_price should reflect the average. Since all units came at
856+
# the same price, it's 110, plus the landed cost (95 / 190)
857+
# 110 + 95 / 190 = 110.5
858+
self.assertAlmostEqual(self.product1.standard_price, 110.5)
859+
860+
# Check total value in SVL:
861+
# 120 * 110 + 95 + 70 * 110 = 20995
862+
self.assertAlmostEqual(self.product1.value_svl, 20995)

0 commit comments

Comments
 (0)