diff --git a/.copier-answers.yml b/.copier-answers.yml index 4afd6631bc1..b1d5574571d 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.21.1 +_commit: v1.22 _src_path: gh:oca/oca-addons-repo-template ci: GitHub convert_readme_fragments_to_markdown: false @@ -17,6 +17,8 @@ org_name: Odoo Community Association (OCA) org_slug: OCA rebel_module_groups: - product_tax_multicompany_default +- product_supplierinfo_intercompany,product_supplierinfo_intercompany_multi_company,product_supplierinfo_group_intercompany +- stock_intercompany repo_description: Addons for the management of multi company instances as well as repo_name: Multi company modules repo_slug: multi-company diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5fa32223b4b..d40e9045a37 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,6 +42,13 @@ jobs: include: "product_tax_multicompany_default" name: test with OCB makepot: "true" + - container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest + include: "product_supplierinfo_intercompany,product_supplierinfo_intercompany_multi_company,product_supplierinfo_group_intercompany" + name: test with Odoo + - container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest + include: "product_supplierinfo_intercompany,product_supplierinfo_intercompany_multi_company,product_supplierinfo_group_intercompany" + name: test with OCB + makepot: "true" - container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest include: "stock_intercompany" name: test with Odoo @@ -50,10 +57,10 @@ jobs: name: test with OCB makepot: "true" - container: ghcr.io/oca/oca-ci/py3.6-odoo14.0:latest - exclude: "product_tax_multicompany_default,stock_intercompany" + exclude: "product_tax_multicompany_default,product_supplierinfo_intercompany,product_supplierinfo_intercompany_multi_company,product_supplierinfo_group_intercompany,stock_intercompany" name: test with Odoo - container: ghcr.io/oca/oca-ci/py3.6-ocb14.0:latest - exclude: "product_tax_multicompany_default,stock_intercompany" + exclude: "product_tax_multicompany_default,product_supplierinfo_intercompany,product_supplierinfo_intercompany_multi_company,product_supplierinfo_group_intercompany,stock_intercompany" name: test with OCB makepot: "true" services: diff --git a/purchase_sale_inter_company/models/res_company.py b/purchase_sale_inter_company/models/res_company.py index cab6b7e9f49..58a58fee4a3 100644 --- a/purchase_sale_inter_company/models/res_company.py +++ b/purchase_sale_inter_company/models/res_company.py @@ -47,7 +47,19 @@ class ResCompany(models.Model): default="raise", help="Pick action to perform on sync picking failure", ) + sync_picking_state = fields.Boolean( + string="Sync the receipt state with the delivery state", + default=lambda p: p.sync_picking, + help="State of receipt picking syncs with state of the delivery " + "from the source company. Note this disallows user to manually " + "correct or change a picking that did not sync properly.", + ) block_po_manual_picking_validation = fields.Boolean( string="Block manual validation of picking in the destination company", ) notify_user_id = fields.Many2one("res.users", "User to Notify") + notification_side = fields.Selection( + [("so", "Sale Order Source Company"), ("po", "Purchase Destination Company")], + default="so", + help="Select which Company side to notify", + ) diff --git a/purchase_sale_inter_company/models/res_config.py b/purchase_sale_inter_company/models/res_config.py index 3116b653a12..be3c795d35f 100644 --- a/purchase_sale_inter_company/models/res_config.py +++ b/purchase_sale_inter_company/models/res_config.py @@ -49,6 +49,9 @@ class InterCompanyRulesConfig(models.TransientModel): ) sync_picking_failure_action = fields.Selection( related="company_id.sync_picking_failure_action", + ) + sync_picking_state = fields.Boolean( + related="company_id.sync_picking_state", readonly=False, ) block_po_manual_picking_validation = fields.Boolean( @@ -61,3 +64,6 @@ class InterCompanyRulesConfig(models.TransientModel): help="User to notify incase of sync picking failure.", readonly=False, ) + notification_side = fields.Selection( + related="company_id.notification_side", string="Notify", readonly=False + ) diff --git a/purchase_sale_inter_company/models/sale_order.py b/purchase_sale_inter_company/models/sale_order.py index 85aa2673899..fef50efce10 100644 --- a/purchase_sale_inter_company/models/sale_order.py +++ b/purchase_sale_inter_company/models/sale_order.py @@ -20,7 +20,13 @@ def action_confirm(self): for order in self.filtered("auto_purchase_order_id"): for line in order.order_line.sudo(): if line.auto_purchase_line_id: - line.auto_purchase_line_id.price_unit = line.price_unit + qty = line.product_uom_qty + if qty: + line.auto_purchase_line_id.price_unit = ( + line.price_subtotal / qty + ) + else: + line.auto_purchase_line_id.price_unit = 0.0 res = super().action_confirm() for sale_order in self.sudo(): dest_company = sale_order.partner_id.ref_company_ids diff --git a/purchase_sale_inter_company/models/stock_picking.py b/purchase_sale_inter_company/models/stock_picking.py index 1df7f835fb9..e866af31eee 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): @@ -21,7 +28,8 @@ def _compute_state(self): res = super()._compute_state() for picking in self: if ( - picking.intercompany_picking_id + picking.company_id.sync_picking_state + and picking.intercompany_picking_id and picking.picking_type_code == "incoming" and picking.state not in ["done", "cancel"] ): @@ -32,122 +40,199 @@ 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): + ret = super()._action_done() + + # sync lots for move lines on returns + # TODO: integrate with the non-return part just below this clause + for pick in self.filtered(lambda x: x.intercompany_return_picking_id).sudo(): + ic_pick = pick.intercompany_return_picking_id + dest_company = ic_pick.sudo().company_id + if not dest_company.sync_picking: + continue + ic_user = dest_company.intercompany_sale_user_id + ic_pick = ic_pick.with_user(ic_user) + try: + dest_move_qty_update_dict = {} + for move in pick.move_lines: + product = move.product_id + move_lines = move.move_line_ids + po_move = ic_pick.move_lines.filtered( + lambda x, prod=product: x.product_id == prod + ) + po_move_lines = po_move.mapped("move_line_ids") + # Don't support one move splitting into multiple moves with the same product + # (Not sure how to even achieve that in Odoo) + # And don't support cases with more stock.move.line SO-side than PO side + if len(po_move) != 1 or len(move_lines) > len(po_move_lines): + pick._warn_move_line_mismatch( + ic_pick, product, move_lines, po_move_lines + ) + continue + # Delete excess stock.move.line on PO side + if len(po_move_lines) > len(move_lines): + po_move_lines[len(move_lines) :].unlink() + for ml, po_ml in zip(move_lines, po_move_lines): + pick._sync_lots(ml, po_ml) + po_ml.qty_done = ml.qty_done + dest_move_qty_update_dict.setdefault(po_move, 0.0) + dest_move_qty_update_dict[po_move] += move.quantity_done + for dest_move, qty_done in dest_move_qty_update_dict.items(): + dest_move.quantity_done = qty_done + ic_pick._action_done() + except Exception as e: + if dest_company.sync_picking_failure_action == "raise": + raise + else: + pick._notify_picking_problem( + pick.sale_id.auto_purchase_order_id, + additional_note=str(e), + ) + + # sync lots for move lines on pickings for pick in self.filtered( lambda x: x.location_dest_id.usage == "customer" ).sudo(): purchase = pick.sale_id.auto_purchase_order_id if not purchase: continue - purchase.picking_ids.write({"intercompany_picking_id": pick.id}) - if not pick.intercompany_picking_id and purchase.picking_ids[0]: - pick.write({"intercompany_picking_id": purchase.picking_ids[0]}) - pick._action_done_intercompany_actions(purchase) - return super()._action_done() - - def _action_done_intercompany_actions(self, purchase): - self.ensure_one() - try: - pick = self - for move in pick.move_lines: - move_lines = move.move_line_ids - po_move_lines = ( - move.sale_line_id.auto_purchase_line_id.move_ids.filtered( - lambda x, ic_pick=pick.intercompany_picking_id: x.picking_id - == ic_pick - ).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, - ), + dest_company = purchase.company_id + if not dest_company.sync_picking: + continue + try: + if not purchase.picking_ids: + raise UserError(_("PO does not exist or has no receipts")) + ic_user = dest_company.intercompany_sale_user_id + purchase.picking_ids.write({"intercompany_picking_id": pick.id}) + ic_pick = pick.intercompany_picking_id.with_user(ic_user) + if not ic_pick and purchase.picking_ids[0]: + pick.write({"intercompany_picking_id": purchase.picking_ids[0]}) + # formerly in action_done_intercompany_actions + dest_move_qty_update_dict = {} + for move in pick.move_lines: + move_lines = move.move_line_ids + po_move = move.sale_line_id.auto_purchase_line_id.move_ids.filtered( + lambda x, ic_picking=ic_pick, prod=move.product_id: x.picking_id # noqa + == ic_picking + and x.product_id == prod ) - # 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, + po_move_lines = po_move.mapped("move_line_ids") + # Don't support one move splitting into multiple moves with the same product + # (Not sure how to even achieve that in Odoo) + # And don't support cases with more stock.move.line SO-side than PO side + if len(po_move) != 1 or len(move_lines) > len(po_move_lines): + self._notify_picking_problem( + purchase, + additional_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), ) + continue + # Delete excess stock.move.line on PO side + if len(po_move_lines) > len(move_lines): + po_move_lines[len(move_lines) :].unlink() + for ml, po_ml in zip(move_lines, po_move_lines): + pick._sync_lots(ml, po_ml) + po_ml.qty_done = ml.qty_done + dest_move_qty_update_dict.setdefault(po_move, 0.0) + dest_move_qty_update_dict[po_move] += move.quantity_done + # formerly in sync_receipt_to_delivery + # "No backorder" case splits SO moves in two while PO stays the same. + # Aggregating writes per each PO move makes sure qty does not get overwritten + for dest_move, qty_done in dest_move_qty_update_dict.items(): + dest_move.quantity_done = qty_done + ic_pick.with_context( + cancel_backorder=bool( + self.env.context.get("picking_ids_not_to_backorder") + ) + )._action_done() + + except Exception as e: + if dest_company.sync_picking_failure_action == "raise": + raise + else: + pick._notify_picking_problem( + pick.sale_id.auto_purchase_order_id, + additional_note=str(e), ) - 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 - - except Exception: - if self.env.company_id.sync_picking_failure_action == "raise": - raise - else: - self._notify_picking_problem(purchase) - - def _notify_picking_problem(self, purchase): + + return ret + + def _notify_picking_problem(self, purchase, additional_note=False): self.ensure_one() note = _( "Failure to confirm picking for PO %s. " "Original picking %s still confirmed, please check " "the other side manually." ) % (purchase.name, self.name) - self.activity_schedule( + if additional_note: + note += _(" Additional info: ") + additional_note + _logger.warning(note) + user_id = self.sudo()._get_user_to_notify(purchase) + self.sudo().activity_schedule( "mail.mail_activity_data_warning", fields.Date.today(), note=note, - # Try to notify someone relevant - user_id=( + user_id=user_id or SUPERUSER_ID, + ) + + def _get_user_to_notify(self, purchase): + """Notify user based on res.config.settings""" + if purchase.company_id.notification_side == "so": + return ( self.company_id.notify_user_id.id or self.sale_id.user_id.id or self.sale_id.team_id.user_id.id - or SUPERUSER_ID, - ), - ) + ) + return purchase.company_id.notify_user_id.id or purchase.user_id.id def button_validate(self): res = super().button_validate() - for record in self.sudo(): - dest_company = ( - record.sale_id.partner_id.commercial_partner_id.ref_company_ids - ) - if ( - dest_company - and dest_company.sync_picking - and record.state == "done" - and record.picking_type_code == "outgoing" - ): - if record.intercompany_picking_id: - try: - 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 - ) # if the flag is set, block the validation of the picking in the destination company if self.env.company.block_po_manual_picking_validation: @@ -164,41 +249,6 @@ def button_validate(self): ) return res - 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")) - if self.intercompany_picking_id: - dest_picking = self.intercompany_picking_id.with_user(intercompany_user.id) - dest_move_qty_update_dict = {} - for move in self.move_ids_without_package.sudo(): - # To identify the correct move to write to, - # use both the SO-PO link and the intercompany_picking_id link - dest_move = move.sale_line_id.auto_purchase_line_id.move_ids.filtered( - lambda x, pick=dest_picking: x.picking_id == pick - ) - for line, dest_line in zip(move.move_line_ids, 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_qty_update_dict.setdefault(dest_move, 0.0) - dest_move_qty_update_dict[dest_move] += move.quantity_done - # "No backorder" case splits SO moves in two while PO stays the same. - # Aggregating writes per each PO move makes sure qty does not get overwritten - for dest_move, qty_done in dest_move_qty_update_dict.items(): - dest_move.quantity_done = qty_done - dest_picking.sudo().with_context( - cancel_backorder=bool( - self.env.context.get("picking_ids_not_to_backorder") - ) - )._action_done() - def _update_extra_data_in_picking(self, picking): if hasattr(self, "_cal_weight"): # from delivery module self._cal_weight() 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 cd183b3a956..f944281562b 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 @@ -84,6 +84,7 @@ def setUpClass(cls): "type": "product", "tracking": "serial", "categ_id": cls.env.ref("product.product_category_all").id, + "company_id": None, } ) @@ -292,8 +293,9 @@ def test_cancel_confirmed_po_so(self): def test_so_change_price(self): sale = self._approve_po(self.purchase_company_a) sale.order_line.price_unit = 10 + sale.order_line.discount = 10.0 sale.action_confirm() - self.assertEqual(self.purchase_company_a.order_line.price_unit, 10) + self.assertEqual(self.purchase_company_a.order_line.price_unit, 9) def test_po_with_contact_as_partner(self): contact = self.env["res.partner"].create( @@ -326,6 +328,8 @@ def test_confirm_several_picking(self): def test_sync_picking(self): self.company_a.sync_picking = True self.company_b.sync_picking = True + self.company_a.sync_picking_state = True + self.company_b.sync_picking_state = True purchase = self._create_purchase_order( self.partner_company_b, self.consumable_product @@ -354,14 +358,16 @@ def test_sync_picking(self): so_picking_id.move_lines.product_qty, ) - so_picking_id.state = "done" + # Validate sale order, create backorder wizard_data = so_picking_id.with_user(self.user_company_b).button_validate() wizard = ( self.env["stock.backorder.confirmation"] .with_context(**wizard_data.get("context")) .create({}) ) - wizard.process() + 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") # Quantities should have been synced self.assertNotEqual(po_picking_id, so_picking_id) @@ -376,10 +382,41 @@ def test_sync_picking(self): # 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)) + # The original orders should now be done. + self.assertEqual(so_picking_id.state, "done") + self.assertEqual(po_picking_id.state, "done") + + 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 + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False purchase = self._create_purchase_order( self.partner_company_b, self.consumable_product @@ -393,7 +430,7 @@ def test_sync_picking_no_backorder(self): so_picking_id = sale.picking_ids # check po_picking state - self.assertEqual(po_picking_id.state, "waiting") + self.assertEqual(po_picking_id.state, "assigned") # validate the SO picking so_picking_id.move_lines.quantity_done = 2 @@ -421,29 +458,52 @@ 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 by searching or creating a new lot in the company of destination """ + """ Sad flow for lot picking """ # lot 3 already exists in company_a serial_3_company_a = self._create_serial_and_quant( self.stockable_product_serial, @@ -453,9 +513,14 @@ def test_sync_picking_lot(self): ) self.company_a.sync_picking = True self.company_b.sync_picking = True + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False + self.company_a.sync_picking_failure_action = "raise" + self.company_b.sync_picking_failure_action = "raise" purchase = self._create_purchase_order( - self.partner_company_b, self.stockable_product_serial + self.partner_company_b, + self.stockable_product_serial + self.consumable_product, ) sale = self._approve_po(purchase) @@ -463,14 +528,15 @@ def test_sync_picking_lot(self): po_picking_id = purchase.picking_ids so_picking_id = sale.picking_ids - so_move = so_picking_id.move_lines - so_move.move_line_ids = [ + so_moves = so_picking_id.move_lines + so_moves[1].quantity_done = 2 + so_moves[0].move_line_ids = [ ( 0, 0, { - "location_id": so_move.location_id.id, - "location_dest_id": so_move.location_dest_id.id, + "location_id": so_moves[0].location_id.id, + "location_dest_id": so_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, @@ -482,51 +548,177 @@ def test_sync_picking_lot(self): 0, 0, { - "location_id": so_move.location_id.id, - "location_dest_id": so_move.location_dest_id.id, + "location_id": so_moves[0].location_id.id, + "location_dest_id": so_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_2.id, + "lot_id": self.serial_3.id, "picking_id": so_picking_id.id, }, ), + ] + wizard_data = so_picking_id.with_user(self.user_company_b).button_validate() + wizard = ( + self.env["stock.backorder.confirmation"] + .with_context(**wizard_data.get("context")) + .create({}) + ) + 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") + self._assert_picking_equal_lots(so_picking_id, po_picking_id) + self.assertIn( + serial_3_company_a, + 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 + 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) + + # The original orders should now be done. + self.assertEqual(so_picking_id.state, "done") + self.assertEqual(po_picking_id.state, "done") + + # 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": so_move.location_id.id, - "location_dest_id": so_move.location_dest_id.id, + "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": so_picking_id.id, + "picking_id": return_pick.id, }, ), ] - so_picking_id.button_validate() + self.assertIs(return_pick.button_validate(), True) + self.assertEqual(return_pick.state, "done") + self._assert_picking_equal_lines(so_picking_id, return_pick) - so_lots = so_move.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.mapped("name"), - po_lots.mapped("name"), - msg="The lots should have the same name in both moves", + # 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_lot_fail(self): + """Sad flow for lot picking""" + self.company_a.sync_picking = True + self.company_b.sync_picking = True + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False + + purchase = self._create_purchase_order( + self.partner_company_b, + self.stockable_product_serial + self.consumable_product, ) - self.assertIn( - serial_3_company_a, - po_lots, - msg="Serial 333 already existed, a new one shouldn't have been created", + sale = self._approve_po(purchase) + + # validate the SO picking + po_picking_id = purchase.picking_ids + so_picking_id = sale.picking_ids + + so_moves = so_picking_id.move_lines + so_moves[1].quantity_done = 2 + so_moves[0].move_line_ids = [ + ( + 0, + 0, + { + "location_id": so_moves[0].location_id.id, + "location_dest_id": so_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": so_picking_id.id, + }, + ), + ( + 0, + 0, + { + "location_id": so_moves[0].location_id.id, + "location_dest_id": so_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": so_picking_id.id, + }, + ), + ] + wizard_data = so_picking_id.with_user(self.user_company_b).button_validate() + wizard = ( + self.env["stock.backorder.confirmation"] + .with_context(**wizard_data.get("context")) + .create({}) ) + 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") + self._assert_picking_equal_lots(so_picking_id, po_picking_id) + + # A backorder should have been made for both + 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) + + # TODO: somehow simulate a failure here, eg by inserting extra stock move lines on + # the SO side that don't exist on the PO side + # Then test whether not only the picking doesn't go to done, but also, + # it refrains from creating the lot_ids on the PO company side, + # So that user can do it manually again without running into 'already exist' error + # self.assertEqual(so_picking_id.state, "done") + # self.assertEqual(po_picking_id.state, "done") def test_sync_picking_same_product_multiple_lines(self): """ @@ -659,6 +851,10 @@ def test_update_open_sale_order(self): "3.0 Units of Consumable Product 2.+instead of 8.0 Units", re.DOTALL ), ) + print(so_picking_id.state) + po_picking_id = purchase.picking_ids + print(po_picking_id.state) + # Upon confirm, I expect here an issue def test_block_manual_validation(self): """ @@ -667,6 +863,8 @@ def test_block_manual_validation(self): """ self.company_a.sync_picking = True self.company_b.sync_picking = True + self.company_a.sync_picking_state = True + self.company_b.sync_picking_state = True self.company_a.block_po_manual_picking_validation = True self.company_b.block_po_manual_picking_validation = True purchase = self._create_purchase_order( @@ -683,6 +881,8 @@ def test_block_manual_validation(self): def test_notify_picking_problem(self): self.company_a.sync_picking = True self.company_b.sync_picking = True + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False self.company_a.sync_picking_failure_action = "notify" self.company_b.sync_picking_failure_action = "notify" self.company_a.notify_user_id = self.user_company_a @@ -723,9 +923,58 @@ def test_notify_picking_problem(self): warning_activity.user_id, so_picking_id.company_id.notify_user_id ) + # The PO picking will not even be assigned + po_picking_id = purchase.picking_ids + self.assertEqual(po_picking_id.state, "assigned") + + def test_notify_picking_problem_dest_company(self): + self.company_a.sync_picking = True + self.company_b.sync_picking = True + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False + self.company_a.sync_picking_failure_action = "notify" + self.company_b.sync_picking_failure_action = "notify" + self.company_a.notification_side = "po" + self.company_b.notification_side = "po" + purchase = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + purchase_2 = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + purchase.order_line += purchase.order_line.copy({"product_qty": 2}) + sale = self._approve_po(purchase) + sale.action_confirm() + + # validate the SO picking + so_picking_id = sale.picking_ids + # Set as purchase_2 user user_company_a + purchase_2.user_id = self.user_company_a + # Link to a new purchase order so it can trigger + # `PO does not exist or has no receipts` in _sync_receipt_with_delivery + sale.auto_purchase_order_id = purchase_2 + + # Set quantities done on the picking and validate + for move in so_picking_id.move_lines: + move.quantity_done = move.product_uom_qty + so_picking_id.button_validate() + + activity_warning = self.env.ref("mail.mail_activity_data_warning") + warning_activity = so_picking_id.activity_ids.filtered( + lambda a: a.activity_type_id == activity_warning + ) + # Test the user assigned to the activity + self.assertEqual(warning_activity.user_id, self.user_company_a) + + # The picking should still be in confirmed state + po_picking_id = purchase.picking_ids + self.assertEqual(po_picking_id.state, "assigned") + def test_raise_picking_problem(self): self.company_a.sync_picking = True self.company_b.sync_picking = True + self.company_a.sync_picking_state = False + self.company_b.sync_picking_state = False self.company_a.sync_picking_failure_action = "raise" self.company_b.sync_picking_failure_action = "raise" diff --git a/purchase_sale_inter_company/views/res_config_view.xml b/purchase_sale_inter_company/views/res_config_view.xml index 46f1f59ef0f..a0b18a97701 100644 --- a/purchase_sale_inter_company/views/res_config_view.xml +++ b/purchase_sale_inter_company/views/res_config_view.xml @@ -52,6 +52,18 @@ for="sync_picking_failure_action" />
+