diff --git a/purchase_sale_inter_company/models/stock_picking.py b/purchase_sale_inter_company/models/stock_picking.py index 1df7f835fb9..6e9fc4e683a 100644 --- a/purchase_sale_inter_company/models/stock_picking.py +++ b/purchase_sale_inter_company/models/stock_picking.py @@ -2,14 +2,21 @@ # Copyright 2018 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + from odoo import SUPERUSER_ID, _, api, fields, models from odoo.exceptions import UserError +_logger = logging.getLogger(__name__) + class StockPicking(models.Model): _inherit = "stock.picking" intercompany_picking_id = fields.Many2one(comodel_name="stock.picking", copy=False) + intercompany_return_picking_id = fields.Many2one( + comodel_name="stock.picking", copy=False + ) @api.depends("intercompany_picking_id.state") def _compute_state(self): @@ -32,7 +39,80 @@ def _compute_state(self): return res + def _warn_move_line_mismatch(self, ic_pick, product, ml, po_ml): + self.ensure_one() + note = _( + "Mismatch between move lines (%s vs %s) with the " + "corresponding PO picking %s for assigning " + "quantities and lots from %s for product %s" + ) % (len(ml), len(po_ml), ic_pick.name, self.name, product.name) + _logger.warning(note) + self.activity_schedule( + "mail.mail_activity_data_warning", + fields.Date.today(), + note=note, + # Try to notify someone relevant + user_id=( + self.sale_id.user_id.id + or self.sale_id.team_id.user_id.id + or SUPERUSER_ID, + ), + ) + + def _sync_lots(self, ml, po_ml): + lot_id = ml.lot_id + if not lot_id: + return + # search if the same lot exists in destination company + dest_lot_id = ( + self.env["stock.production.lot"] + .sudo() + .search( + [ + ("product_id", "=", lot_id.product_id.id), + ("name", "=", lot_id.name), + ("company_id", "=", po_ml.company_id.id), + ], + limit=1, + ) + ) + if not dest_lot_id: + # if it doesn't exist, create it by copying from original company + dest_lot_id = lot_id.copy({"company_id": po_ml.company_id.id}) + po_ml.lot_id = dest_lot_id + def _action_done(self): + # sync lots for move lines on returns + for pick in self.filtered(lambda x: x.intercompany_return_picking_id).sudo(): + ic_picking = pick.intercompany_return_picking_id + dest_company = ic_picking.sudo().company_id + if not dest_company.sync_picking: + continue + intercompany_user = dest_company.intercompany_sale_user_id + for product in pick.move_lines.mapped("product_id"): + if product.tracking == "none": + continue + move_lines = ( + pick.move_lines.filtered( + lambda x, prod=product: x.product_id == product + ) + .mapped("move_line_ids") + .filtered("lot_id") + ) + po_move_lines = ( + ic_picking.with_user(intercompany_user) + .with_company(dest_company) + .move_lines.filtered(lambda x, prod=product: x.product_id == prod) + .mapped("move_line_ids") + ) + if len(move_lines) != len(po_move_lines): + pick._warn_move_line_mismatch( + ic_picking, product, move_lines, po_move_lines + ) + for ml, po_ml in zip(move_lines, po_move_lines): + pick._sync_lots(ml, po_ml) + + # sync lots for move lines on pickings for pick in self.filtered( lambda x: x.location_dest_id.usage == "customer" ).sudo(): @@ -58,44 +138,14 @@ def _action_done_intercompany_actions(self, purchase): ).mapped("move_line_ids") ) if len(move_lines) != len(po_move_lines): - note = _( - "Mismatch between move lines with the " - "corresponding PO %s for assigning " - "quantities and lots from %s for product %s" - ) % (purchase.name, pick.name, move.product_id.name) - self.activity_schedule( - "mail.mail_activity_data_warning", - fields.Date.today(), - note=note, - # Try to notify someone relevant - user_id=( - pick.sale_id.user_id.id - or pick.sale_id.team_id.user_id.id - or SUPERUSER_ID, - ), + pick._warn_move_line_mismatch( + pick.intercompany_picking_id, + move.product_id, + move_lines, + po_move_lines, ) - # check and assign lots here for ml, po_ml in zip(move_lines, po_move_lines): - lot_id = ml.lot_id - if not lot_id: - continue - # search if the same lot exists in destination company - dest_lot_id = ( - self.env["stock.production.lot"] - .sudo() - .search( - [ - ("product_id", "=", lot_id.product_id.id), - ("name", "=", lot_id.name), - ("company_id", "=", po_ml.company_id.id), - ], - limit=1, - ) - ) - if not dest_lot_id: - # if it doesn't exist, create it by copying from original company - dest_lot_id = lot_id.copy({"company_id": po_ml.company_id.id}) - po_ml.lot_id = dest_lot_id + pick._sync_lots(ml, po_ml) except Exception: if self.env.company_id.sync_picking_failure_action == "raise": @@ -132,22 +182,30 @@ def button_validate(self): if ( dest_company and dest_company.sync_picking + # only if it worked, not if wizard was raised and record.state == "done" - and record.picking_type_code == "outgoing" ): - if record.intercompany_picking_id: - try: + try: + if ( + record.picking_type_code == "outgoing" + and record.intercompany_picking_id + ): record._sync_receipt_with_delivery( dest_company, record.sale_id, ) - except Exception: - if record.company_id.sync_picking_failure_action == "raise": - raise - else: - record._notify_picking_problem( - record.sale_id.auto_purchase_order_id - ) + elif ( + record.picking_type_code == "incoming" + and record.intercompany_return_picking_id + ): + record._sync_receipt_with_delivery(dest_company, None) + except Exception: + if record.company_id.sync_picking_failure_action == "raise": + raise + else: + record._notify_picking_problem( + record.sale_id.auto_purchase_order_id + ) # if the flag is set, block the validation of the picking in the destination company if self.env.company.block_po_manual_picking_validation: @@ -167,11 +225,53 @@ def button_validate(self): def _sync_receipt_with_delivery(self, dest_company, sale_order): self.ensure_one() intercompany_user = dest_company.intercompany_sale_user_id - purchase_order = sale_order.auto_purchase_order_id.sudo() - if not (purchase_order and purchase_order.picking_ids): - raise UserError(_("PO does not exist or has no receipts")) + + # sync SO return to PO return + if self.intercompany_return_picking_id: + moves = [(m, m.quantity_done) for m in self.move_ids_without_package] + dest_picking = self.intercompany_return_picking_id.with_user( + intercompany_user + ).with_company(dest_company) + all_dest_moves = ( + self.intercompany_return_picking_id.with_user(intercompany_user) + .with_company(dest_company) + .move_lines + ) + for move, qty in moves: + dest_moves = all_dest_moves.filtered( + lambda x, prod=move.product_id: x.product_id == prod + ) + remaining_qty = qty + remaining_ml = move.move_line_ids + while dest_moves and remaining_qty > 0.0: + dest_move = dest_moves[0] + to_assign = min( + remaining_qty, + dest_move.product_uom_qty - dest_move.quantity_done, + ) + final_qty = dest_move.quantity_done + to_assign + for line, dest_line in zip(remaining_ml, dest_move.move_line_ids): + # Assuming the order of move lines is the same on both moves + # is risky but what would be a better option? + dest_line.sudo().write( + { + "qty_done": line.qty_done, + } + ) + dest_move.quantity_done = final_qty + remaining_qty -= to_assign + if dest_move.quantity_done == dest_move.product_qty: + dest_moves -= dest_move + dest_picking._action_done() + + # sync SO to PO picking if self.intercompany_picking_id: - dest_picking = self.intercompany_picking_id.with_user(intercompany_user.id) + purchase_order = sale_order.auto_purchase_order_id.sudo() + if not (purchase_order and purchase_order.picking_ids): + raise UserError(_("PO does not exist or has no receipts")) + dest_picking = self.intercompany_picking_id.with_user( + intercompany_user.id + ).with_company(dest_company) dest_move_qty_update_dict = {} for move in self.move_ids_without_package.sudo(): # To identify the correct move to write to, diff --git a/purchase_sale_inter_company/tests/test_inter_company_purchase_sale.py b/purchase_sale_inter_company/tests/test_inter_company_purchase_sale.py index 379148567da..3958f97036f 100644 --- a/purchase_sale_inter_company/tests/test_inter_company_purchase_sale.py +++ b/purchase_sale_inter_company/tests/test_inter_company_purchase_sale.py @@ -379,6 +379,32 @@ def test_sync_picking(self): self.assertTrue(len(sale.picking_ids) > 1) self.assertEqual(len(purchase.picking_ids), len(sale.picking_ids)) + def _assert_picking_equal_lines(self, pick1, pick2, field_name="quantity_done"): + for product in pick1.move_lines.mapped("product_id"): + self.assertEqual( + sum( + pick1.move_lines.filtered(lambda l: l.product_id == product).mapped( + field_name + ) + ), + sum( + pick2.move_lines.filtered(lambda l: l.product_id == product).mapped( + field_name + ) + ), + ) + + def _assert_picking_equal_lots(self, pick1, pick2): + for product in pick1.move_lines.mapped("product_id"): + self.assertItemsEqual( + pick1.move_lines.filtered(lambda l: l.product_id == product) + .sudo() + .mapped("move_line_ids.lot_id.name"), + pick2.move_lines.filtered(lambda l: l.product_id == product) + .sudo() + .mapped("move_line_ids.lot_id.name"), + ) + def test_sync_picking_no_backorder(self): self.company_a.sync_picking = True self.company_b.sync_picking = True @@ -423,24 +449,46 @@ def test_sync_picking_no_backorder(self): # Quantity done should be the same on both sides, per product self.assertNotEqual(po_picking_id, so_picking_id) - for product in so_picking_id.move_lines.mapped("product_id"): - self.assertEqual( - sum( - so_picking_id.move_lines.filtered( - lambda l: l.product_id == product - ).mapped("quantity_done") - ), - sum( - po_picking_id.move_lines.filtered( - lambda l: l.product_id == product - ).mapped("quantity_done") - ), - ) + self._assert_picking_equal_lines(so_picking_id, po_picking_id) # No backorder should have been made for both self.assertEqual(len(sale.picking_ids), 1) self.assertEqual(len(purchase.picking_ids), len(sale.picking_ids)) + # We create a return + stock_return_picking_form = Form( + self.env["stock.return.picking"].with_context( + active_ids=so_picking_id.ids, + active_id=so_picking_id.id, + active_model="stock.picking", + ) + ) + stock_return_picking = stock_return_picking_form.save() # accept defaults + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env["stock.picking"].browse( + stock_return_picking_action["res_id"] + ) + + # A return should also have been create po side + return_pick_po = purchase.picking_ids - po_picking_id + self.assertEqual(len(return_pick_po), 1) + self.assertEqual(return_pick_po.location_id, po_picking_id.location_dest_id) + self.assertEqual(return_pick_po.location_dest_id, po_picking_id.location_id) + self._assert_picking_equal_lines( + return_pick_po, return_pick, field_name="product_uom_qty" + ) + + # We confirm the return SO side + return_pick.action_assign() + return_pick.move_lines.quantity_done = 2 + self.assertIs(return_pick.button_validate(), True) + self.assertEqual(return_pick.state, "done") + self._assert_picking_equal_lines(so_picking_id, return_pick) + + # We test the generated return PO side + self.assertEqual(return_pick_po.state, "done") + self._assert_picking_equal_lines(return_pick_po, return_pick) + def test_sync_picking_lot(self): """ Test that the lot is synchronized on the moves @@ -505,30 +553,82 @@ def test_sync_picking_lot(self): wizard.with_user(self.user_company_b).process() self.assertEqual(so_picking_id.state, "done") self.assertNotEqual((sale.picking_ids - so_picking_id).state, "done") - - so_lots = so_moves.mapped("move_line_ids.lot_id") - po_lots = po_picking_id.mapped("move_lines.move_line_ids.lot_id") - self.assertEqual( - len(so_lots), - len(po_lots), - msg="There aren't the same number of lots on both moves", - ) - self.assertNotEqual( - so_lots, po_lots, msg="The lots of the moves should be different objects" - ) - self.assertEqual( - so_lots.sudo().mapped("name"), - po_lots.sudo().mapped("name"), - msg="The lots should have the same name in both moves", - ) + self._assert_picking_equal_lots(so_picking_id, po_picking_id) self.assertIn( serial_3_company_a, - po_lots, + po_picking_id.mapped("move_lines.move_line_ids.lot_id"), msg="Serial 333 already existed, a new one shouldn't have been created", ) + # A backorder should have been made for both - self.assertTrue(len(sale.picking_ids) > 1) - self.assertEqual(len(purchase.picking_ids), len(sale.picking_ids)) + so_back_pick_id = sale.picking_ids - so_picking_id + po_back_pick_id = purchase.picking_ids - po_picking_id + self.assertEqual(len(so_back_pick_id), 1) + self.assertEqual(len(po_back_pick_id), 1) + + # We create a return + stock_return_picking_form = Form( + self.env["stock.return.picking"].with_context( + active_ids=so_picking_id.ids, + active_id=so_picking_id.id, + active_model="stock.picking", + ) + ) + stock_return_picking = stock_return_picking_form.save() # accept defaults + stock_return_picking_action = stock_return_picking.create_returns() + return_pick = self.env["stock.picking"].browse( + stock_return_picking_action["res_id"] + ) + + # A return should also have been create po side + return_pick_po = purchase.picking_ids - po_picking_id - po_back_pick_id + self.assertEqual(len(return_pick_po), 1) + self.assertEqual(return_pick_po.location_id, po_picking_id.location_dest_id) + self.assertEqual(return_pick_po.location_dest_id, po_picking_id.location_id) + self._assert_picking_equal_lines( + return_pick_po, return_pick, field_name="product_uom_qty" + ) + + # We confirm the return SO side + # We specify that we want to return all + ret_moves = return_pick.move_lines + ret_moves[1].quantity_done = 2 + ret_moves[0].move_line_ids = [ + ( + 0, + 0, + { + "location_id": ret_moves[0].location_id.id, + "location_dest_id": ret_moves[0].location_dest_id.id, + "product_id": self.stockable_product_serial.id, + "product_uom_id": self.stockable_product_serial.uom_id.id, + "qty_done": 1, + "lot_id": self.serial_1.id, + "picking_id": return_pick.id, + }, + ), + ( + 0, + 0, + { + "location_id": ret_moves[0].location_id.id, + "location_dest_id": ret_moves[0].location_dest_id.id, + "product_id": self.stockable_product_serial.id, + "product_uom_id": self.stockable_product_serial.uom_id.id, + "qty_done": 1, + "lot_id": self.serial_3.id, + "picking_id": return_pick.id, + }, + ), + ] + self.assertIs(return_pick.button_validate(), True) + self.assertEqual(return_pick.state, "done") + self._assert_picking_equal_lines(so_picking_id, return_pick) + + # We test the generated return PO side + self.assertEqual(return_pick_po.state, "done") + self._assert_picking_equal_lines(return_pick_po, return_pick) + self._assert_picking_equal_lots(return_pick_po, return_pick) def test_sync_picking_same_product_multiple_lines(self): """ diff --git a/purchase_sale_inter_company/wizard/__init__.py b/purchase_sale_inter_company/wizard/__init__.py index b2b43357448..7a74bea96ef 100644 --- a/purchase_sale_inter_company/wizard/__init__.py +++ b/purchase_sale_inter_company/wizard/__init__.py @@ -1 +1,2 @@ from . import stock_backorder_confirmation +from . import stock_return_picking diff --git a/purchase_sale_inter_company/wizard/stock_return_picking.py b/purchase_sale_inter_company/wizard/stock_return_picking.py new file mode 100644 index 00000000000..02825a296cd --- /dev/null +++ b/purchase_sale_inter_company/wizard/stock_return_picking.py @@ -0,0 +1,92 @@ +import logging + +from odoo import SUPERUSER_ID, _, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ReturnPicking(models.TransientModel): + _inherit = "stock.return.picking" + + def create_returns(self): + res = super(ReturnPicking, self).create_returns() + pick = self.picking_id + + # dont trigger on returns of incoming pickings + if pick.picking_type_code == "incoming": + return res + + # only trigger in case there is a coupled picking on the other side + ic_pick = pick.intercompany_picking_id + if not ic_pick: + return res + dest_company = ic_pick.sudo().company_id + intercompany_user = dest_company.intercompany_sale_user_id + ic_pick = ic_pick.with_user(intercompany_user) + + # warn in case of partial return + total_qty_sent = sum(pick.move_line_ids.mapped("product_uom_qty")) + total_qty_returned = sum(self.product_return_moves.mapped("quantity")) + if total_qty_returned < total_qty_sent: + note = _( + "This inter-company shipment was partially returned, but also has a " + "counterpart in company {}: {}. This could not be automatically returned; " + "please take care to do this manually." + ).format(ic_pick.company_id.name, ic_pick.name, pick.name) + _logger.warning(note) + pick.activity_schedule( + "mail.mail_activity_data_todo", + fields.Date.today(), + note=note, + # Try to notify someone relevant + user_id=( + pick.sale_id.user_id.id + or pick.sale_id.team_id.user_id.id + or SUPERUSER_ID, + ), + ) + return res + + return_pick_id = res.get("res_id") + if not return_pick_id: + return res + return_pick = self.env["stock.picking"].browse(return_pick_id) + + # create a return for the IC pick as well + intercompany_user = dest_company.intercompany_sale_user_id + vals = {"picking_id": ic_pick.id, "location_id": pick.location_id.id} + return_wizard = ( + self.env["stock.return.picking"] + .with_context(active_id=ic_pick.id) + .with_user(intercompany_user) + .with_company(dest_company) + .create(vals) + ) + return_wizard._onchange_picking_id() + try: + action = return_wizard.create_returns() + except UserError: + note = _( + "This inter-company shipment was returned, but also has a " + "counterpart in company {}: {}. This could not be automatically returned; " + "please take care to do this manually." + ).format(ic_pick.company_id.name, ic_pick.name, pick.name) + _logger.warning(note) + pick.activity_schedule( + "mail.mail_activity_data_todo", + fields.Date.today(), + note=note, + # Try to notify someone relevant + user_id=( + pick.sale_id.user_id.id + or pick.sale_id.team_id.user_id.id + or SUPERUSER_ID, + ), + ) + return res + + ic_return_pick_id = action["res_id"] + return_pick.intercompany_return_picking_id = ic_return_pick_id + + return res