diff --git a/purchase_sale_stock_inter_company/__manifest__.py b/purchase_sale_stock_inter_company/__manifest__.py index 939b286ff1e..b819b992adf 100644 --- a/purchase_sale_stock_inter_company/__manifest__.py +++ b/purchase_sale_stock_inter_company/__manifest__.py @@ -14,5 +14,5 @@ "installable": True, "auto_install": True, "depends": ["purchase_sale_inter_company", "sale_stock", "purchase_stock"], - "data": ["views/res_config_view.xml"], + "data": ["views/res_config_view.xml", "views/stock_picking_type_views.xml"], } diff --git a/purchase_sale_stock_inter_company/models/__init__.py b/purchase_sale_stock_inter_company/models/__init__.py index fd0b2e801f8..700283196a1 100644 --- a/purchase_sale_stock_inter_company/models/__init__.py +++ b/purchase_sale_stock_inter_company/models/__init__.py @@ -2,3 +2,5 @@ from . import res_company from . import res_config from . import stock_picking +from . import stock_production_lot +from . import stock_picking_type diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 03cfb27d2d7..5994af336d0 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -2,7 +2,7 @@ # Copyright 2018 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, fields, models +from odoo import SUPERUSER_ID, _, fields, models from odoo.exceptions import UserError @@ -10,6 +10,9 @@ class StockPicking(models.Model): _inherit = "stock.picking" intercompany_picking_id = fields.Many2one(comodel_name="stock.picking") + intercompany_create_lots_mode = fields.Selection( + related="picking_type_id.intercompany_create_lots_mode" + ) def _get_product_intercompany_qty_done_dict(self, sale_move_lines, po_move_lines): product = po_move_lines[0].product_id @@ -17,47 +20,110 @@ def _get_product_intercompany_qty_done_dict(self, sale_move_lines, po_move_lines res = {product: qty_done} return res + def _get_intercompany_move_lots(self, sale_move_lines, po_moves_open, **kwargs): + po_move_lots = self.env["stock.lot"] + lot_creation_mode = self.intercompany_create_lots_mode + if lot_creation_mode == "same": + for sale_lot in sale_move_lines.lot_id: + po_move_lots |= sale_lot.get_inter_company_lot( + po_moves_open.company_id, **kwargs + ) + elif lot_creation_mode == "manual": + po_move_lots |= po_moves_open.mapped("lot_ids") + return po_move_lots + + def _check_manual_lots(self, move, product): + self.ensure_one() + lot_names = move.move_line_ids.mapped("lot_name") + lot_ids = move.lot_ids + if ( + self.intercompany_create_lots_mode == "manual" + and product.tracking != "none" + and move.quantity_done + and not lot_names + and not lot_ids + ): + raise UserError( + _( + "To validate the delivery, you must first assign lot/serial numbers" + " manually on the receipt of the intercompany purchase." + ) + ) + def _set_intercompany_picking_qty(self, purchase): po_picks = self.browse() - sale_line_ids = self.move_line_ids.mapped("move_id.sale_line_id") + sale_line_ids = self.move_line_ids.move_id.sale_line_id for sale_line in sale_line_ids: sale_move_lines = self.move_line_ids.filtered( lambda ml: ml.move_id.sale_line_id == sale_line ) - po_move_lines = sale_line.auto_purchase_line_id.move_ids.mapped( - "move_line_ids" + po_moves_open = sale_line.auto_purchase_line_id.move_ids.filtered( + lambda sm: sm.state not in ["draft", "done", "cancel"] ) - if not po_move_lines: - raise UserError( - _( - "There's no corresponding line in PO %(po)s for assigning " - "qty from %(pick_name)s for product %(product)s" - ) - % ( - { - "po": purchase.name, - "pick_name": self.name, - "product": sale_line.product_id.name, - } - ) + if not po_moves_open: + po_moves_done = sale_line.auto_purchase_line_id.move_ids.filtered( + lambda sm: sm.state == "done" ) + if po_moves_done: + for done_move in po_moves_done: + new_move = done_move.copy( + { + "quantity_done": 0.0, + "move_line_ids": False, + } + ) + po_moves_open |= new_move + else: + note = _( + "Mismatch between move lines with the " + "corresponding PO %(po)s for assigning " + "quantities and lots from %(pick_name)s for product %(product)s" + ) % { + "po": purchase.name, + "pick_name": self.name, + "product": sale_line.product_id.name, + } + purchase.activity_schedule( + "mail.mail_activity_data_warning", + date_deadline=fields.Date.today(), + note=note, + user_id=( + self.sale_id.user_id.id + or self.sale_id.team_id.user_id.id + or SUPERUSER_ID + ), + ) + continue + po_moves_open.picking_id.action_assign() product_qty_done = self._get_product_intercompany_qty_done_dict( - sale_move_lines, po_move_lines + sale_move_lines, po_moves_open.move_line_ids + ) + po_move_lots = self._get_intercompany_move_lots( + sale_move_lines, po_moves_open ) for product, qty_done in product_qty_done.items(): - product_po_mls = po_move_lines.filtered( + product_po_moves = po_moves_open.filtered( + lambda x: x.exists() and x.product_id == product + ) + product_po_lots = po_move_lots.filtered( lambda x: x.product_id == product ) - for po_move_line in product_po_mls: - if po_move_line.reserved_qty >= qty_done: - po_move_line.qty_done = qty_done + for po_move in product_po_moves: + if po_move.product_uom_qty >= qty_done: + po_move.quantity_done = qty_done + po_move.lot_ids = product_po_lots qty_done = 0.0 - elif po_move_line.reserved_qty: - po_move_line.qty_done = po_move_line.reserved_qty - qty_done -= po_move_line.reserved_qty - po_picks |= po_move_line.picking_id - if qty_done and product_po_mls: - product_po_mls[-1:].qty_done += qty_done + else: + po_move.quantity_done = po_move.product_uom_qty + po_move.lot_ids = product_po_lots[: po_move.product_uom_qty] + product_po_lots = product_po_lots[po_move.product_uom_qty :] + qty_done -= po_move.product_uom_qty + self._check_manual_lots(po_move, product) + po_picks |= po_move.picking_id + if qty_done and product_po_moves: + product_po_moves[-1:].quantity_done += qty_done + product_po_moves[-1:].lot_ids |= product_po_lots + self._check_manual_lots(product_po_moves[-1:], product) return po_picks def _action_done(self): @@ -71,7 +137,73 @@ def _action_done(self): continue purchase.picking_ids.write({"intercompany_picking_id": pick.id}) po_picks |= pick._set_intercompany_picking_qty(purchase) - # Transfer dropship pickings + + # Process Returns (incoming from customer) + for pick in self.filtered( + lambda x: x.location_id.usage == "customer" and x.origin + ).sudo(): + source_picking = pick.move_ids.origin_returned_move_id.picking_id + if not source_picking: + continue + sale_order = source_picking.sale_id + if not sale_order or not sale_order.auto_purchase_order_id: + continue + purchase = sale_order.auto_purchase_order_id + po_delivery = purchase.picking_ids.filtered( + lambda p: p.intercompany_picking_id == source_picking + and p.state == "done" + ) + if not po_delivery: + continue + po_return = po_delivery.move_ids.returned_move_ids.picking_id.filtered( + lambda p: p.state not in ["done", "cancel"] + ) + if not po_return: + po_return = pick._create_intercompany_return(po_delivery[-1], purchase) + if po_return: + po_picks |= pick._sync_return_quantities(po_return, purchase) + + # Transfer dropship/return pickings for po_pick in po_picks.sudo(): po_pick.with_company(po_pick.company_id.id)._action_done() return super()._action_done() + + def _create_intercompany_return(self, po_delivery, purchase): + return_wizard = ( + self.env["stock.return.picking"] + .with_context(active_id=po_delivery.id, active_model="stock.picking") + .with_company(purchase.company_id.id) + .create({}) + ) + return_wizard._onchange_picking_id() + # TODO: filter lines by product/qty when the return is not complete + result = return_wizard.create_returns() + po_return = self.env["stock.picking"].browse(result["res_id"]) + return po_return + + def _sync_return_quantities(self, po_return, purchase): + po_return.action_assign() + for return_move_line in self.move_line_ids: + # TODO: filter lines by product/qty when the return is not complete + po_move_lines = po_return.move_line_ids + qty_to_set = return_move_line.qty_done + for po_ml in po_move_lines: + if qty_to_set <= 0: + break + available_qty = po_ml.reserved_uom_qty or po_ml.product_uom_qty + qty_done = min(qty_to_set, available_qty) + po_ml.qty_done = qty_done + if return_move_line.lot_id: + matching_lot = self.env["stock.lot"].search( + [ + ("name", "=", return_move_line.lot_id.name), + ("product_id", "=", po_ml.product_id.id), + ("company_id", "=", purchase.company_id.id), + ], + limit=1, + ) + if matching_lot: + po_ml.lot_id = matching_lot + qty_to_set -= qty_done + po_return.write({"intercompany_picking_id": self.id}) + return po_return diff --git a/purchase_sale_stock_inter_company/models/stock_picking_type.py b/purchase_sale_stock_inter_company/models/stock_picking_type.py new file mode 100644 index 00000000000..e963b44a6de --- /dev/null +++ b/purchase_sale_stock_inter_company/models/stock_picking_type.py @@ -0,0 +1,20 @@ +# Copyright 2025 ForgeFlow S.L. (https://www.forgeflow.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockPickingType(models.Model): + _inherit = "stock.picking.type" + + intercompany_create_lots_mode = fields.Selection( + [("same", "Use Same Number"), ("manual", "Assign Number Manually")], + string="Lots Creation Mode in Intercompany", + default="same", + required=True, + help=" Select which logic to follow when a receipt with products " + "tracked by Lots/Serial numbers is auto validated by an intercompany flow:\n" + "* Use Same Number: Create a Lot/Serial number with the same number as in the " + "originating company.\n" + "* Assign Number Manually: Assign manually in the receipt the lot number to be used.\n", + ) diff --git a/purchase_sale_stock_inter_company/models/stock_production_lot.py b/purchase_sale_stock_inter_company/models/stock_production_lot.py new file mode 100644 index 00000000000..d35c0c7bdf7 --- /dev/null +++ b/purchase_sale_stock_inter_company/models/stock_production_lot.py @@ -0,0 +1,44 @@ +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Kévin Roche +# @author Guillaume MASSON +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockProductionLot(models.Model): + _inherit = "stock.lot" + + def _get_inter_company_lot_product(self, **kwargs): + """Return the product to use when searching/creating the lot. + Hook for customization. By default, it’s just the selling company + lot’s product. + """ + self.ensure_one() + return self.product_id + + def prepare_inter_company_lot_values(self, company, product): + res = { + "name": self.name, + "product_id": product.id, + "company_id": company.id, + } + if "expiration_date" in self._fields: + res.update(expiration_date=self.expiration_date) + return res + + def get_inter_company_lot(self, company, **kwargs): + self.ensure_one() + product = self._get_inter_company_lot_product(**kwargs) + lot = self.sudo().search( + [ + ("name", "=", self.name), + ("product_id", "=", product.id), + ("company_id", "=", company.id), + ] + ) + if not lot: + lot = self.sudo().create( + self.prepare_inter_company_lot_values(company, product) + ) + return lot diff --git a/purchase_sale_stock_inter_company/tests/test_inter_company_purchase_sale_stock.py b/purchase_sale_stock_inter_company/tests/test_inter_company_purchase_sale_stock.py index 7ffd37f62d0..2967a2fad6f 100644 --- a/purchase_sale_stock_inter_company/tests/test_inter_company_purchase_sale_stock.py +++ b/purchase_sale_stock_inter_company/tests/test_inter_company_purchase_sale_stock.py @@ -4,6 +4,8 @@ # Copyright 2020 ForgeFlow S.L. (https://www.forgeflow.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.exceptions import UserError +from odoo.tests import Form from odoo.addons.purchase_sale_inter_company.tests.test_inter_company_purchase_sale import ( TestPurchaseSaleInterCompany, @@ -114,3 +116,123 @@ def test_purchase_sale_with_two_products_no_backorder(self): sale_picking.sudo().button_validate() self.assertEqual(len(self.purchase_company_a.picking_ids), 1) self.assertEqual(len(self.purchase_company_a.picking_ids.move_line_ids), 2) + + def test_sync_inter_company_picking_qty_with_lot_same_creation_mode(self): + self.product.type = "product" + self.product.tracking = "serial" + self.serial01 = self.env["stock.lot"].create( + { + "name": "Serial01", + "product_id": self.product.id, + "company_id": self.company_b.id, + } + ) + self.partner_company_b.company_id = False + purchase = self.purchase_company_a + purchase.order_line.product_qty = 2.0 + sale = self._approve_po() + sale.action_confirm() + self.env["stock.quant"]._update_available_quantity( + self.product, sale.warehouse_id.lot_stock_id, 1, lot_id=self.serial01 + ) + sale_picking = sale.picking_ids[0] + sale_picking.picking_type_id.sudo().intercompany_create_lots_mode = "same" + sale_picking.sudo().action_confirm() + sale_picking.sudo().action_assign() + sale_picking.move_ids.quantity_done = 1.0 + self.assertEqual(sale_picking.move_line_ids.lot_id, self.serial01) + res_dict = sale_picking.sudo().button_validate() + backorder_wizard = Form( + self.env[res_dict["res_model"]].with_context(**res_dict["context"]) + ).save() + backorder_wizard.process() + self.assertEqual(purchase.picking_ids[0].move_line_ids.qty_done, 1) + self.assertEqual( + purchase.picking_ids[0].move_line_ids.lot_id.name, self.serial01.name + ) + self.assertEqual(purchase.picking_ids[1].move_line_ids.qty_done, 0) + self.assertEqual(purchase.order_line.qty_received, 1) + + def test_sync_inter_company_picking_qty_with_lot_manual_mode_success(self): + self.product.type = "product" + self.product.tracking = "serial" + self.serial01 = self.env["stock.lot"].create( + { + "name": "Serial01", + "product_id": self.product.id, + "company_id": self.company_b.id, + } + ) + self.serial02 = self.env["stock.lot"].create( + { + "name": "Serial02", + "product_id": self.product.id, + "company_id": self.company_a.id, + } + ) + self.partner_company_b.company_id = False + purchase = self.purchase_company_a + purchase.order_line.product_qty = 2.0 + sale = self._approve_po() + sale.action_confirm() + self.env["stock.quant"]._update_available_quantity( + self.product, sale.warehouse_id.lot_stock_id, 1, lot_id=self.serial01 + ) + sale_picking = sale.picking_ids[0] + sale_picking.picking_type_id.sudo().intercompany_create_lots_mode = "manual" + sale_picking.sudo().action_confirm() + sale_picking.sudo().action_assign() + sale_picking.move_ids.quantity_done = 1.0 + self.assertEqual(sale_picking.move_line_ids.lot_id, self.serial01) + + purchase.picking_ids[0].move_ids[0].lot_ids = [(4, self.serial02.id)] + + res_dict = sale_picking.sudo().button_validate() + backorder_wizard = Form( + self.env[res_dict["res_model"]].with_context(**res_dict["context"]) + ).save() + backorder_wizard.process() + self.assertEqual(purchase.picking_ids[0].move_line_ids.qty_done, 1) + self.assertEqual( + purchase.picking_ids[0].move_line_ids.lot_id.name, self.serial02.name + ) + self.assertEqual(purchase.picking_ids[1].move_line_ids.qty_done, 0) + self.assertEqual(purchase.order_line.qty_received, 1) + + def test_sync_inter_company_picking_qty_with_lot_manual_mode_error(self): + self.product.type = "product" + self.product.tracking = "serial" + self.serial01 = self.env["stock.lot"].create( + { + "name": "Serial01", + "product_id": self.product.id, + "company_id": self.company_b.id, + } + ) + self.serial02 = self.env["stock.lot"].create( + { + "name": "Serial02", + "product_id": self.product.id, + "company_id": self.company_a.id, + } + ) + self.partner_company_b.company_id = False + purchase = self.purchase_company_a + purchase.order_line.product_qty = 2.0 + sale = self._approve_po() + sale.action_confirm() + self.env["stock.quant"]._update_available_quantity( + self.product, sale.warehouse_id.lot_stock_id, 1, lot_id=self.serial01 + ) + sale_picking = sale.picking_ids[0] + sale_picking.picking_type_id.sudo().intercompany_create_lots_mode = "manual" + sale_picking.sudo().action_confirm() + sale_picking.sudo().action_assign() + sale_picking.move_ids.quantity_done = 1.0 + self.assertEqual(sale_picking.move_line_ids.lot_id, self.serial01) + res_dict = sale_picking.sudo().button_validate() + backorder_wizard = Form( + self.env[res_dict["res_model"]].with_context(**res_dict["context"]) + ).save() + with self.assertRaises(UserError): + backorder_wizard.process() diff --git a/purchase_sale_stock_inter_company/views/stock_picking_type_views.xml b/purchase_sale_stock_inter_company/views/stock_picking_type_views.xml new file mode 100644 index 00000000000..cfb61c88bd0 --- /dev/null +++ b/purchase_sale_stock_inter_company/views/stock_picking_type_views.xml @@ -0,0 +1,15 @@ + + + + stock.picking.type + + + + + + + +