From 5dbeaf55c621e8364869ee9d8d3bedefc1c483f1 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Carreras Date: Tue, 11 Aug 2026 19:32:33 +0000 Subject: [PATCH 01/15] [FIX] purchase_stock_ux: cancel remaining must not leave a pending vendor receipt nor a counter-delivery When cancelling the remaining qty, lowering product_qty does not always cancel the pending receipt: with a refund return (ticket 124773) a live pending IN remains and inflates the forecast of units to receive, and when the negative move does not net against the pending one (different price/final location) a counter-delivery to the vendor is generated (ticket 124957). We now explicitly cancel the line's open remaining move. In 1, 2 or 3 step receptions the pending qty to receive always lives in the first-step move (vendor->input), which hangs off move_ids and has no downstream; core (_action_cancel) propagates to later moves if needed, so there is no need to walk the chain. The cancel is scoped to genuine forward vendor receipts: exchange-return replacements (a legitimate expected receipt) and open, not-yet-validated vendor returns (origin_returned_move_id, which also hang off move_ids and are not part of the remainder) are excluded. product_qty is not touched, so invoicing (qty_to_invoice) stays intact. Forward port note: in 19.0 purchase exchanges are not flagged with the sale_stock_ux is_exchange_move field (that wizard override does not exist here); they are detected from the return picking, as _should_count_for_quantity_received already did. We extract that criterion into _is_exchange_receipt() and use it for the guard, so the exclusion is real in this version instead of dead code. Tests (test_cancel_remaining.py, over 1/2/3 steps): partial receipt, refund return (124773), preservation of goods in transit, and an open vendor return that must survive cancel remaining (RED without the origin_returned_move_id guard). closes ingadhoc/purchase#365 X-original-commit: 44a1146a97c5cbcb5e7733eb02d6d7bf66bad909 Signed-off-by: Luciano Esperlazza Signed-off-by: ced-adhoc (cherry picked from commit 885a7a6f9350ab7675c2d6e2f9e43a5fcd7a5b0c) In 20.0, _should_count_for_quantity_received is not overridden: the exchange receipt rule of d404c5bb3 is not brought, so only _is_exchange_receipt() comes with this commit. --- .../models/purchase_order_line.py | 18 +- purchase_stock_ux/models/stock_move.py | 5 + purchase_stock_ux/tests/__init__.py | 1 + .../tests/test_cancel_remaining.py | 199 ++++++++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 purchase_stock_ux/tests/test_cancel_remaining.py diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index cf36a55e..ad2068e4 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -114,9 +114,21 @@ def button_cancel_remaining(self): _("Cancel remaining can't be called for Kit Products (products with a bom of type kit).") ) rec.with_context(cancel_from_order=True).product_qty = rec.qty_received + rec.qty_returned - # la realidad es que probablemente esto de acá no sea necesario. modificar product_qty ya hace que odoo, - # apartir de 16 al menos, baje las cantidades de los moves. Justamente por esta razon es que ahora - # pasamos contexto arriba de "cancel_from_order", porque ahora es odoo quien cancela los pickings + # Bajar product_qty hace que Odoo (>=16) reduzca los moves, pero no siempre cancela el + # remanente de recepción: si hubo una devolución con reembolso, o si el move negativo no + # netea contra el pendiente (precio/ubicación final distintos), queda una recepción viva en + # el pronóstico (o una contra-entrega al proveedor). Cancelamos explícitamente el remanente + # abierto de la línea. En recepción de 1, 2 o 3 pasos el pendiente vive siempre en el move de + # primer paso (proveedor->entrada), que cuelga de move_ids; core (_action_cancel) propaga a + # los downstream si hiciera falta. Acotamos a las recepciones forward genuinas del proveedor: + # excluimos los reemplazos de devolución con cambio (recepción legítima esperada) y las + # devoluciones al proveedor abiertas sin validar (origin_returned_move_id), que no son parte + # del remanente y no deben cancelarse. + rec.move_ids.filtered( + lambda m: m.state not in ("done", "cancel") + and not m._is_exchange_receipt() + and not m.origin_returned_move_id + ).with_context(cancel_from_order=True)._action_cancel() if rec.product_qty < old_product_qty: rec.order_id._log_decrease_ordered_quantity({rec: (rec.product_qty, old_product_qty)}) rec.order_id.message_post( diff --git a/purchase_stock_ux/models/stock_move.py b/purchase_stock_ux/models/stock_move.py index 11fe0335..ae2f88df 100644 --- a/purchase_stock_ux/models/stock_move.py +++ b/purchase_stock_ux/models/stock_move.py @@ -19,6 +19,11 @@ def _compute_origin_description(self): if rec.purchase_line_id: rec.origin_description = rec.purchase_line_id.name + def _is_exchange_receipt(self): + # A receipt created from a vendor return that did not update the PO (exchange) + self.ensure_one() + return bool(self.picking_id.return_id and not self.origin_returned_move_id and not self.to_refund) + @api.model def _prepare_merge_moves_distinct_fields(self): # Esto lo hacemos porque si por ejemplo el replenishment_cost del producto cambió, este cambia el price unit al momento diff --git a/purchase_stock_ux/tests/__init__.py b/purchase_stock_ux/tests/__init__.py index 99e464fd..fca32ef2 100644 --- a/purchase_stock_ux/tests/__init__.py +++ b/purchase_stock_ux/tests/__init__.py @@ -7,3 +7,4 @@ from . import test_stock_orderpoint from . import test_qty_returned from . import test_stock_rule +from . import test_cancel_remaining diff --git a/purchase_stock_ux/tests/test_cancel_remaining.py b/purchase_stock_ux/tests/test_cancel_remaining.py new file mode 100644 index 00000000..6b331ca0 --- /dev/null +++ b/purchase_stock_ux/tests/test_cancel_remaining.py @@ -0,0 +1,199 @@ +from odoo import Command +from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon + + +class TestCancelRemaining(PurchaseTestCommon): + """Cobertura de "Cancelar remanente" (button_cancel_remaining) en compras. + + Al cancelar remanente NO debe quedar (ni generarse) recepción pendiente del proveedor: + * Ticket 124773: tras una devolución con reembolso quedaba un IN pendiente vivo, inflando + el pronóstico de unidades a recibir. + * Ticket 124957: cuando el move negativo no neteaba, se generaba una contra-entrega (OUT) al + proveedor en lugar de cancelar el remanente. + + Se valida en recepción de 1, 2 y 3 pasos: el remanente vive siempre en el move de primer paso + (proveedor->entrada) y debe quedar cancelado, preservando lo ya recibido en tránsito a stock. + """ + + STEPS = ("one_step", "two_steps", "three_steps") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Cancel Remaining Vendor"}) + cls.warehouse = cls.env["stock.warehouse"].search([("company_id", "=", cls.env.company.id)], limit=1) + + # ------------------------------------------------------------------ helpers + def _product(self, name): + return self.env["product.product"].create({"name": name, "type": "consu", "is_storable": True}) + + 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 _chain(self, line): + """Toda la cadena de moves de la línea (en 2/3 pasos los internos cuelgan por move_dest_ids).""" + line.invalidate_recordset() + chain = frontier = line.move_ids + while frontier: + frontier = (frontier.move_dest_ids | frontier.move_orig_ids) - chain + chain |= frontier + return chain + + def _validate(self, picking, qty=None, backorder=True): + if picking.state not in ("assigned", "partially_available"): + picking.action_assign() + for move in picking.move_ids.filtered(lambda m: m.state not in ("done", "cancel")): + move.quantity = move.product_uom_qty if qty is None else qty + move.picked = True + res = picking.button_validate() + if isinstance(res, dict) and res.get("res_model") == "stock.backorder.confirmation": + wizard = self.env[res["res_model"]].with_context(**res["context"]).create({}) + wizard.process() if backorder else wizard.process_cancel_backorder() + + def _receive_full(self, po): + """Recibe la cantidad pedida hasta stock, empujando toda la cadena (1/2/3 pasos).""" + for _i in range(8): + pickings = po.picking_ids.filtered(lambda p: p.state not in ("done", "cancel")) + for picking in pickings: + picking.action_assign() + ready = pickings.filtered(lambda p: p.state in ("assigned", "partially_available")) + if not ready: + break + for picking in ready: + self._validate(picking) + + def _receive_partial_at_input(self, po, qty): + """Recibe parcialmente en el primer picking (proveedor->entrada) y deja backorder.""" + picking = po.picking_ids.filtered( + lambda p: p.picking_type_id.code == "incoming" and p.state not in ("done", "cancel") + ).sorted("id")[:1] + self._validate(picking, qty=qty, backorder=True) + + def _return(self, po, qty, to_refund=True, validate=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 line in wizard.product_return_moves: + line.quantity = qty + line.to_refund = to_refund + action = wizard.action_create_returns() + return_picking = self.env["stock.picking"].browse(action["res_id"]) + if validate: + self._validate(return_picking) + return return_picking + + # -------------------------------------------------------------- assertions + def _assert_no_vendor_pending(self, line): + """No debe quedar recepción pendiente DEL PROVEEDOR ni contra-entrega al proveedor.""" + chain = self._chain(line) + vendor_pending = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage == "supplier" + and not m._is_exchange_receipt() + ) + self.assertFalse(vendor_pending, "quedó recepción pendiente del proveedor tras cancelar remanente") + out_to_vendor = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_dest_id.usage == "supplier" + and not m._is_exchange_receipt() + ) + self.assertFalse(out_to_vendor, "quedó una contra-entrega (OUT) al proveedor tras cancelar remanente") + self.assertAlmostEqual(line.product_qty, line.qty_received + line.qty_returned) + + # -------------------------------------------------------------------- tests + def test_partial_receipt_cancel_remaining(self): + """Caso base sin devoluciones: recibir parcial y cancelar remanente cierra la línea, en 1/2/3 pasos.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + product = self._product("CR base %s" % steps) + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + line.button_cancel_remaining() + self._assert_no_vendor_pending(line) + self.assertEqual(line.product_qty, 2) + + def test_return_refund_then_cancel_remaining(self): + """Ticket 124773: recibir todo, devolver con reembolso, subir la cantidad y cancelar remanente + no debe dejar (ni inflar) el IN pendiente, en 1/2/3 pasos.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + product = self._product("CR 124773 %s" % steps) + po = self._confirm_po(product, 4) + line = po.order_line + self._receive_full(po) + self._return(po, 4, to_refund=True) + line.invalidate_recordset() + line.product_qty = 6 + line.button_cancel_remaining() + self._assert_no_vendor_pending(line) + + def test_open_return_not_cancelled(self): + """Una devolución al proveedor ABIERTA (sin validar) no es parte del remanente y NO debe + cancelarse al cancelar remanente (arista reportada en base cummotors: cancelaba todo).""" + for to_refund in (True, False): + with self.subTest(to_refund=to_refund): + self.warehouse.reception_steps = "one_step" + product = self._product("CR open return %s" % to_refund) + po = self._confirm_po(product, 10) + line = po.order_line + self._receive_partial_at_input(po, 6) # deja backorder de 4 = remanente a cancelar + open_return = self._return(po, 2, to_refund=to_refund, validate=False) + self.assertNotIn(open_return.state, ("done", "cancel")) + line.button_cancel_remaining() + open_return.invalidate_recordset() + self.assertNotEqual( + open_return.state, "cancel", "cancelar remanente canceló una devolución abierta en curso" + ) + self.assertTrue( + open_return.move_ids.filtered(lambda m: m.state != "cancel"), + "el move de la devolución abierta quedó cancelado", + ) + # el remanente forward del proveedor (backorder de 4) sí debe cancelarse + forward_pending = self._chain(line).filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage == "supplier" + and not m.origin_returned_move_id + ) + self.assertFalse(forward_pending, "no se canceló el remanente forward del proveedor") + + def test_received_goods_preserved(self): + """Cancelar remanente no debe cancelar lo ya recibido: en 2/3 pasos el tránsito interno sobrevive.""" + self.warehouse.reception_steps = "two_steps" + product = self._product("CR preserva") + po = self._confirm_po(product, 6) + line = po.order_line + self._receive_partial_at_input(po, 2) + line.button_cancel_remaining() + chain = self._chain(line) + internal_alive = chain.filtered( + lambda m: m.state not in ("done", "cancel") + and m.location_id.usage in ("internal", "transit") + and m.location_dest_id.usage in ("internal", "transit") + ) + self.assertTrue(internal_alive, "se canceló por error la mercadería recibida en tránsito a stock") + self.assertAlmostEqual(sum(internal_alive.mapped("product_uom_qty")), 2) From a7f65377925a4c78a8e756c05b7e3211dd852765 Mon Sep 17 00:00:00 2001 From: Celina Devigili Date: Mon, 5 Oct 2026 20:08:21 +0000 Subject: [PATCH 02/15] [FIX] purchase_stock_ux: cancel remaining on subcontracted lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancel remaining on a subcontracted line raised "Nothing to check the availability for." and left the order unusable, with nothing or part of it received. Lowering product_qty makes core create a negative move that nets against the pending receipt and empties it. mrp_subcontracting then calls action_assign() on that receipt in _action_confirm(), and the receipt has no open move left. For subcontracted lines, cancel the open receipt moves before lowering the quantity, so no negative move is created. Cancelling the receipt cancels the subcontracting MO and, through its procurement group, the resupply transfer. Ordering the quantity again creates a new MO with the current BoM. propagate_cancel is turned off first, as core does when it nets the negative move, so a customer delivery fed by the receipt is not cancelled. If the subcontractor already recorded production on the MO, stop with a message instead of cancelling it and losing what was recorded: the user receives what was produced or cancels the MO first. With a refunded return the line keeps the previous flow: there the negative move does not empty the receipt, and cancelling first would make core create a new receipt and MO for the returned quantity. Other lines keep the current flow. The filter of open moves to cancel moves to _get_remaining_moves_to_cancel(), used in both places. Change note: Al usar "Cancelar remanente" en una línea de compra de un producto subcontratado, con nada o parte recibido, Odoo mostraba el error "No hay nada para lo que comprobar disponibilidad" y la compra quedaba sin poder modificarse. Ahora se cancelan la recepción pendiente, la orden de subcontratación y el reabastecimiento al subcontratista. Si después se vuelve a cargar la cantidad, se genera todo de nuevo con la lista de materiales vigente. La entrega a un cliente que dependa de esa compra no se cancela. Si el subcontratista ya registró producción, se avisa para recibir lo producido o cancelar esa orden de producción antes de cancelar el remanente. X-original-commit: 395ee690fdbaa2ac42baa49742315ac7cbdcbc79 (cherry picked from commit 2685dbbb06586135eb80a12a3110cd15400ea5da) --- purchase_stock_ux/i18n/es.po | 11 ++ .../models/purchase_order_line.py | 39 ++++- .../tests/test_cancel_remaining.py | 146 ++++++++++++++++++ 3 files changed, 191 insertions(+), 5 deletions(-) diff --git a/purchase_stock_ux/i18n/es.po b/purchase_stock_ux/i18n/es.po index 2e9b4add..4d35b6d4 100644 --- a/purchase_stock_ux/i18n/es.po +++ b/purchase_stock_ux/i18n/es.po @@ -45,6 +45,17 @@ msgstr "" "Cancelar remanente para línea \"%s\" (id %s), cantidad actualizada de la " "linea desde %s a %s" +#. module: purchase_stock_ux +#. odoo-python +#: code:addons/purchase_stock_ux/models/purchase_order_line.py:0 +msgid "" +"The subcontractor already recorded production on %(productions)s. Receive " +"what was produced or cancel that manufacturing order before cancelling the " +"remaining quantity." +msgstr "" +"El subcontratista ya registró producción en %(productions)s. Reciba lo " +"producido o cancele esa orden de producción antes de cancelar el remanente." + #. module: purchase_stock_ux #. odoo-python #: code:addons/purchase_stock_ux/models/purchase_order_line.py:0 diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index ad2068e4..150efd8d 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -113,6 +113,30 @@ def button_cancel_remaining(self): raise UserError( _("Cancel remaining can't be called for Kit Products (products with a bom of type kit).") ) + # Subcontracting: once core nets the negative move, mrp_subcontracting runs action_assign + # on the emptied receipt and raises. Cancel the remainder first so no negative move is + # created; like core's netting, don't propagate the cancel to destination moves. + subcontracted_moves = rec._get_remaining_moves_to_cancel().filtered( + lambda m: "is_subcontract" in m._fields and m.is_subcontract + ) + if subcontracted_moves: + recorded_productions = subcontracted_moves._get_subcontract_production().filtered( + "subcontracting_has_been_recorded" + ) + if recorded_productions: + raise UserError( + _( + "The subcontractor already recorded production on %(productions)s. " + "Receive what was produced or cancel that manufacturing order before " + "cancelling the remaining quantity.", + productions=", ".join(recorded_productions.mapped("name")), + ) + ) + # With refunded returns the negative move does not empty the receipt, and cancelling + # first would make core create a new receipt (and MO) for the returned quantity. + if float_is_zero(rec.qty_returned, precision_rounding=rec.product_uom_id.rounding): + subcontracted_moves.write({"propagate_cancel": False}) + subcontracted_moves.with_context(cancel_from_order=True)._action_cancel() rec.with_context(cancel_from_order=True).product_qty = rec.qty_received + rec.qty_returned # Bajar product_qty hace que Odoo (>=16) reduzca los moves, pero no siempre cancela el # remanente de recepción: si hubo una devolución con reembolso, o si el move negativo no @@ -124,11 +148,7 @@ def button_cancel_remaining(self): # excluimos los reemplazos de devolución con cambio (recepción legítima esperada) y las # devoluciones al proveedor abiertas sin validar (origin_returned_move_id), que no son parte # del remanente y no deben cancelarse. - rec.move_ids.filtered( - lambda m: m.state not in ("done", "cancel") - and not m._is_exchange_receipt() - and not m.origin_returned_move_id - ).with_context(cancel_from_order=True)._action_cancel() + rec._get_remaining_moves_to_cancel().with_context(cancel_from_order=True)._action_cancel() if rec.product_qty < old_product_qty: rec.order_id._log_decrease_ordered_quantity({rec: (rec.product_qty, old_product_qty)}) rec.order_id.message_post( @@ -140,6 +160,15 @@ def button_cancel_remaining(self): if orders_to_relock: orders_to_relock.with_context(tracking_disable=True).write({"state": "done"}) + def _get_remaining_moves_to_cancel(self): + """Open moves of the line, without exchange replacements nor moves created by a return.""" + self.ensure_one() + return self.move_ids.filtered( + lambda m: m.state not in ("done", "cancel") + and not m._is_exchange_receipt() + and not m.origin_returned_move_id + ) + def _compute_vouchers(self): # Cambiamos esta lógica ya que antes teníamos si o si voucher_ids por dependencias y ahora va a depender de que esté instalado stock_voucher for rec in self: diff --git a/purchase_stock_ux/tests/test_cancel_remaining.py b/purchase_stock_ux/tests/test_cancel_remaining.py index 6b331ca0..e2cb50c3 100644 --- a/purchase_stock_ux/tests/test_cancel_remaining.py +++ b/purchase_stock_ux/tests/test_cancel_remaining.py @@ -1,5 +1,6 @@ from odoo import Command from odoo.addons.purchase_stock.tests.common import PurchaseTestCommon +from odoo.exceptions import UserError class TestCancelRemaining(PurchaseTestCommon): @@ -197,3 +198,148 @@ def test_received_goods_preserved(self): ) self.assertTrue(internal_alive, "se canceló por error la mercadería recibida en tránsito a stock") self.assertAlmostEqual(sum(internal_alive.mapped("product_uom_qty")), 2) + + def _subcontracted_product(self, name): + """Storable product with a subcontracting BoM (subcontractor: self.partner) whose component + is resupplied on order. Skips the test when mrp_subcontracting is not installed.""" + resupply_route = self.env.ref("mrp_subcontracting.route_resupply_subcontractor_mto", raise_if_not_found=False) + if not resupply_route: + self.skipTest("mrp_subcontracting is not installed") + component = self._product("%s component" % name) + component.route_ids = [Command.link(resupply_route.id)] + finished = self._product(name) + bom = self.env["mrp.bom"].create( + { + "product_tmpl_id": finished.product_tmpl_id.id, + "type": "subcontract", + "subcontractor_ids": [Command.link(self.partner.id)], + "bom_line_ids": [Command.create({"product_id": component.id, "product_qty": 1})], + } + ) + return finished, bom + + def _open_moves_of_order(self, po, exclude=None): + return self.env["stock.move"].search( + [("reference_ids", "in", po.reference_ids.ids), ("state", "not in", ("done", "cancel"))] + ) - (exclude or self.env["stock.move"]) + + def test_subcontracted_cancel_remaining_nothing_received(self): + """Subcontracting: cancel remaining with nothing received must not raise and must cancel the + whole receipt chain (1/2/3 steps), the subcontracting MO and the resupply, so the line can be + re-ordered with the new BoM.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + finished, bom = self._subcontracted_product("CR subcontract %s" % steps) + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + resupply = production.picking_ids + self.assertTrue(resupply, "no resupply picking was created for the subcontracting MO") + # the BoM changes after the order was confirmed + new_comp = self._product("CR subcontract new component %s" % steps) + bom.bom_line_ids = [Command.clear(), Command.create({"product_id": new_comp.id, "product_qty": 1})] + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 0) + self.assertFalse(self._open_moves_of_order(po), "a receipt leg was left open") + self.assertEqual(production.state, "cancel") + self.assertEqual(resupply.state, "cancel") + + # ordering again takes the current BoM + line.product_qty = 10 + new_production = self.env["mrp.production"].search( + [("product_id", "=", finished.id), ("state", "!=", "cancel")] + ) + self.assertEqual(new_production.move_raw_ids.product_id, new_comp) + + def test_subcontracted_recorded_production_blocks_cancel_remaining(self): + """If the subcontractor already recorded production, cancel remaining stops with a message + instead of cancelling the MO and losing what was recorded.""" + self.warehouse.reception_steps = "one_step" + finished, _bom = self._subcontracted_product("CR subcontract recorded") + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + production.subcontracting_has_been_recorded = True + + with self.assertRaisesRegex(UserError, production.name): + line.button_cancel_remaining() + self.assertEqual(line.product_qty, 10) + self.assertNotEqual(production.state, "cancel") + + def test_subcontracted_partial_receipt_cancel_remaining(self): + """Subcontracting with a partial receipt: cancel remaining cancels the pending receipt and its + MO, and keeps the internal legs (2/3 steps) that bring the received goods to stock.""" + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + finished, _bom = self._subcontracted_product("CR subcontract partial %s" % steps) + po = self._confirm_po(finished, 10) + line = po.order_line + self._receive_partial_at_input(po, 4) + + line.button_cancel_remaining() + + self.assertEqual(line.product_qty, 4) + self.assertFalse( + self._open_moves_of_order(po).filtered("purchase_line_id"), "the pending receipt was left open" + ) + self.assertFalse( + self.env["mrp.production"].search( + [("product_id", "=", finished.id), ("state", "not in", ("done", "cancel"))] + ), + "the MO of the pending quantity was left open", + ) + internal_alive = self._open_moves_of_order(po).filtered( + lambda m: m.location_id.usage == "internal" + and m.location_dest_id.usage == "internal" + and not m.location_dest_id.is_subcontract() + ) + if steps == "one_step": + self.assertFalse(internal_alive) + else: + self.assertAlmostEqual(sum(internal_alive.mapped("product_uom_qty")), 4) + + def test_subcontracted_cancel_remaining_keeps_sale_delivery(self): + """Subcontracting bought on order for a sale: cancel remaining on the purchase cancels the + receipt chain (1/2/3 steps) but not the customer delivery, as core's netting does not either.""" + if "sale_line_id" not in self.env["stock.move"]._fields: + self.skipTest("sale_stock is not installed") + self.warehouse.delivery_steps = "ship_only" + mto_route = self.warehouse.mto_pull_id.route_id + mto_route.active = True + for steps in self.STEPS: + with self.subTest(steps=steps): + self.warehouse.reception_steps = steps + # the purchase line takes it from the buy rule; with it on, a plain cancel would reach the delivery + self.warehouse.buy_pull_id.propagate_cancel = True + finished, _bom = self._subcontracted_product("CR subcontract MTO %s" % steps) + finished.route_ids = [ + Command.link(mto_route.id), + Command.link(self.warehouse.buy_pull_id.route_id.id), + ] + finished.seller_ids = [Command.create({"partner_id": self.partner.id, "price": 10.0})] + sale_vals = { + "partner_id": self.env["res.partner"].create({"name": "CR customer"}).id, + "order_line": [Command.create({"product_id": finished.id, "product_uom_qty": 5})], + } + # sale exception rules installed in the build must not block this confirmation + if "ignore_exception" in self.env["sale.order"]._fields: + sale_vals["ignore_exception"] = True + sale = self.env["sale.order"].create(sale_vals) + sale.action_confirm() + delivery_move = sale.order_line.move_ids + line = self.env["purchase.order.line"].search([("product_id", "=", finished.id)]) + self.assertEqual(len(line), 1, "the sale did not generate the purchase to the subcontractor") + line.order_id.button_confirm() + self.assertTrue(line.propagate_cancel) + self.assertIn(delivery_move, self._chain(line)) + + line.button_cancel_remaining() + + self.assertFalse( + self._open_moves_of_order(line.order_id, exclude=delivery_move), "a receipt leg was left open" + ) + self.assertNotEqual(delivery_move.state, "cancel", "cancel remaining cancelled the customer delivery") From 510421050370d92afc723eb95b5488d5e163faf6 Mon Sep 17 00:00:00 2001 From: Celina Devigili Date: Tue, 27 Oct 2026 16:28:02 +0000 Subject: [PATCH 03/15] [FIX] purchase_stock_ux: detect recorded subcontract production in 19.0 The cancel remaining guard relied on a helper and a flag that do not exist in 19.0 (subcontracting_has_been_recorded is never written), so it never stopped and the subcontracting MO was cancelled together with what the subcontractor recorded. Use the state of the MO instead: progress or to_close. (cherry picked from commit 2584a69172f1b580c9f7ecb4460f0fb0d53a9605) --- .../models/purchase_order_line.py | 2 +- .../tests/test_cancel_remaining.py | 21 +++++++++++-------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/purchase_stock_ux/models/purchase_order_line.py b/purchase_stock_ux/models/purchase_order_line.py index 150efd8d..03200ba8 100644 --- a/purchase_stock_ux/models/purchase_order_line.py +++ b/purchase_stock_ux/models/purchase_order_line.py @@ -121,7 +121,7 @@ def button_cancel_remaining(self): ) if subcontracted_moves: recorded_productions = subcontracted_moves._get_subcontract_production().filtered( - "subcontracting_has_been_recorded" + lambda p: p.state in ("progress", "to_close") ) if recorded_productions: raise UserError( diff --git a/purchase_stock_ux/tests/test_cancel_remaining.py b/purchase_stock_ux/tests/test_cancel_remaining.py index e2cb50c3..c5a37734 100644 --- a/purchase_stock_ux/tests/test_cancel_remaining.py +++ b/purchase_stock_ux/tests/test_cancel_remaining.py @@ -258,16 +258,19 @@ def test_subcontracted_recorded_production_blocks_cancel_remaining(self): """If the subcontractor already recorded production, cancel remaining stops with a message instead of cancelling the MO and losing what was recorded.""" self.warehouse.reception_steps = "one_step" - finished, _bom = self._subcontracted_product("CR subcontract recorded") - po = self._confirm_po(finished, 10) - line = po.order_line - production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) - production.subcontracting_has_been_recorded = True + for state, qty in (("progress", 4), ("to_close", 10)): + with self.subTest(state=state): + finished, _bom = self._subcontracted_product("CR subcontract recorded %s" % state) + po = self._confirm_po(finished, 10) + line = po.order_line + production = self.env["mrp.production"].search([("product_id", "=", finished.id)]) + production.qty_producing = qty + self.assertEqual(production.state, state) - with self.assertRaisesRegex(UserError, production.name): - line.button_cancel_remaining() - self.assertEqual(line.product_qty, 10) - self.assertNotEqual(production.state, "cancel") + with self.assertRaisesRegex(UserError, production.name): + line.button_cancel_remaining() + self.assertEqual(line.product_qty, 10) + self.assertNotEqual(production.state, "cancel") def test_subcontracted_partial_receipt_cancel_remaining(self): """Subcontracting with a partial receipt: cancel remaining cancels the pending receipt and its From 8172e9cbc901c7d88e44a1077aeac5977530a519 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Thu, 29 Oct 2026 12:03:34 -0300 Subject: [PATCH 04/15] [MIG] purchase_stock_ux: Migration to 20.0 --- purchase_stock_ux/__manifest__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/purchase_stock_ux/__manifest__.py b/purchase_stock_ux/__manifest__.py index 037fdc04..4b71901f 100644 --- a/purchase_stock_ux/__manifest__.py +++ b/purchase_stock_ux/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "Purchase Stock UX", - "version": "19.0.1.4.0", + "version": "20.0.1.0.0", "category": "Purchases", "sequence": 14, "summary": "", @@ -41,7 +41,7 @@ "wizards/purchase_order_cancel_remaining.xml", ], "demo": [], - "installable": False, + "installable": True, "auto_install": True, "application": False, } From abcf163833565b9bcfafbde10ea5e316e5ceba54 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Thu, 29 Oct 2026 12:04:09 -0300 Subject: [PATCH 05/15] [MIG] purchase_stock_ux: upgrade_code 19.4-00-ir-access --- purchase_stock_ux/__manifest__.py | 2 +- purchase_stock_ux/security/ir.access.csv | 2 ++ purchase_stock_ux/security/ir.model.access.csv | 2 -- 3 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 purchase_stock_ux/security/ir.access.csv delete mode 100644 purchase_stock_ux/security/ir.model.access.csv diff --git a/purchase_stock_ux/__manifest__.py b/purchase_stock_ux/__manifest__.py index 4b71901f..0684d442 100644 --- a/purchase_stock_ux/__manifest__.py +++ b/purchase_stock_ux/__manifest__.py @@ -33,7 +33,7 @@ "stock_ux", ], "data": [ - "security/ir.model.access.csv", + "security/ir.access.csv", "views/purchase_order_views.xml", "views/purchase_line_views.xml", "views/stock_move_views.xml", diff --git a/purchase_stock_ux/security/ir.access.csv b/purchase_stock_ux/security/ir.access.csv new file mode 100644 index 00000000..cec1c9b1 --- /dev/null +++ b/purchase_stock_ux/security/ir.access.csv @@ -0,0 +1,2 @@ +id,name,model_id,group_id/id,operation,domain +access_purchase_order_cancel_remaining_wizard,access_purchase_order_cancel_remaining_wizard,purchase.order.cancel.remaining,base.group_user,cru, diff --git a/purchase_stock_ux/security/ir.model.access.csv b/purchase_stock_ux/security/ir.model.access.csv deleted file mode 100644 index 69b40db4..00000000 --- a/purchase_stock_ux/security/ir.model.access.csv +++ /dev/null @@ -1,2 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_purchase_order_cancel_remaining_wizard,access_purchase_order_cancel_remaining_wizard,model_purchase_order_cancel_remaining,base.group_user,1,1,1,0 From 60ffa288caf9e56ef39700266b220bf6b6ba6f92 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Thu, 29 Oct 2026 12:05:08 -0300 Subject: [PATCH 06/15] [MIG] purchase_stock_ux: icons to Material Symbols Odoo 20 does not load Font Awesome anymore. Applied port_icons.py (Odoo's fa_to_ms table): fa-ban -> block, fa-credit-card -> credit_card. --- purchase_stock_ux/views/purchase_order_views.xml | 2 +- purchase_stock_ux/views/stock_move_views.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/purchase_stock_ux/views/purchase_order_views.xml b/purchase_stock_ux/views/purchase_order_views.xml index 373e3a36..38503d25 100644 --- a/purchase_stock_ux/views/purchase_order_views.xml +++ b/purchase_stock_ux/views/purchase_order_views.xml @@ -14,7 +14,7 @@ -