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)