From 05a9e71d57edcb4f1f2f5de94c0945cc53f502b1 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 4 Sep 2026 13:04:02 +0000 Subject: [PATCH] [FIX] purchase_stock_ux: only count returns of the line product in qty_returned _compute_qty_returned() iterated over every move of the line, so a return was counted even when it belonged to a product that is no longer the one on the line. When a wrong product is received, returned to the vendor marked to refund, and then corrected on the same purchase order line, that return was subtracted from what is left to bill for the new product: qty_to_invoice dropped to 0 and invoice_status became 'invoiced' with nothing actually billed, so the vendor bill could not be created ("There is no invoiceable line"). Iterate over _get_po_line_moves() instead, which filters the moves by the product of the line. It is the same helper the core uses in _compute_qty_received(), so both quantities are now built from the same set of moves. A return of the product that is on the line keeps being counted, so the refund case is unchanged. Add move_ids.to_refund to the depends as well: the flag is what decides whether a move counts as returned, and without it correcting the flag left the stored qty_to_invoice and invoice_status untouched. Signed-off-by: les-adhoc X-original-commit: 889d8a34cdeabdd58ee894664ac8b2512a348324 --- .../models/purchase_order_line.py | 54 +++++++++ purchase_stock_ux/tests/__init__.py | 7 ++ purchase_stock_ux/tests/test_qty_returned.py | 113 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 purchase_stock_ux/tests/test_qty_returned.py diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index 4d059439..2c2d4a97 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -185,13 +185,67 @@ def _onchange_product_qty(self): return {"warning": warning_mess} return {} +<<<<<<< 43b4f59c454753480ff90f3037d1b07e42d82ed7 @api.depends("order_id.state", "move_ids.state") +||||||| e67119e1f5b065fafbd0d5310bb17129cb453079 + @api.depends("qty_received_method", "qty_received_manual") + def _compute_qty_received(self): + super()._compute_qty_received() + for line in self.filtered(lambda l: l.qty_received_method in ["manual", "stock_moves"]): + exchange_move_ids = line.move_ids.filtered( + lambda m: m.state == "done" and m.location_id.usage != "supplier" and m._is_exchange_move_helper() + ) + if exchange_move_ids: + line.qty_received -= sum( + line.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + for move in exchange_move_ids + ) + + @api.depends("order_id.state", "move_ids.state") +======= + @api.depends("qty_received_method", "qty_received_manual") + def _compute_qty_received(self): + super()._compute_qty_received() + for line in self.filtered(lambda l: l.qty_received_method in ["manual", "stock_moves"]): + exchange_move_ids = line.move_ids.filtered( + lambda m: m.state == "done" and m.location_id.usage != "supplier" and m._is_exchange_move_helper() + ) + if exchange_move_ids: + line.qty_received -= sum( + line.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) + for move in exchange_move_ids + ) + + @api.depends("order_id.state", "move_ids.state", "move_ids.to_refund") +>>>>>>> 955b1890650ae278b691ed999cdbebc9ee835608 def _compute_qty_returned(self): for line in self: qty = 0.0 +<<<<<<< 43b4f59c454753480ff90f3037d1b07e42d82ed7 # Count only real vendor returns (excludes the subcontract receipt move). for move in line.move_ids.filtered(lambda m: m.state == "done" and m.to_refund and m._is_purchase_return()): qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom_id) +||||||| e67119e1f5b065fafbd0d5310bb17129cb453079 + for move in line.move_ids.filtered( + lambda m: ( + m.state == "done" + and m.location_id.usage != "supplier" + and m.to_refund + and not m._is_exchange_move_helper() + ) + ): + qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) +======= + for move in line._get_po_line_moves().filtered( + lambda m: ( + m.state == "done" + and m.location_id.usage != "supplier" + and m.to_refund + and not m._is_exchange_move_helper() + ) + ): + qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) +>>>>>>> 955b1890650ae278b691ed999cdbebc9ee835608 line.qty_returned = qty # Overwrite the origin method to introduce the qty_on_voucher diff --git a/purchase_stock_ux/tests/__init__.py b/purchase_stock_ux/tests/__init__.py index cdaac5e9..463e4afe 100644 --- a/purchase_stock_ux/tests/__init__.py +++ b/purchase_stock_ux/tests/__init__.py @@ -5,3 +5,10 @@ from . import test_purchase_order from . import test_stock_orderpoint +<<<<<<< 43b4f59c454753480ff90f3037d1b07e42d82ed7 +||||||| e67119e1f5b065fafbd0d5310bb17129cb453079 +from . import test_cancel_remaining +======= +from . import test_cancel_remaining +from . import test_qty_returned +>>>>>>> 955b1890650ae278b691ed999cdbebc9ee835608 diff --git a/purchase_stock_ux/tests/test_qty_returned.py b/purchase_stock_ux/tests/test_qty_returned.py new file mode 100644 index 00000000..64da61b4 --- /dev/null +++ b/purchase_stock_ux/tests/test_qty_returned.py @@ -0,0 +1,113 @@ +from odoo import Command +from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon + + +class TestQtyReturned(PurchaseTestCommon): + """Cobertura de qty_returned (_compute_qty_returned) en compras. + + Ticket 126775: se recibió el producto equivocado, se lo devolvió al proveedor marcando + "Para abonar" y se corrigió el producto en la misma línea de la OC. La devolución del + producto viejo se descontaba de lo pendiente a facturar del producto nuevo, dejando la + OC como "totalmente facturada" con cero facturado y sin forma de emitir la factura del + proveedor. El cómputo debe considerar solo los movimientos del producto de la línea, + igual que _compute_qty_received del core. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Qty Returned Vendor"}) + cls.warehouse = cls.env["stock.warehouse"].search([("company_id", "=", cls.env.company.id)], limit=1) + cls.warehouse.reception_steps = "one_step" + + # ------------------------------------------------------------------ helpers + def _product(self, name): + return self.env["product.product"].create( + { + "name": name, + "type": "consu", + "is_storable": True, + "purchase_method": "purchase", + } + ) + + def _confirm_po(self, product, qty): + po = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "picking_type_id": self.warehouse.in_type_id.id, + "order_line": [ + Command.create( + { + "product_id": product.id, + "product_qty": qty, + "price_unit": 10.0, + "name": product.name, + } + ) + ], + } + ) + po.button_confirm() + return po + + def _validate(self, picking): + picking.action_assign() + for move in picking.move_ids.filtered(lambda m: m.state not in ("done", "cancel")): + move.quantity = move.product_uom_qty + move.picked = True + picking.button_validate() + + def _receive_all(self, po): + for picking in po.picking_ids.filtered(lambda p: p.state not in ("done", "cancel")): + self._validate(picking) + + def _return_all(self, po, qty, to_refund=True): + receipt = po.picking_ids.filtered(lambda p: p.picking_type_id.code == "incoming" and p.state == "done").sorted( + "id" + )[-1:] + wizard = ( + self.env["stock.return.picking"] + .with_context(active_id=receipt.id, active_model="stock.picking", active_ids=receipt.ids) + .create({}) + ) + for wizard_line in wizard.product_return_moves: + wizard_line.quantity = qty + wizard_line.to_refund = to_refund + action = wizard.action_create_returns() + return_picking = self.env["stock.picking"].browse(action["res_id"]) + self._validate(return_picking) + return return_picking + + # -------------------------------------------------------------------- tests + def test_return_of_replaced_product_not_counted(self): + """Ticket 126775: la devolución del producto reemplazado no debe contar como devuelta + en la línea -que ahora tiene otro producto- ni bloquear su facturación.""" + old_product = self._product("QR producto viejo") + new_product = self._product("QR producto nuevo") + po = self._confirm_po(old_product, 10) + line = po.order_line + self._receive_all(po) + self._return_all(po, 10, to_refund=True) + + line.product_id = new_product + line.invalidate_recordset() + # el cambio de producto no está en los depends de los campos almacenados + line._compute_qty_invoiced() + line._compute_invoice_status() + + self.assertEqual(line.qty_returned, 0, "la devolución del producto viejo se contó en la línea nueva") + self.assertEqual(line.qty_to_invoice, 10, "la línea quedó sin cantidad pendiente a facturar") + self.assertEqual(line.invoice_status, "to invoice") + + def test_return_of_same_product_counted(self): + """No regresión: la devolución del mismo producto de la línea sí debe seguir contando.""" + product = self._product("QR mismo producto") + po = self._confirm_po(product, 10) + line = po.order_line + self._receive_all(po) + self._return_all(po, 10, to_refund=True) + + line.invalidate_recordset() + self.assertEqual(line.qty_returned, 10) + self.assertEqual(line.qty_to_invoice, 0)