From 92b3d53ba17c4d81b670ca741abd6af864f2f888 Mon Sep 17 00:00:00 2001 From: Mateu Griful Date: Wed, 25 May 2022 15:36:17 +0200 Subject: [PATCH 01/18] [12.0][IMP] purchase_sale_inter_company: add sync_picking option --- .../models/res_config.py | 1 + purchase_sale_stock_inter_company/__init__.py | 1 + .../__manifest__.py | 5 +- .../models/res_company.py | 6 + .../models/res_config.py | 7 + .../models/stock_picking.py | 129 +++++++++++++++++- .../views/res_config_view.xml | 8 ++ .../wizard/__init__.py | 1 + .../wizard/stock_backorder_confirmation.py | 39 ++++++ .../stock_backorder_confirmation_views.xml | 27 ++++ 10 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 purchase_sale_stock_inter_company/wizard/__init__.py create mode 100644 purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation.py create mode 100644 purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation_views.xml diff --git a/purchase_sale_inter_company/models/res_config.py b/purchase_sale_inter_company/models/res_config.py index c6d18a1e384..4d15789f712 100644 --- a/purchase_sale_inter_company/models/res_config.py +++ b/purchase_sale_inter_company/models/res_config.py @@ -33,3 +33,4 @@ class InterCompanyRulesConfig(models.TransientModel): "order in another company.", readonly=False, ) + diff --git a/purchase_sale_stock_inter_company/__init__.py b/purchase_sale_stock_inter_company/__init__.py index 0650744f6bc..9b4296142f4 100644 --- a/purchase_sale_stock_inter_company/__init__.py +++ b/purchase_sale_stock_inter_company/__init__.py @@ -1 +1,2 @@ from . import models +from . import wizard diff --git a/purchase_sale_stock_inter_company/__manifest__.py b/purchase_sale_stock_inter_company/__manifest__.py index c6d90bdfe25..e47d335e676 100644 --- a/purchase_sale_stock_inter_company/__manifest__.py +++ b/purchase_sale_stock_inter_company/__manifest__.py @@ -14,5 +14,8 @@ "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", + "wizard/stock_backorder_confirmation_views.xml", + ], } diff --git a/purchase_sale_stock_inter_company/models/res_company.py b/purchase_sale_stock_inter_company/models/res_company.py index 2ee88f5b1a2..891e87d1f67 100644 --- a/purchase_sale_stock_inter_company/models/res_company.py +++ b/purchase_sale_stock_inter_company/models/res_company.py @@ -16,3 +16,9 @@ class ResCompany(models.Model): help="Default value to set on Sale Orders that " "will be created based on Purchase Orders made to this company", ) + sync_picking = fields.Boolean( + string="Sync the receipt with the delivery", + help="Sync the receipt from the destination company with the " + "delivery from the source company", + ) + diff --git a/purchase_sale_stock_inter_company/models/res_config.py b/purchase_sale_stock_inter_company/models/res_config.py index d1fad81c6a9..bea6698d926 100644 --- a/purchase_sale_stock_inter_company/models/res_config.py +++ b/purchase_sale_stock_inter_company/models/res_config.py @@ -18,3 +18,10 @@ class InterCompanyRulesConfig(models.TransientModel): "based on Purchase Orders made to this company.", readonly=False, ) + sync_picking = fields.Boolean( + related="company_id.sync_picking", + string="Sync the receipt from the destination company with the delivery", + help="Sync the receipt from the destination company with " + "the delivery from the source company", + readonly=False, + ) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index ee0a030b3da..8001b58825a 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -2,14 +2,14 @@ # 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 _, api, fields, models from odoo.exceptions import UserError class StockPicking(models.Model): _inherit = "stock.picking" - intercompany_picking_id = fields.Many2one(comodel_name="stock.picking") + intercompany_picking_id = fields.Many2one(comodel_name="stock.picking", copy=False) def action_done(self): # Only DropShip pickings @@ -55,3 +55,128 @@ def action_done(self): for po_pick in po_picks.sudo(): po_pick.with_company(po_pick.company_id.id).action_done() return super(StockPicking, self).action_done() + + def button_validate(self): + is_intercompany = self.env["res.company"].search( + [("partner_id", "=", self.partner_id.id)] + ) or self.env["res.company"].search( + [("partner_id", "=", self.partner_id.parent_id.id)] + ) + if ( + is_intercompany + and self.company_id.sync_picking + and self.picking_type_code == "outgoing" + ): + sale_order = self.sale_id + src_pickings = sale_order.picking_ids.filtered( + lambda l: l.state in ["draft", "waiting", "confirmed", "assigned"] + ) + dest_company = sale_order.partner_id.ref_company_ids + for src_picking in src_pickings: + src_picking._sync_receipt_with_delivery( + dest_company, + sale_order, + src_pickings, + ) + return super().button_validate() + + @api.model + def _prepare_picking_line_data(self, src_picking, dest_picking): + self.ensure_one() + if self.check_all_done(src_picking): + for line in src_picking.sudo().move_ids_without_package: + line.write({"quantity_done": line.reserved_availability}) + for src_line in src_picking.sudo().move_ids_without_package: + if ( + src_line.product_id + in dest_picking.sudo().move_ids_without_package.mapped("product_id") + and src_line.quantity_done > 0 + ): + dest_move = dest_picking.sudo().move_ids_without_package.filtered( + lambda m: m.product_id == src_line.product_id + ) + dest_move.write( + {"quantity_done": dest_move.quantity_done + src_line.quantity_done} + ) + + def _sync_receipt_with_delivery(self, dest_company, sale_order, src_pickings): + self.ensure_one() + intercompany_user = dest_company.intercompany_user_id + purchase_order = ( + self.sudo(intercompany_user.id) + .env["purchase.order"] + .search( + [ + ("partner_ref", "=", sale_order.name), + ("company_id", "=", dest_company.id), + ] + ) + ) + if ( + not purchase_order + or not purchase_order.sudo(intercompany_user.id).picking_ids + ): + raise UserError(_("PO does not exist or has no receipts")) + receipts = purchase_order.sudo(intercompany_user.id).picking_ids + for src_picking in src_pickings.sudo().filtered(lambda l: l.state != "done"): + dest_picking = receipts.sudo().filtered( + lambda r: not r.intercompany_picking_id + or r.intercompany_picking_id.id == src_picking.id + ) + if not dest_picking: + dest_picking = receipts.sudo().filtered( + lambda r: r.state not in ["done", "draft", "cancel"] + ) + if ( + dest_picking + and src_picking.state not in ["done", "cancel"] + and sum(src_picking.mapped("move_ids_without_package.quantity_done")) + > 0 + or self.check_all_done(src_picking) + ): + dest_picking._prepare_picking_line_data( + src_picking, + dest_picking, + ) + dest_picking.write( + { + "intercompany_picking_id": src_picking.id, + } + ) + src_picking.sudo().write( + { + "intercompany_picking_id": dest_picking.id, + } + ) + dest_picking.action_confirm() + + def check_all_done(self, picking): + picking_lines = picking.move_ids_without_package.filtered( + lambda l: l.state != "cancel" + ) + qty_done = sum(picking_lines.mapped("quantity_done")) + reserved = sum(picking_lines.mapped("reserved_availability")) + available = sum(picking_lines.mapped("product_uom_qty")) + if qty_done == 0.0 and reserved == available: + return True + return False + + def action_generate_backorder_wizard(self): + view = self.env.ref("stock.view_backorder_confirmation") + wiz = ( + self.env["stock.backorder.confirmation"] + .with_context(picking_id=self.id) + .create({"pick_ids": [(4, p.id) for p in self]}) + ) + return { + "name": _("Create Backorder?"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "stock.backorder.confirmation", + "views": [(view.id, "form")], + "view_id": view.id, + "target": "new", + "res_id": wiz.id, + "context": self.env.context, + } diff --git a/purchase_sale_stock_inter_company/views/res_config_view.xml b/purchase_sale_stock_inter_company/views/res_config_view.xml index f6445f26df7..b8c4635f256 100644 --- a/purchase_sale_stock_inter_company/views/res_config_view.xml +++ b/purchase_sale_stock_inter_company/views/res_config_view.xml @@ -16,6 +16,14 @@ domain="[('company_id', '=', company_id)]" /> + + + diff --git a/purchase_sale_stock_inter_company/wizard/__init__.py b/purchase_sale_stock_inter_company/wizard/__init__.py new file mode 100644 index 00000000000..b2b43357448 --- /dev/null +++ b/purchase_sale_stock_inter_company/wizard/__init__.py @@ -0,0 +1 @@ +from . import stock_backorder_confirmation diff --git a/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation.py b/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation.py new file mode 100644 index 00000000000..b31eb601b33 --- /dev/null +++ b/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation.py @@ -0,0 +1,39 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class StockBackorderConfirmation(models.TransientModel): + _inherit = "stock.backorder.confirmation" + + force_backorder = fields.Boolean(string="Force backorder", default=False) + force_no_backorder = fields.Boolean(string="Force No backorder", default=False) + + @api.model + def default_get(self, fields): + res = super(StockBackorderConfirmation, self).default_get(fields) + picking = self.env["stock.picking"].browse( + self.env.context.get("picking_id", False) + ) + sale_order = self.env["sale.order"] + if picking.picking_type_code == "incoming": + sale_order = sale_order.sudo().search( + [("name", "=", picking.purchase_id.partner_ref)] + ) + if not picking or not sale_order: + return res + is_intercompany = self.env["res.company"].search( + [("partner_id", "=", picking.partner_id.id)] + ) or self.env["res.company"].search( + [("partner_id", "=", picking.partner_id.parent_id.id)] + ) + if ( + is_intercompany + and is_intercompany.sync_picking + and picking.picking_type_code == "incoming" + ): + if sale_order.shipping_status == "completed": + res.update({"force_no_backorder": True}) + else: + res.update({"force_backorder": True}) + return res diff --git a/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation_views.xml b/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation_views.xml new file mode 100644 index 00000000000..2d97c96d0e1 --- /dev/null +++ b/purchase_sale_stock_inter_company/wizard/stock_backorder_confirmation_views.xml @@ -0,0 +1,27 @@ + + + + stock_backorder_confirmation_inherit + stock.backorder.confirmation + + + + + + + + {'invisible': [('force_backorder', '=', True)]} + + + {'invisible': [('force_no_backorder', '=', True)]} + + + + From b243d6fccac6eac0a5a2f8f617709d89ced7abd0 Mon Sep 17 00:00:00 2001 From: Mateu Griful Date: Thu, 9 Feb 2023 15:25:36 +0100 Subject: [PATCH 02/18] [12.0][IMP] purchase_sale_inter_company: sync pickings --- .../models/sale_order.py | 13 ++ .../models/__init__.py | 1 + .../models/sale_order.py | 212 ++++++++++++++++++ .../models/stock_picking.py | 81 ++----- .../static/description/index.html | 12 +- 5 files changed, 257 insertions(+), 62 deletions(-) create mode 100644 purchase_sale_stock_inter_company/models/sale_order.py diff --git a/purchase_sale_inter_company/models/sale_order.py b/purchase_sale_inter_company/models/sale_order.py index 44f69735203..1c92ab2d597 100644 --- a/purchase_sale_inter_company/models/sale_order.py +++ b/purchase_sale_inter_company/models/sale_order.py @@ -23,6 +23,19 @@ def action_confirm(self): line.auto_purchase_line_id.price_unit = line.price_unit return super().action_confirm() + + def action_cancel(self): + purchase_orders = ( + self.env["purchase.order"] + .sudo() + .search([("auto_sale_order_id", "in", self.ids)]) + ) + for po in purchase_orders: + if po.state not in ["draft", "sent", "cancel"]: + raise UserError(_("You can't cancel an order that is %s") % po.state) + if purchase_orders: + purchase_orders.button_cancel() + return super().action_cancel() class SaleOrderLine(models.Model): _inherit = "sale.order.line" diff --git a/purchase_sale_stock_inter_company/models/__init__.py b/purchase_sale_stock_inter_company/models/__init__.py index fd0b2e801f8..6175b484bd3 100644 --- a/purchase_sale_stock_inter_company/models/__init__.py +++ b/purchase_sale_stock_inter_company/models/__init__.py @@ -1,4 +1,5 @@ from . import purchase_order from . import res_company from . import res_config +from . import sale_order from . import stock_picking diff --git a/purchase_sale_stock_inter_company/models/sale_order.py b/purchase_sale_stock_inter_company/models/sale_order.py new file mode 100644 index 00000000000..fdd54704f99 --- /dev/null +++ b/purchase_sale_stock_inter_company/models/sale_order.py @@ -0,0 +1,212 @@ +# Copyright 2013-Today Odoo SA +# Copyright 2016-2019 Chafique DELLI @ Akretion +# Copyright 2018-2019 Tecnativa - Carlos Dauden +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def action_confirm(self): + for order in self.filtered("auto_purchase_order_id"): + po_company = order.sudo().auto_purchase_order_id.company_id + if not po_company.intercompany_overwrite_purchase_price: + order.assert_intercompany_prices_equal() + else: + for line in order.order_line.sudo(): + if line.auto_purchase_line_id: + line.auto_purchase_line_id.price_unit = line.price_unit + res = super().action_confirm() + for sale_order in self.sudo(): + # Do not consider SO created from intercompany PO + dest_company = sale_order.partner_id.ref_company_ids + if not sale_order.auto_purchase_order_id: + if dest_company and dest_company.po_from_so: + sale_order.with_context( + force_company=dest_company.id + )._inter_company_create_purchase_order(dest_company) + if ( + sale_order.auto_purchase_order_id + and dest_company + and dest_company.sync_picking + ): + pickings = sale_order.picking_ids + purchase_picking = sale_order.auto_purchase_order_id.sudo().picking_ids + + if len(pickings) == len(purchase_picking) == 1: + purchase_picking.sudo().write( + {"intercompany_picking_id": pickings[0].id} + ) + pickings[0].write({"intercompany_picking_id": purchase_picking.id}) + if len(pickings) > 1 and len(purchase_picking) == 1: + # If there are `n` pickings in the sale order + # and one in the purchase order, split the receipt + assigned_moves = [] + assigned_move_lines = [] + for pick in pickings: + assigned = ( + purchase_picking.sudo().move_ids_without_package.filtered( + lambda m: m.product_id.id + in pick.mapped("move_ids_without_package.product_id.id") + ) + ) + lines = purchase_picking.sudo().move_line_ids_without_package + assigned_lines = lines.filtered( + lambda m: m.product_id.id + in pick.mapped( + "move_line_ids_without_package.product_id.id" + ) + ) + assigned_moves.append(assigned) + assigned_move_lines.append(assigned_lines) + + for i in range(len(assigned_moves)): + if i == 0: + purchase_picking.write( + { + "move_ids_without_package": [ + (6, False, assigned_moves[i].ids) + ], + "move_line_ids_without_package": [ + (6, False, assigned_move_lines[i].ids) + ], + "intercompany_picking_id": pickings[i].id, + } + ) + pickings[i].write( + {"intercompany_picking_id": purchase_picking.id} + ) + purchase_picking.action_assign() + else: + new = purchase_picking.copy( + { + "move_ids_without_package": [ + (6, False, assigned_moves[i].ids) + ], + "move_line_ids_without_package": [ + (6, False, assigned_move_lines[i].ids) + ], + "intercompany_picking_id": pickings[i].id, + } + ) + new.action_assign() + pickings[i].write({"intercompany_picking_id": new.id}) + + return res + + def _get_user_domain(self, dest_company): + self.ensure_one() + group_sale_user = self.env.ref("sales_team.group_sale_salesman") + return [ + ("id", "!=", 1), + ("company_id", "=", dest_company.id), + ("id", "in", group_sale_user.users.ids), + ] + + def _check_intercompany_product(self, dest_company): + domain = self._get_user_domain(dest_company) + dest_user = self.env["res.users"].search(domain, limit=1) + if dest_user: + for sale_line in self.order_line: + try: + sale_line.product_id.sudo(dest_user).read(["default_code"]) + except Exception: + raise UserError( + _( + "You cannot create PO from SO because product '%s' " + "is not intercompany" + ) + % sale_line.product_id.name + ) + + def _inter_company_create_purchase_order(self, dest_company): + """Create a Purchase Order from the current SO (self) + Note : In this method, reading the current SO is done as sudo, + and the creation of the derived + PO as intercompany_user, minimizing the access right required + for the trigger user. + :param dest_company : the company of the created SO + :rtype dest_company : res.company record + """ + self.ensure_one() + intercompany_user = dest_company.intercompany_user_id + if intercompany_user.company_id != dest_company: + intercompany_user.company_id = dest_company + self._check_intercompany_product(dest_company) + company_partner = self.company_id.partner_id + purchase_order_data = self._prepare_purchase_order_data( + self.name, company_partner, dest_company + ) + purchase_order = ( + self.env["purchase.order"] + .sudo(intercompany_user.id) + .create(purchase_order_data) + ) + for sale_line in self.order_line: + purchase_line_data = self._prepare_purchase_order_line_data( + sale_line, purchase_order + ) + self.env["purchase.order.line"].sudo(intercompany_user.id).create( + purchase_line_data + ) + if not self.name: + self.name = purchase_order.partner_ref + # Validation of sale order + if dest_company.purchase_auto_validation: + purchase_order.sudo(intercompany_user.id).button_approve() + + def _prepare_purchase_order_data(self, name, partner, dest_company): + """Generate the Purchase Order values from the SO + :param name : the origin client reference + :rtype name : string + :param partner : the partner reprenseting the company + :rtype partner : res.partner record + :param dest_company : the company of the created PO + :rtype dest_company : res.company record + """ + self.ensure_one() + new_order = self.env["purchase.order"].new( + { + "company_id": dest_company.id, + "partner_ref": name, + "partner_id": partner.id, + "date_order": self.date_order, + "auto_sale_order_id": self.id, + } + ) + for onchange_method in new_order._onchange_methods["partner_id"]: + onchange_method(new_order) + new_order.user_id = False + if self.note: + new_order.notes = self.note + if "picking_type_id" in new_order: + new_order.picking_type_id = ( + dest_company.po_picking_type_id.warehouse_id.company_id == dest_company + and dest_company.po_picking_type_id + or False + ) + return new_order._convert_to_write(new_order._cache) + + @api.model + def _prepare_purchase_order_line_data(self, sale_line, purchase_order): + """Generate the Purchase Order Line values from the SO line + :param sale_line : the origin Sale Order Line + :rtype sale_line : sale.order.line record + :param purchase_order : the Purchase Order + """ + new_line = self.env["purchase.order.line"].new( + { + "order_id": purchase_order.id, + "product_id": sale_line.product_id.id, + "auto_sale_line_id": sale_line.id, + } + ) + for onchange_method in new_line._onchange_methods["product_id"]: + onchange_method(new_line) + new_line.product_uom = sale_line.product_uom.id + new_line.product_qty = sale_line.product_uom_qty + new_line.price_unit = sale_line.price_unit + return new_line._convert_to_write(new_line._cache) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 8001b58825a..c876d4f5e52 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 _, api, fields, models +from odoo import _, fields, models from odoo.exceptions import UserError @@ -68,18 +68,16 @@ def button_validate(self): and self.picking_type_code == "outgoing" ): sale_order = self.sale_id - src_pickings = sale_order.picking_ids.filtered( - lambda l: l.state in ["draft", "waiting", "confirmed", "assigned"] - ) dest_company = sale_order.partner_id.ref_company_ids - for src_picking in src_pickings: - src_picking._sync_receipt_with_delivery( - dest_company, - sale_order, - src_pickings, - ) + for rec in self: + if rec.intercompany_picking_id: + rec._sync_receipt_with_delivery( + dest_company, + sale_order, + ) return super().button_validate() +<<<<<<< HEAD:purchase_sale_stock_inter_company/models/stock_picking.py @api.model def _prepare_picking_line_data(self, src_picking, dest_picking): self.ensure_one() @@ -100,66 +98,35 @@ def _prepare_picking_line_data(self, src_picking, dest_picking): ) def _sync_receipt_with_delivery(self, dest_company, sale_order, src_pickings): +======= + def _sync_receipt_with_delivery(self, dest_company, sale_order): +>>>>>>> 9f678494 ([12.0][IMP] purchase_sale_inter_company: sync pickings):purchase_sale_inter_company/models/stock_picking.py self.ensure_one() intercompany_user = dest_company.intercompany_user_id - purchase_order = ( - self.sudo(intercompany_user.id) - .env["purchase.order"] - .search( - [ - ("partner_ref", "=", sale_order.name), - ("company_id", "=", dest_company.id), - ] - ) - ) + purchase_order = sale_order.auto_purchase_order_id.sudo() if ( not purchase_order or not purchase_order.sudo(intercompany_user.id).picking_ids ): raise UserError(_("PO does not exist or has no receipts")) - receipts = purchase_order.sudo(intercompany_user.id).picking_ids - for src_picking in src_pickings.sudo().filtered(lambda l: l.state != "done"): - dest_picking = receipts.sudo().filtered( - lambda r: not r.intercompany_picking_id - or r.intercompany_picking_id.id == src_picking.id - ) - if not dest_picking: - dest_picking = receipts.sudo().filtered( - lambda r: r.state not in ["done", "draft", "cancel"] - ) - if ( - dest_picking - and src_picking.state not in ["done", "cancel"] - and sum(src_picking.mapped("move_ids_without_package.quantity_done")) - > 0 - or self.check_all_done(src_picking) - ): - dest_picking._prepare_picking_line_data( - src_picking, - dest_picking, - ) - dest_picking.write( + if self.intercompany_picking_id: + dest_picking = self.intercompany_picking_id.sudo(intercompany_user.id) + for picking_line in self.move_ids_without_package: + picking_line.write( { - "intercompany_picking_id": src_picking.id, + "qty_done": picking_line.reserved_availability, } ) - src_picking.sudo().write( + dest_picking_line = ( + dest_picking.sudo().move_line_ids_without_package.filtered( + lambda l: l.product_id.id == picking_line.product_id.id + ) + ) + dest_picking_line.sudo().write( { - "intercompany_picking_id": dest_picking.id, + "qty_done": picking_line.reserved_availability, } ) - dest_picking.action_confirm() - - def check_all_done(self, picking): - picking_lines = picking.move_ids_without_package.filtered( - lambda l: l.state != "cancel" - ) - qty_done = sum(picking_lines.mapped("quantity_done")) - reserved = sum(picking_lines.mapped("reserved_availability")) - available = sum(picking_lines.mapped("product_uom_qty")) - if qty_done == 0.0 and reserved == available: - return True - return False def action_generate_backorder_wizard(self): view = self.env.ref("stock.view_backorder_confirmation") diff --git a/purchase_sale_stock_inter_company/static/description/index.html b/purchase_sale_stock_inter_company/static/description/index.html index 01c23bddc55..f7b0e06aa47 100644 --- a/purchase_sale_stock_inter_company/static/description/index.html +++ b/purchase_sale_stock_inter_company/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -425,7 +425,9 @@

Contributors

Maintainers

This module is maintained by the OCA.

-Odoo Community Association + +Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

From 6512d9122e218f99422b66b76d4c4a2d47c809d0 Mon Sep 17 00:00:00 2001 From: Mateu Griful Date: Wed, 15 Mar 2023 12:30:41 +0100 Subject: [PATCH 03/18] [12.0][IMP] purchas_sale_inter_company: sync lines and moves --- .../models/stock_picking.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index c876d4f5e52..b776b9cba52 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -111,12 +111,18 @@ def _sync_receipt_with_delivery(self, dest_company, sale_order): raise UserError(_("PO does not exist or has no receipts")) if self.intercompany_picking_id: dest_picking = self.intercompany_picking_id.sudo(intercompany_user.id) - for picking_line in self.move_ids_without_package: - picking_line.write( + for picking_move in self.move_ids_without_package: + dest_picking_move = ( + dest_picking.sudo().move_ids_without_package.filtered( + lambda l: l.product_id.id == picking_move.product_id.id + ) + ) + dest_picking_move.sudo().write( { - "qty_done": picking_line.reserved_availability, + "quantity_done": picking_move.quantity_done, } ) + for picking_line in self.move_line_ids_without_package: dest_picking_line = ( dest_picking.sudo().move_line_ids_without_package.filtered( lambda l: l.product_id.id == picking_line.product_id.id @@ -124,7 +130,7 @@ def _sync_receipt_with_delivery(self, dest_company, sale_order): ) dest_picking_line.sudo().write( { - "qty_done": picking_line.reserved_availability, + "qty_done": picking_line.qty_done, } ) From 3b2673a3cab3e9cb5c7db9572e6d921be1df1cf6 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Thu, 22 Aug 2024 11:03:41 +0200 Subject: [PATCH 04/18] fixup! [12.0][IMP] purchase_sale_inter_company: sync pickings --- purchase_sale_stock_inter_company/models/stock_picking.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index b776b9cba52..f412071b2b0 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -77,7 +77,6 @@ def button_validate(self): ) return super().button_validate() -<<<<<<< HEAD:purchase_sale_stock_inter_company/models/stock_picking.py @api.model def _prepare_picking_line_data(self, src_picking, dest_picking): self.ensure_one() @@ -97,10 +96,7 @@ def _prepare_picking_line_data(self, src_picking, dest_picking): {"quantity_done": dest_move.quantity_done + src_line.quantity_done} ) - def _sync_receipt_with_delivery(self, dest_company, sale_order, src_pickings): -======= def _sync_receipt_with_delivery(self, dest_company, sale_order): ->>>>>>> 9f678494 ([12.0][IMP] purchase_sale_inter_company: sync pickings):purchase_sale_inter_company/models/stock_picking.py self.ensure_one() intercompany_user = dest_company.intercompany_user_id purchase_order = sale_order.auto_purchase_order_id.sudo() From 82c9c02561a1e155b48894100a884f28a3ab850d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Ra=C3=AFch?= Date: Wed, 6 Sep 2023 13:13:28 +0200 Subject: [PATCH 05/18] [FIX] purchase_sale_inter_company: consider moves that contain same products Imagine you have a customization that enables the split of a stock move into several moves (distributed in several pickings). Thus, we need to handle this case. --- .../models/__init__.py | 5 + .../models/__init__.py | 1 + .../models/sale_order.py | 139 ++++++++++++------ .../models/stock_move.py | 19 +++ .../models/stock_picking.py | 4 + 5 files changed, 124 insertions(+), 44 deletions(-) create mode 100644 purchase_sale_stock_inter_company/models/stock_move.py diff --git a/purchase_sale_inter_company/models/__init__.py b/purchase_sale_inter_company/models/__init__.py index b03d4b54283..42b75a2dc1f 100644 --- a/purchase_sale_inter_company/models/__init__.py +++ b/purchase_sale_inter_company/models/__init__.py @@ -3,3 +3,8 @@ from . import res_company from . import res_config from . import sale_order +<<<<<<< HEAD +======= +from . import stock_picking +from . import stock_move +>>>>>>> 5026d8f7 ([FIX] purchase_sale_inter_company: consider moves that contain same products) diff --git a/purchase_sale_stock_inter_company/models/__init__.py b/purchase_sale_stock_inter_company/models/__init__.py index 6175b484bd3..50bb11730d8 100644 --- a/purchase_sale_stock_inter_company/models/__init__.py +++ b/purchase_sale_stock_inter_company/models/__init__.py @@ -2,4 +2,5 @@ from . import res_company from . import res_config from . import sale_order +from . import stock_move from . import stock_picking diff --git a/purchase_sale_stock_inter_company/models/sale_order.py b/purchase_sale_stock_inter_company/models/sale_order.py index fdd54704f99..35ba7b32cd5 100644 --- a/purchase_sale_stock_inter_company/models/sale_order.py +++ b/purchase_sale_stock_inter_company/models/sale_order.py @@ -19,7 +19,7 @@ def action_confirm(self): for line in order.order_line.sudo(): if line.auto_purchase_line_id: line.auto_purchase_line_id.price_unit = line.price_unit - res = super().action_confirm() + res = super(SaleOrder, self).action_confirm() for sale_order in self.sudo(): # Do not consider SO created from intercompany PO dest_company = sale_order.partner_id.ref_company_ids @@ -34,66 +34,117 @@ def action_confirm(self): and dest_company.sync_picking ): pickings = sale_order.picking_ids - purchase_picking = sale_order.auto_purchase_order_id.sudo().picking_ids + po_company = sale_order.sudo().auto_purchase_order_id.company_id + purchase_picking = sale_order.auto_purchase_order_id.sudo( + po_company.intercompany_user_id.id + ).picking_ids if len(pickings) == len(purchase_picking) == 1: - purchase_picking.sudo().write( - {"intercompany_picking_id": pickings[0].id} - ) - pickings[0].write({"intercompany_picking_id": purchase_picking.id}) + # thus they have the same moves and move lines with same quantities + purchase_picking.write({"intercompany_picking_id": pickings.id}) + pickings.write({"intercompany_picking_id": purchase_picking.id}) if len(pickings) > 1 and len(purchase_picking) == 1: - # If there are `n` pickings in the sale order - # and one in the purchase order, split the receipt - assigned_moves = [] - assigned_move_lines = [] - for pick in pickings: - assigned = ( - purchase_picking.sudo().move_ids_without_package.filtered( - lambda m: m.product_id.id - in pick.mapped("move_ids_without_package.product_id.id") - ) + # If there are several pickings in the sale order and one + # in the purchase order, then split the receipt + # thus we need to recreate new moves and moves lines, as they differ + purchase_moves = purchase_picking.move_ids_without_package + purchase_move_lines = purchase_picking.move_line_ids_without_package + new_pickings = self.env["stock.picking"].sudo( + po_company.intercompany_user_id.id + ) + for i, pick in enumerate(pickings): + moves = pick.move_ids_without_package + new_moves = self.env["stock.move"].sudo( + po_company.intercompany_user_id.id ) - lines = purchase_picking.sudo().move_line_ids_without_package - assigned_lines = lines.filtered( - lambda m: m.product_id.id - in pick.mapped( - "move_line_ids_without_package.product_id.id" - ) + new_move_lines = self.env["stock.move.line"].sudo( + po_company.intercompany_user_id.id ) - assigned_moves.append(assigned) - assigned_move_lines.append(assigned_lines) - - for i in range(len(assigned_moves)): - if i == 0: - purchase_picking.write( + for move in moves: + purchase_move = purchase_moves.filtered( + lambda m: m.product_id.id == move.product_id.id + ) + new_move = purchase_move.sudo( + po_company.intercompany_user_id.id + ).copy( { - "move_ids_without_package": [ - (6, False, assigned_moves[i].ids) - ], - "move_line_ids_without_package": [ - (6, False, assigned_move_lines[i].ids) - ], - "intercompany_picking_id": pickings[i].id, + "picking_id": purchase_picking.id + if i == 0 + else False, + "name": move.name, + "product_uom_qty": move.product_uom_qty, + "product_uom": move.product_uom.id, + "price_unit": -move.price_unit, + "note": move.note, + "create_date": move.create_date, + "date": move.date, + "date_expected": move.date_expected, + "state": move.state, } ) - pickings[i].write( - {"intercompany_picking_id": purchase_picking.id} + new_move._update_extra_data_in_move(move) + new_moves |= new_move + for move_line in move.move_line_ids.filtered( + lambda l: l.package_level_id + and not l.picking_type_entire_packs + ): + purchase_move_line = purchase_move_lines.filtered( + lambda l: l.product_id.id == move_line.product_id.id + )[:1] + new_move_line = purchase_move_line.sudo( + po_company.intercompany_user_id.id + ).copy( + { + "picking_id": purchase_picking.id + if i == 0 + else False, + "move_id": new_move.id, + "product_uom_qty": move_line.product_uom_qty, + "product_uom_id": move_line.product_uom_id.id, + "create_date": move_line.create_date, + "date": move_line.date, + "state": move_line.state, + } + ) + new_move_line._update_extra_data_in_move_line(move_line) + new_move_lines |= new_move_line + if i == 0: + purchase_picking.sudo( + purchase_picking.company_id.intercompany_user_id.id + ).write( + { + "intercompany_picking_id": pick.id, + "note": pick.note, + "create_date": pick.create_date, + "state": pick.state, + } ) - purchase_picking.action_assign() + new_pick = purchase_picking else: - new = purchase_picking.copy( + new_pick = purchase_picking.sudo( + po_company.intercompany_user_id.id + ).copy( { "move_ids_without_package": [ - (6, False, assigned_moves[i].ids) + (6, False, new_moves.ids) ], "move_line_ids_without_package": [ - (6, False, assigned_move_lines[i].ids) + (6, False, new_move_lines.ids) ], - "intercompany_picking_id": pickings[i].id, + "intercompany_picking_id": pick.id, + "note": pick.note, + "create_date": pick.create_date, + "state": pick.state, } ) - new.action_assign() - pickings[i].write({"intercompany_picking_id": new.id}) + new_pick._update_extra_data_in_picking(pick) + pick.write({"intercompany_picking_id": new_pick.id}) + new_pickings |= new_pick + purchase_move_lines.unlink() + purchase_moves._action_cancel() + purchase_moves.unlink() + purchase_picking._update_extra_data_in_picking(pickings[:1]) + new_pickings.action_assign() return res diff --git a/purchase_sale_stock_inter_company/models/stock_move.py b/purchase_sale_stock_inter_company/models/stock_move.py new file mode 100644 index 00000000000..3d3a0a153f4 --- /dev/null +++ b/purchase_sale_stock_inter_company/models/stock_move.py @@ -0,0 +1,19 @@ +# Copyright 2023 ForgeFlow +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockMove(models.Model): + _inherit = "stock.move" + + def _update_extra_data_in_move(self, move): + if hasattr(self, "_cal_move_weight"): # from delivery module + self._cal_move_weight() + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + def _update_extra_data_in_move_line(self, move_line): + pass diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index f412071b2b0..b088d83b2b0 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -149,3 +149,7 @@ def action_generate_backorder_wizard(self): "res_id": wiz.id, "context": self.env.context, } + + def _update_extra_data_in_picking(self, picking): + if hasattr(self, "_cal_weight"): # from delivery module + self._cal_weight() From 3e5db45119db4e13708667b38588e2edb16f6dae Mon Sep 17 00:00:00 2001 From: Alessio Renda Date: Thu, 5 Oct 2023 10:23:36 +0200 Subject: [PATCH 06/18] [IMP] account_invoice_inter_company: Remove duplicated partner creation in tests --- .../tests/test_inter_company_invoice.py | 91 +++++++++++++++---- 1 file changed, 74 insertions(+), 17 deletions(-) diff --git a/account_invoice_inter_company/tests/test_inter_company_invoice.py b/account_invoice_inter_company/tests/test_inter_company_invoice.py index af166e91610..cd14b9001ed 100644 --- a/account_invoice_inter_company/tests/test_inter_company_invoice.py +++ b/account_invoice_inter_company/tests/test_inter_company_invoice.py @@ -5,10 +5,12 @@ from odoo import _ from odoo.exceptions import UserError, ValidationError -from odoo.tests.common import Form, TransactionCase +from odoo.tests import tagged +from odoo.tests.common import Form, SavepointCase -class TestAccountInvoiceInterCompanyBase(TransactionCase): +@tagged("post_install", "-at_install") +class TestAccountInvoiceInterCompanyBase(SavepointCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -30,11 +32,8 @@ def setUpClass(cls): "invoice_auto_validation": True, } ) - cls.chart.try_loading(company=cls.company_a, install_demo=False) - cls.partner_company_a = cls.env["res.partner"].create( - {"name": cls.company_a.name, "is_company": True} - ) - cls.company_a.partner_id = cls.partner_company_a + cls.chart.try_loading(cls.company_a) + cls.partner_company_a = cls.company_a.partner_id cls.company_b = cls.env["res.company"].create( { "name": "Company B", @@ -44,10 +43,8 @@ def setUpClass(cls): "invoice_auto_validation": True, } ) - cls.chart.try_loading(company=cls.company_b, install_demo=False) - cls.partner_company_b = cls.env["res.partner"].create( - {"name": cls.company_b.name, "is_company": True} - ) + cls.chart.try_loading(cls.company_b) + cls.partner_company_b = cls.company_b.partner_id cls.child_partner_company_b = cls.env["res.partner"].create( { "name": "Child, Company B", @@ -56,8 +53,6 @@ def setUpClass(cls): "parent_id": cls.partner_company_b.id, } ) - cls.company_b.partner_id = cls.partner_company_b - # cls.partner_company_b = cls.company_b.parent_id.partner_id cls.user_company_a = cls.env["res.users"].create( { "name": "User A", @@ -269,7 +264,8 @@ def setUpClass(cls): "code": "SAJ-A", "type": "sale", "secure_sequence_id": cls.sequence_sale_journal_company_a.id, - "default_account_id": cls.a_sale_company_a.id, + "payment_credit_account_id": cls.a_sale_company_a.id, + "payment_debit_account_id": cls.a_sale_company_a.id, "company_id": cls.company_a.id, } ) @@ -279,7 +275,8 @@ def setUpClass(cls): "name": "Bank Journal - (Company A)", "code": "BNK-A", "type": "bank", - "default_account_id": cls.a_sale_company_a.id, + "payment_credit_account_id": cls.a_sale_company_a.id, + "payment_debit_account_id": cls.a_sale_company_a.id, "company_id": cls.company_a.id, } ) @@ -298,7 +295,8 @@ def setUpClass(cls): "code": "EXJ-B", "type": "purchase", "secure_sequence_id": cls.sequence_purchase_journal_company_b.id, - "default_account_id": cls.a_expense_company_b.id, + "payment_credit_account_id": cls.a_expense_company_b.id, + "payment_debit_account_id": cls.a_expense_company_b.id, "company_id": cls.company_b.id, } ) @@ -307,7 +305,8 @@ def setUpClass(cls): "name": "Bank Journal - (Company B)", "code": "BNK-B", "type": "bank", - "default_account_id": cls.a_sale_company_b.id, + "payment_credit_account_id": cls.a_sale_company_b.id, + "payment_debit_account_id": cls.a_sale_company_b.id, "company_id": cls.company_b.id, } ) @@ -559,6 +558,46 @@ def test_confirm_invoice_with_native_product_rule_and_unshared_product(self): with self.assertRaises(UserError): self._confirm_invoice_with_product() + def test_purchase_attachement_out_invoice(self): + # Sale Invoice PDF appears as attachment in the purchase invoice form. + # From a Sale Invoice. + self.invoice_company_a.action_post() + invoice_company_b = self.account_move_obj.with_user( + self.user_company_b.id + ).search([("auto_invoice_id", "=", self.invoice_company_a.id)]) + invoice_b_pdf = self.env["ir.attachment"].search( + [("res_model", "=", "account.move"), ("res_id", "=", invoice_company_b.id)] + ) + self.assertEqual(len(invoice_b_pdf), 1) + self.assertEqual(invoice_b_pdf.name, self.invoice_company_a.name + ".pdf") + + def test_purchase_attachement_in_invoice(self): + # Sale Invoice PDF appears as attachment in the purchase invoice form. + # From a Purchase Invoice. + bill_company_a = Form( + self.account_move_obj.with_company(self.company_a.id).with_context( + default_move_type="in_invoice", + ) + ) + bill_company_a.partner_id = self.partner_company_b + bill_company_a.invoice_date = bill_company_a.date + with bill_company_a.invoice_line_ids.new() as line_form: + line_form.product_id = self.product_consultant_multi_company + line_form.quantity = 1 + line_form.product_uom_id = self.env.ref("uom.product_uom_hour") + line_form.price_unit = 450.0 + bill_company_a = bill_company_a.save() + bill_company_a.action_post() + + invoice_company_b = self.account_move_obj.with_user( + self.user_company_b.id + ).search([("auto_invoice_id", "=", bill_company_a.id)]) + bill_a_pdf = self.env["ir.attachment"].search( + [("res_model", "=", "account.move"), ("res_id", "=", bill_company_a.id)] + ) + self.assertEqual(len(bill_a_pdf), 1) + self.assertEqual(bill_a_pdf.name, invoice_company_b.name + ".pdf") + def _confirm_invoice_with_product(self): # Confirm the invoice of company A self.invoice_company_a.with_user(self.user_company_a.id).action_post() @@ -568,3 +607,21 @@ def _confirm_invoice_with_product(self): ) self.assertEqual(len(invoices), 1) return invoices + + def test_confirm_invoice_and_full_refund(self): + self.env.ref("product.product_comp_rule").write({"active": False}) + self._confirm_invoice_with_product() + wizard = self.env["account.move.reversal"].create( + { + "refund_method": "cancel", + "move_ids": [(6, 0, self.invoice_company_a.ids)], + } + ) + action = wizard.reverse_moves() + refund_company_a = self.account_move_obj.browse(action["res_id"]) + + # Check destination refund created in company B + refund = self.account_move_obj.with_user(self.user_company_b.id).search( + [("auto_invoice_id", "=", refund_company_a.id)] + ) + self.assertEqual(len(refund), 1) From 04009a42bd3e7a95aae57208565a45c6fe655ffa Mon Sep 17 00:00:00 2001 From: Alessandro Uffreduzzi Date: Tue, 19 Sep 2023 17:27:45 +0200 Subject: [PATCH 07/18] [MIG] purchase_sale_inter_company: migrate sync pickings for v14 --- .../readme/CONTEXT.rst | 10 +++ purchase_sale_inter_company/readme/USAGE.rst | 6 ++ .../static/description/index.html | 75 +++++++++++++++++ .../tests/test_inter_company_purchase_sale.py | 52 ++++++++---- .../models/sale_order.py | 40 ++++----- .../models/stock_picking.py | 84 ++++++------------- .../test_inter_company_purchase_sale_stock.py | 48 +++++++++++ 7 files changed, 217 insertions(+), 98 deletions(-) create mode 100644 purchase_sale_inter_company/readme/CONTEXT.rst create mode 100644 purchase_sale_inter_company/readme/USAGE.rst diff --git a/purchase_sale_inter_company/readme/CONTEXT.rst b/purchase_sale_inter_company/readme/CONTEXT.rst new file mode 100644 index 00000000000..e48bb838cb2 --- /dev/null +++ b/purchase_sale_inter_company/readme/CONTEXT.rst @@ -0,0 +1,10 @@ +Imagine you have company A and company B in the same Odoo database: + + +Company A purchases goods from company B. + +Company A will create a purchase order with company B as supplier. + +This module automates the creation of the sale order in company B with company A as customer. + +Receipt picking(s) created from Company A purchase are synced with quantities delivered in picking(s) by Company B sale. diff --git a/purchase_sale_inter_company/readme/USAGE.rst b/purchase_sale_inter_company/readme/USAGE.rst new file mode 100644 index 00000000000..ec453db5db9 --- /dev/null +++ b/purchase_sale_inter_company/readme/USAGE.rst @@ -0,0 +1,6 @@ +Create a purchase with Company A, setting Company B as vendor > confirm PO > a SO for Company B with customer Company A is created automatically. + + +Validate SO for Company B > validate delivery picking > in PO for Company A, receipt picking is validated with quantities from Company B delivery picking. + +If backorders have been created from delivery picking, they will be synchronized to receipt picking. diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index 015bd22cbbc..51f9d5dccf0 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -366,6 +366,7 @@

Inter Company Module for Purchase to Sale Order

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +<<<<<<< HEAD !! source digest: sha256:c7f112b9bede803ae39e7ef8b5c83a3533ae6e2413282cd4b904da53df01b10f !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/multi-company Translate me on Weblate Try me on Runboat

@@ -388,10 +389,28 @@

Inter Company Module for Purchase to Sale Order

  • Authors
  • Contributors
  • Maintainers
  • +======= +!! source digest: sha256:64bdb0a1f69af0e4ff11bd541fc751d13b59ba222c640f6a9c534d317b794521 +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> +

    Beta License: AGPL-3 OCA/multi-company Translate me on Weblate Try me on Runboat

    +

    This module is useful if there are multiple companies in the same Odoo database and those companies sell goods or services among themselves.

    +

    It allows to create a sale order in company A from a purchase order in company B, and keep delivery/receipt pickings synced, including backorders.

    +

    Table of contents

    +
    +
    +<<<<<<< HEAD

    Installation

    If you want also to have different warehouses for your sales orders you can install stock and then purchase_sale_stock_inter_company will be auto installed.

    @@ -426,6 +445,34 @@

    Bug Tracker

    Credits

    Authors

    +======= +
    +

    Use Cases / Context

    +

    Imagine you have company A and company B in the same Odoo database:

    +

    Company A purchases goods from company B.

    +

    Company A will create a purchase order with company B as supplier.

    +

    This module automates the creation of the sale order in company B with company A as customer.

    +

    Receipt picking(s) created from Company A purchase are synced with quantities delivered in picking(s) by Company B sale.

    +
    +
    +

    Usage

    +

    Create a purchase with Company A, setting Company B as vendor > confirm PO > a SO for Company B with customer Company A is created automatically.

    +

    Validate SO for Company B > validate delivery picking > in PO for Company A, receipt picking is validated with quantities from Company B delivery picking.

    +

    If backorders have been created from delivery picking, they will be synchronized to receipt picking.

    +
    +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +>>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14)
    • Odoo SA
    • Akretion
    • @@ -433,6 +480,7 @@

      Authors

    +<<<<<<< HEAD

    Contributors

    • Odoo S.A. (original module inter_company_rules)
    • @@ -459,12 +507,39 @@

      Contributors

    Maintainers

    +======= +

    Contributors

    + +
    +
    +

    Maintainers

    +>>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14)

    This module is maintained by the OCA.

    Odoo Community Association

    OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

    +<<<<<<< HEAD

    This module is part of the OCA/multi-company project on GitHub.

    +======= +

    This module is part of the OCA/multi-company project on GitHub.

    +>>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14)

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    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 9b0d4cd8a88..12e3b064dc8 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 @@ -23,7 +23,7 @@ def _configure_user(cls, user): user.groups_id |= cls.env.ref(xml) @classmethod - def _create_purchase_order(cls, partner): + def _create_purchase_order(cls, partner, product_id=None): po = Form(cls.env["purchase.order"]) po.company_id = cls.company_a po.partner_id = partner @@ -31,7 +31,7 @@ def _create_purchase_order(cls, partner): cls.product.invoice_policy = "order" with po.order_line.new() as line_form: - line_form.product_id = cls.product + line_form.product_id = product_id if product_id else cls.product line_form.product_qty = 3.0 line_form.name = "Service Multi Company" line_form.price_unit = 450.0 @@ -55,10 +55,24 @@ def setUpClass(cls): # We have to do that because the default method added a company cls.service_product_2.company_ids = False + cls.consumable_product = cls.env["product.product"].create( + { + "name": "Consumable Product", + "type": "product", + "categ_id": cls.env.ref("product.product_category_all").id, + "qty_available": 100, + } + ) + + # if partner_multi_company or product_multi_company is installed + # We have to do that because the default method added a company if "company_ids" in cls.env["res.partner"]._fields: - # We have to do that because the default method added a company - cls.partner_company_a.company_ids = [(6, 0, cls.company_a.ids)] - cls.partner_company_b.company_ids = [(6, 0, cls.company_b.ids)] + cls.partner_company_a.company_ids = False + cls.partner_company_b.company_ids = False + + if "company_ids" in cls.env["product.template"]._fields: + cls.product.company_ids = False + cls.consumable_product.company_ids = False # Configure Company B (the supplier) cls.company_b.so_from_po = True @@ -80,18 +94,20 @@ def setUpClass(cls): {"currency_id": cls.env.ref("base.USD").id} ) - def _approve_po(self): + def _approve_po(self, purchase_id): """Confirm the PO in company A and return the related sale of Company B""" - self.purchase_company_a.with_user(self.user_company_a).button_approve() + + purchase_id.with_user(self.intercompany_sale_user_id).button_approve() + return ( self.env["sale.order"] .with_user(self.user_company_b) - .search([("auto_purchase_order_id", "=", self.purchase_company_a.id)]) + .search([("auto_purchase_order_id", "=", purchase_id.id)]) ) def test_purchase_sale_inter_company(self): self.purchase_company_a.notes = "Test note" - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(len(sale), 1) self.assertEqual(sale.state, "sale") self.assertEqual(sale.partner_id, self.partner_company_a) @@ -101,7 +117,7 @@ def test_purchase_sale_inter_company(self): def test_not_auto_validate(self): self.company_b.sale_auto_validation = False - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(sale.state, "draft") # TODO FIXME @@ -114,7 +130,7 @@ def xxtest_date_planned(self): return False module.button_install() self.purchase_company_a.date_planned = "2070-12-31" - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(sale.requested_date, "2070-12-31") def test_raise_product_access(self): @@ -125,18 +141,18 @@ def test_raise_product_access(self): self.product.company_ids = [(6, 0, [self.company_a.id])] self.product.company_id = self.company_a with self.assertRaises(UserError): - self._approve_po() + self._approve_po(self.purchase_company_a) def test_raise_currency(self): currency = self.env.ref("base.EUR") self.purchase_company_a.currency_id = currency with self.assertRaises(UserError): - self._approve_po() + self._approve_po(self.purchase_company_a) def test_purchase_invoice_relation(self): self.partner_company_a.company_id = False self.partner_company_b.company_id = False - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) sale_invoice = sale._create_invoices()[0] sale_invoice.action_post() self.assertEqual(len(self.purchase_company_a.invoice_ids), 1) @@ -149,7 +165,7 @@ def test_purchase_invoice_relation(self): def test_cancel(self): self.company_b.sale_auto_validation = False - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(self.purchase_company_a.partner_ref, sale.name) self.purchase_company_a.with_user(self.user_company_a).button_cancel() self.assertFalse(self.purchase_company_a.partner_ref) @@ -157,12 +173,12 @@ def test_cancel(self): def test_cancel_confirmed_po_so(self): self.company_b.sale_auto_validation = True - self._approve_po() + self._approve_po(self.purchase_company_a) with self.assertRaises(UserError): self.purchase_company_a.with_user(self.user_company_a).button_cancel() def test_so_change_price(self): - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) sale.order_line.price_unit = 10 sale.action_confirm() self.assertEqual(self.purchase_company_a.order_line.price_unit, 10) @@ -172,7 +188,7 @@ def test_po_with_contact_as_partner(self): {"name": "Test contact", "parent_id": self.partner_company_b.id} ) self.purchase_company_a = self._create_purchase_order(contact) - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(len(sale), 1) self.assertEqual(sale.state, "sale") self.assertEqual(sale.partner_id, self.partner_company_a) diff --git a/purchase_sale_stock_inter_company/models/sale_order.py b/purchase_sale_stock_inter_company/models/sale_order.py index 35ba7b32cd5..36eed9803c2 100644 --- a/purchase_sale_stock_inter_company/models/sale_order.py +++ b/purchase_sale_stock_inter_company/models/sale_order.py @@ -19,15 +19,9 @@ def action_confirm(self): for line in order.order_line.sudo(): if line.auto_purchase_line_id: line.auto_purchase_line_id.price_unit = line.price_unit - res = super(SaleOrder, self).action_confirm() + res = super().action_confirm() for sale_order in self.sudo(): - # Do not consider SO created from intercompany PO dest_company = sale_order.partner_id.ref_company_ids - if not sale_order.auto_purchase_order_id: - if dest_company and dest_company.po_from_so: - sale_order.with_context( - force_company=dest_company.id - )._inter_company_create_purchase_order(dest_company) if ( sale_order.auto_purchase_order_id and dest_company @@ -35,8 +29,8 @@ def action_confirm(self): ): pickings = sale_order.picking_ids po_company = sale_order.sudo().auto_purchase_order_id.company_id - purchase_picking = sale_order.auto_purchase_order_id.sudo( - po_company.intercompany_user_id.id + purchase_picking = sale_order.auto_purchase_order_id.with_user( + po_company.intercompany_sale_user_id.id ).picking_ids if len(pickings) == len(purchase_picking) == 1: @@ -49,23 +43,23 @@ def action_confirm(self): # thus we need to recreate new moves and moves lines, as they differ purchase_moves = purchase_picking.move_ids_without_package purchase_move_lines = purchase_picking.move_line_ids_without_package - new_pickings = self.env["stock.picking"].sudo( - po_company.intercompany_user_id.id + new_pickings = self.env["stock.picking"].with_user( + po_company.intercompany_sale_user_id.id ) for i, pick in enumerate(pickings): moves = pick.move_ids_without_package - new_moves = self.env["stock.move"].sudo( - po_company.intercompany_user_id.id + new_moves = self.env["stock.move"].with_user( + po_company.intercompany_sale_user_id.id ) - new_move_lines = self.env["stock.move.line"].sudo( - po_company.intercompany_user_id.id + new_move_lines = self.env["stock.move.line"].with_user( + po_company.intercompany_sale_user_id.id ) for move in moves: purchase_move = purchase_moves.filtered( lambda m: m.product_id.id == move.product_id.id ) - new_move = purchase_move.sudo( - po_company.intercompany_user_id.id + new_move = purchase_move.with_user( + po_company.intercompany_sale_user_id.id ).copy( { "picking_id": purchase_picking.id @@ -91,8 +85,8 @@ def action_confirm(self): purchase_move_line = purchase_move_lines.filtered( lambda l: l.product_id.id == move_line.product_id.id )[:1] - new_move_line = purchase_move_line.sudo( - po_company.intercompany_user_id.id + new_move_line = purchase_move_line.with_user( + po_company.intercompany_sale_user_id.id ).copy( { "picking_id": purchase_picking.id @@ -109,8 +103,8 @@ def action_confirm(self): new_move_line._update_extra_data_in_move_line(move_line) new_move_lines |= new_move_line if i == 0: - purchase_picking.sudo( - purchase_picking.company_id.intercompany_user_id.id + purchase_picking.with_user( + purchase_picking.company_id.intercompany_sale_user_id.id ).write( { "intercompany_picking_id": pick.id, @@ -121,8 +115,8 @@ def action_confirm(self): ) new_pick = purchase_picking else: - new_pick = purchase_picking.sudo( - po_company.intercompany_user_id.id + new_pick = purchase_picking.with_user( + po_company.intercompany_sale_user_id.id ).copy( { "move_ids_without_package": [ diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index b088d83b2b0..c849406c79f 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -11,9 +11,7 @@ class StockPicking(models.Model): intercompany_picking_id = fields.Many2one(comodel_name="stock.picking", copy=False) - def action_done(self): - # Only DropShip pickings - po_picks = self.browse() + def _action_done(self): for pick in self.filtered( lambda x: x.location_dest_id.usage == "customer" ).sudo(): @@ -21,23 +19,14 @@ def action_done(self): 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]}) for move_line in pick.move_line_ids: - qty_done = move_line.qty_done sale_line_id = move_line.move_id.sale_line_id po_move_lines = sale_line_id.auto_purchase_line_id.move_ids.mapped( "move_line_ids" ) - for po_move_line in po_move_lines: - if po_move_line.product_qty >= qty_done: - po_move_line.qty_done = qty_done - qty_done = 0.0 - else: - po_move_line.qty_done = po_move_line.product_qty - qty_done -= po_move_line.product_qty - po_picks |= po_move_line.picking_id - if qty_done and po_move_lines: - po_move_lines[-1:].qty_done += qty_done - elif not po_move_lines: + if not po_move_lines: raise UserError( _( "There's no corresponding line in PO %(po)s for assigning " @@ -51,12 +40,10 @@ def action_done(self): } ) ) - # Transfer dropship pickings - for po_pick in po_picks.sudo(): - po_pick.with_company(po_pick.company_id.id).action_done() - return super(StockPicking, self).action_done() + return super()._action_done() def button_validate(self): + res = super().button_validate() is_intercompany = self.env["res.company"].search( [("partner_id", "=", self.partner_id.id)] ) or self.env["res.company"].search( @@ -65,6 +52,7 @@ def button_validate(self): if ( is_intercompany and self.company_id.sync_picking + and self.state == "done" and self.picking_type_code == "outgoing" ): sale_order = self.sale_id @@ -75,7 +63,7 @@ def button_validate(self): dest_company, sale_order, ) - return super().button_validate() + return res @api.model def _prepare_picking_line_data(self, src_picking, dest_picking): @@ -98,16 +86,24 @@ def _prepare_picking_line_data(self, src_picking, dest_picking): def _sync_receipt_with_delivery(self, dest_company, sale_order): self.ensure_one() - intercompany_user = dest_company.intercompany_user_id + intercompany_user = dest_company.intercompany_sale_user_id purchase_order = sale_order.auto_purchase_order_id.sudo() - if ( - not purchase_order - or not purchase_order.sudo(intercompany_user.id).picking_ids - ): + 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.sudo(intercompany_user.id) - for picking_move in self.move_ids_without_package: + dest_picking = self.intercompany_picking_id.with_user(intercompany_user.id) + for picking_line in self.move_line_ids_without_package.sorted("qty_done"): + dest_picking_line = ( + dest_picking.sudo().move_line_ids_without_package.filtered( + lambda l: l.product_id.id == picking_line.product_id.id + ) + ) + dest_picking_line.sudo().write( + { + "qty_done": picking_line.qty_done, + } + ) + for picking_move in self.move_ids_without_package.sorted("quantity_done"): dest_picking_move = ( dest_picking.sudo().move_ids_without_package.filtered( lambda l: l.product_id.id == picking_move.product_id.id @@ -118,37 +114,11 @@ def _sync_receipt_with_delivery(self, dest_company, sale_order): "quantity_done": picking_move.quantity_done, } ) - for picking_line in self.move_line_ids_without_package: - dest_picking_line = ( - dest_picking.sudo().move_line_ids_without_package.filtered( - lambda l: l.product_id.id == picking_line.product_id.id - ) + dest_picking.sudo().with_context( + cancel_backorder=bool( + self.env.context.get("picking_ids_not_to_backorder") ) - dest_picking_line.sudo().write( - { - "qty_done": picking_line.qty_done, - } - ) - - def action_generate_backorder_wizard(self): - view = self.env.ref("stock.view_backorder_confirmation") - wiz = ( - self.env["stock.backorder.confirmation"] - .with_context(picking_id=self.id) - .create({"pick_ids": [(4, p.id) for p in self]}) - ) - return { - "name": _("Create Backorder?"), - "type": "ir.actions.act_window", - "view_type": "form", - "view_mode": "form", - "res_model": "stock.backorder.confirmation", - "views": [(view.id, "form")], - "view_id": view.id, - "target": "new", - "res_id": wiz.id, - "context": self.env.context, - } + )._action_done() def _update_extra_data_in_picking(self, picking): if hasattr(self, "_cal_weight"): # from delivery module 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 5ae0e207565..37437a10ed5 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 @@ -66,3 +66,51 @@ def test_purchase_sale_stock_inter_company(self): self.purchase_company_a.picking_type_id.warehouse_id.partner_id, ) self.assertEqual(sale.warehouse_id, self.warehouse_c) + + def test_sync_picking(self): + self.company_a.sync_picking = True + self.company_b.sync_picking = True + + purchase = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + sale = self._approve_po(purchase) + + self.assertTrue(purchase.picking_ids) + self.assertTrue(sale.picking_ids) + + # validate the SO picking + po_picking_id = purchase.picking_ids + so_picking_id = sale.picking_ids + + so_picking_id.move_lines.quantity_done = 2 + + self.assertNotEqual(po_picking_id, so_picking_id) + self.assertNotEqual( + po_picking_id.move_lines.quantity_done, + so_picking_id.move_lines.quantity_done, + ) + self.assertEqual( + po_picking_id.move_lines.product_qty, + so_picking_id.move_lines.product_qty, + ) + + so_picking_id.state = "done" + 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() + + # Quantities should have been synced + self.assertNotEqual(po_picking_id, so_picking_id) + self.assertEqual( + po_picking_id.move_lines.quantity_done, + so_picking_id.move_lines.quantity_done, + ) + + # 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)) \ No newline at end of file From 9f8e7a085c2ef94cdb3090460f09701ede22d9b8 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Mon, 26 Aug 2024 11:30:13 +0200 Subject: [PATCH 08/18] [MIG] purchase_sale_inter_company: migrate sync pickings for v15 --- .../tests/test_inter_company_invoice.py | 81 ++--------- purchase_sale_inter_company/README.rst | 24 ++++ .../models/__init__.py | 5 - .../models/res_config.py | 1 - .../models/sale_order.py | 13 -- .../static/description/index.html | 132 +++++------------- .../tests/test_inter_company_purchase_sale.py | 2 +- .../models/res_company.py | 1 - .../models/sale_order.py | 12 +- .../models/stock_picking.py | 18 ++- .../test_inter_company_purchase_sale_stock.py | 14 +- .../views/res_config_view.xml | 6 +- 12 files changed, 90 insertions(+), 219 deletions(-) diff --git a/account_invoice_inter_company/tests/test_inter_company_invoice.py b/account_invoice_inter_company/tests/test_inter_company_invoice.py index cd14b9001ed..cb5cfd760e2 100644 --- a/account_invoice_inter_company/tests/test_inter_company_invoice.py +++ b/account_invoice_inter_company/tests/test_inter_company_invoice.py @@ -5,12 +5,10 @@ from odoo import _ from odoo.exceptions import UserError, ValidationError -from odoo.tests import tagged -from odoo.tests.common import Form, SavepointCase +from odoo.tests.common import Form, TransactionCase -@tagged("post_install", "-at_install") -class TestAccountInvoiceInterCompanyBase(SavepointCase): +class TestAccountInvoiceInterCompanyBase(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -32,7 +30,7 @@ def setUpClass(cls): "invoice_auto_validation": True, } ) - cls.chart.try_loading(cls.company_a) + cls.chart.try_loading(company=cls.company_a, install_demo=False) cls.partner_company_a = cls.company_a.partner_id cls.company_b = cls.env["res.company"].create( { @@ -43,7 +41,7 @@ def setUpClass(cls): "invoice_auto_validation": True, } ) - cls.chart.try_loading(cls.company_b) + cls.chart.try_loading(company=cls.company_b, install_demo=False) cls.partner_company_b = cls.company_b.partner_id cls.child_partner_company_b = cls.env["res.partner"].create( { @@ -53,6 +51,7 @@ def setUpClass(cls): "parent_id": cls.partner_company_b.id, } ) + # cls.partner_company_b = cls.company_b.parent_id.partner_id cls.user_company_a = cls.env["res.users"].create( { "name": "User A", @@ -264,8 +263,7 @@ def setUpClass(cls): "code": "SAJ-A", "type": "sale", "secure_sequence_id": cls.sequence_sale_journal_company_a.id, - "payment_credit_account_id": cls.a_sale_company_a.id, - "payment_debit_account_id": cls.a_sale_company_a.id, + "default_account_id": cls.a_sale_company_a.id, "company_id": cls.company_a.id, } ) @@ -275,8 +273,7 @@ def setUpClass(cls): "name": "Bank Journal - (Company A)", "code": "BNK-A", "type": "bank", - "payment_credit_account_id": cls.a_sale_company_a.id, - "payment_debit_account_id": cls.a_sale_company_a.id, + "default_account_id": cls.a_sale_company_a.id, "company_id": cls.company_a.id, } ) @@ -295,8 +292,7 @@ def setUpClass(cls): "code": "EXJ-B", "type": "purchase", "secure_sequence_id": cls.sequence_purchase_journal_company_b.id, - "payment_credit_account_id": cls.a_expense_company_b.id, - "payment_debit_account_id": cls.a_expense_company_b.id, + "default_account_id": cls.a_expense_company_b.id, "company_id": cls.company_b.id, } ) @@ -305,8 +301,7 @@ def setUpClass(cls): "name": "Bank Journal - (Company B)", "code": "BNK-B", "type": "bank", - "payment_credit_account_id": cls.a_sale_company_b.id, - "payment_debit_account_id": cls.a_sale_company_b.id, + "default_account_id": cls.a_sale_company_b.id, "company_id": cls.company_b.id, } ) @@ -558,46 +553,6 @@ def test_confirm_invoice_with_native_product_rule_and_unshared_product(self): with self.assertRaises(UserError): self._confirm_invoice_with_product() - def test_purchase_attachement_out_invoice(self): - # Sale Invoice PDF appears as attachment in the purchase invoice form. - # From a Sale Invoice. - self.invoice_company_a.action_post() - invoice_company_b = self.account_move_obj.with_user( - self.user_company_b.id - ).search([("auto_invoice_id", "=", self.invoice_company_a.id)]) - invoice_b_pdf = self.env["ir.attachment"].search( - [("res_model", "=", "account.move"), ("res_id", "=", invoice_company_b.id)] - ) - self.assertEqual(len(invoice_b_pdf), 1) - self.assertEqual(invoice_b_pdf.name, self.invoice_company_a.name + ".pdf") - - def test_purchase_attachement_in_invoice(self): - # Sale Invoice PDF appears as attachment in the purchase invoice form. - # From a Purchase Invoice. - bill_company_a = Form( - self.account_move_obj.with_company(self.company_a.id).with_context( - default_move_type="in_invoice", - ) - ) - bill_company_a.partner_id = self.partner_company_b - bill_company_a.invoice_date = bill_company_a.date - with bill_company_a.invoice_line_ids.new() as line_form: - line_form.product_id = self.product_consultant_multi_company - line_form.quantity = 1 - line_form.product_uom_id = self.env.ref("uom.product_uom_hour") - line_form.price_unit = 450.0 - bill_company_a = bill_company_a.save() - bill_company_a.action_post() - - invoice_company_b = self.account_move_obj.with_user( - self.user_company_b.id - ).search([("auto_invoice_id", "=", bill_company_a.id)]) - bill_a_pdf = self.env["ir.attachment"].search( - [("res_model", "=", "account.move"), ("res_id", "=", bill_company_a.id)] - ) - self.assertEqual(len(bill_a_pdf), 1) - self.assertEqual(bill_a_pdf.name, invoice_company_b.name + ".pdf") - def _confirm_invoice_with_product(self): # Confirm the invoice of company A self.invoice_company_a.with_user(self.user_company_a.id).action_post() @@ -607,21 +562,3 @@ def _confirm_invoice_with_product(self): ) self.assertEqual(len(invoices), 1) return invoices - - def test_confirm_invoice_and_full_refund(self): - self.env.ref("product.product_comp_rule").write({"active": False}) - self._confirm_invoice_with_product() - wizard = self.env["account.move.reversal"].create( - { - "refund_method": "cancel", - "move_ids": [(6, 0, self.invoice_company_a.ids)], - } - ) - action = wizard.reverse_moves() - refund_company_a = self.account_move_obj.browse(action["res_id"]) - - # Check destination refund created in company B - refund = self.account_move_obj.with_user(self.user_company_b.id).search( - [("auto_invoice_id", "=", refund_company_a.id)] - ) - self.assertEqual(len(refund), 1) diff --git a/purchase_sale_inter_company/README.rst b/purchase_sale_inter_company/README.rst index 571177899af..f5fe0653af2 100644 --- a/purchase_sale_inter_company/README.rst +++ b/purchase_sale_inter_company/README.rst @@ -42,6 +42,20 @@ Imagine you have company A and company B in the same Odoo database: .. contents:: :local: +Use Cases / Context +=================== + +Imagine you have company A and company B in the same Odoo database: + + +Company A purchases goods from company B. + +Company A will create a purchase order with company B as supplier. + +This module automates the creation of the sale order in company B with company A as customer. + +Receipt picking(s) created from Company A purchase are synced with quantities delivered in picking(s) by Company B sale. + Installation ============ @@ -56,6 +70,16 @@ To configure this module, you need to: #. Go to the tab *Inter-Company* then the group *Purchase To Sale*. #. If you check the option *Sale Auto Validation* in the configuration of company B, then when you validate a *Purchase Order* in company A with company B as supplier, the *Sale Order* will be automatically validated in company B with company A as customer. +Usage +===== + +Create a purchase with Company A, setting Company B as vendor > confirm PO > a SO for Company B with customer Company A is created automatically. + + +Validate SO for Company B > validate delivery picking > in PO for Company A, receipt picking is validated with quantities from Company B delivery picking. + +If backorders have been created from delivery picking, they will be synchronized to receipt picking. + Known issues / Roadmap ====================== diff --git a/purchase_sale_inter_company/models/__init__.py b/purchase_sale_inter_company/models/__init__.py index 42b75a2dc1f..b03d4b54283 100644 --- a/purchase_sale_inter_company/models/__init__.py +++ b/purchase_sale_inter_company/models/__init__.py @@ -3,8 +3,3 @@ from . import res_company from . import res_config from . import sale_order -<<<<<<< HEAD -======= -from . import stock_picking -from . import stock_move ->>>>>>> 5026d8f7 ([FIX] purchase_sale_inter_company: consider moves that contain same products) diff --git a/purchase_sale_inter_company/models/res_config.py b/purchase_sale_inter_company/models/res_config.py index 4d15789f712..c6d18a1e384 100644 --- a/purchase_sale_inter_company/models/res_config.py +++ b/purchase_sale_inter_company/models/res_config.py @@ -33,4 +33,3 @@ class InterCompanyRulesConfig(models.TransientModel): "order in another company.", readonly=False, ) - diff --git a/purchase_sale_inter_company/models/sale_order.py b/purchase_sale_inter_company/models/sale_order.py index 1c92ab2d597..44f69735203 100644 --- a/purchase_sale_inter_company/models/sale_order.py +++ b/purchase_sale_inter_company/models/sale_order.py @@ -23,19 +23,6 @@ def action_confirm(self): line.auto_purchase_line_id.price_unit = line.price_unit return super().action_confirm() - - def action_cancel(self): - purchase_orders = ( - self.env["purchase.order"] - .sudo() - .search([("auto_sale_order_id", "in", self.ids)]) - ) - for po in purchase_orders: - if po.state not in ["draft", "sent", "cancel"]: - raise UserError(_("You can't cancel an order that is %s") % po.state) - if purchase_orders: - purchase_orders.button_cancel() - return super().action_cancel() class SaleOrderLine(models.Model): _inherit = "sale.order.line" diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index 51f9d5dccf0..3ddc2e6eaaf 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -8,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -274,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -300,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -366,7 +367,6 @@

    Inter Company Module for Purchase to Sale Order

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -<<<<<<< HEAD !! source digest: sha256:c7f112b9bede803ae39e7ef8b5c83a3533ae6e2413282cd4b904da53df01b10f !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

    Beta License: AGPL-3 OCA/multi-company Translate me on Weblate Try me on Runboat

    @@ -381,50 +381,48 @@

    Inter Company Module for Purchase to Sale Order

    Table of contents

      -
    • Installation
    • -
    • Configuration
    • -
    • Known issues / Roadmap
    • -
    • Bug Tracker
    • -
    • Credits
        -
      • Authors
      • -
      • Contributors
      • -
      • Maintainers
      • -======= -!! source digest: sha256:64bdb0a1f69af0e4ff11bd541fc751d13b59ba222c640f6a9c534d317b794521 -!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! --> -

        Beta License: AGPL-3 OCA/multi-company Translate me on Weblate Try me on Runboat

        -

        This module is useful if there are multiple companies in the same Odoo database and those companies sell goods or services among themselves.

        -

        It allows to create a sale order in company A from a purchase order in company B, and keep delivery/receipt pickings synced, including backorders.

        -

        Table of contents

        -
        -
        -<<<<<<< HEAD +
        +

        Use Cases / Context

        +

        Imagine you have company A and company B in the same Odoo database:

        +

        Company A purchases goods from company B.

        +

        Company A will create a purchase order with company B as supplier.

        +

        This module automates the creation of the sale order in company B with company A as customer.

        +

        Receipt picking(s) created from Company A purchase are synced with quantities delivered in picking(s) by Company B sale.

        +
        -

        Installation

        +

        Installation

        If you want also to have different warehouses for your sales orders you can install stock and then purchase_sale_stock_inter_company will be auto installed.

        -

        Configuration

        +

        Configuration

        To configure this module, you need to: #. go to the menu General Settings > Companies > Companies. #. Select one of the companies. #. Go to the tab Inter-Company then the group Purchase To Sale. #. If you check the option Sale Auto Validation in the configuration of company B, then when you validate a Purchase Order in company A with company B as supplier, the Sale Order will be automatically validated in company B with company A as customer.

        +
        +

        Usage

        +

        Create a purchase with Company A, setting Company B as vendor > confirm PO > a SO for Company B with customer Company A is created automatically.

        +

        Validate SO for Company B > validate delivery picking > in PO for Company A, receipt picking is validated with quantities from Company B delivery picking.

        +

        If backorders have been created from delivery picking, they will be synchronized to receipt picking.

        +
        -

        Known issues / Roadmap

        +

        Known issues / Roadmap

        • No synchronization is made from the generated sale order back to the purchase order. This would be interesting in the case of price changes and discounts, that would be @@ -434,7 +432,7 @@

          Known issues / Roadmap

        -

        Bug Tracker

        +

        Bug Tracker

        Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -442,37 +440,9 @@

        Bug Tracker

        Do not contact contributors directly about support or help with technical issues.

        -

        Credits

        +

        Credits

        -

        Authors

        -======= -
        -

        Use Cases / Context

        -

        Imagine you have company A and company B in the same Odoo database:

        -

        Company A purchases goods from company B.

        -

        Company A will create a purchase order with company B as supplier.

        -

        This module automates the creation of the sale order in company B with company A as customer.

        -

        Receipt picking(s) created from Company A purchase are synced with quantities delivered in picking(s) by Company B sale.

        -
        -
        -

        Usage

        -

        Create a purchase with Company A, setting Company B as vendor > confirm PO > a SO for Company B with customer Company A is created automatically.

        -

        Validate SO for Company B > validate delivery picking > in PO for Company A, receipt picking is validated with quantities from Company B delivery picking.

        -

        If backorders have been created from delivery picking, they will be synchronized to receipt picking.

        -
        -
        -

        Bug Tracker

        -

        Bugs are tracked on GitHub Issues. -In case of trouble, please check there if your issue has already been reported. -If you spotted it first, help us to smash it by providing a detailed and welcomed -feedback.

        -

        Do not contact contributors directly about support or help with technical issues.

        -
        -
        -

        Credits

        -
        -

        Authors

        ->>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14) +

        Authors

        • Odoo SA
        • Akretion
        • @@ -480,8 +450,7 @@

          Authors

        -<<<<<<< HEAD -

        Contributors

        +

        Contributors

        -

        Maintainers

        -======= -

        Contributors

        - -
        -
        -

        Maintainers

        ->>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14) +

        Maintainers

        This module is maintained by the OCA.

        -Odoo Community Association + +Odoo Community Association +

        OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

        -<<<<<<< HEAD

        This module is part of the OCA/multi-company project on GitHub.

        -======= -

        This module is part of the OCA/multi-company project on GitHub.

        ->>>>>>> 261ca0a0 ([MIG] purchase_sale_inter_company: migrate sync pickings for v14)

        You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

        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 12e3b064dc8..95a36a17554 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 @@ -199,7 +199,7 @@ def test_update_open_sale_order(self): it's open. """ purchase = self.purchase_company_a - sale = self._approve_po() + sale = self._approve_po(purchase) sale.action_confirm() # Now we add an extra product to the PO and it will show up in the SO po_form = Form(purchase) diff --git a/purchase_sale_stock_inter_company/models/res_company.py b/purchase_sale_stock_inter_company/models/res_company.py index 891e87d1f67..209febc800b 100644 --- a/purchase_sale_stock_inter_company/models/res_company.py +++ b/purchase_sale_stock_inter_company/models/res_company.py @@ -21,4 +21,3 @@ class ResCompany(models.Model): help="Sync the receipt from the destination company with the " "delivery from the source company", ) - diff --git a/purchase_sale_stock_inter_company/models/sale_order.py b/purchase_sale_stock_inter_company/models/sale_order.py index 36eed9803c2..c993dd272ed 100644 --- a/purchase_sale_stock_inter_company/models/sale_order.py +++ b/purchase_sale_stock_inter_company/models/sale_order.py @@ -3,7 +3,7 @@ # Copyright 2018-2019 Tecnativa - Carlos Dauden # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, api, fields, models +from odoo import _, api, models from odoo.exceptions import UserError @@ -11,14 +11,6 @@ class SaleOrder(models.Model): _inherit = "sale.order" def action_confirm(self): - for order in self.filtered("auto_purchase_order_id"): - po_company = order.sudo().auto_purchase_order_id.company_id - if not po_company.intercompany_overwrite_purchase_price: - order.assert_intercompany_prices_equal() - else: - for line in order.order_line.sudo(): - if line.auto_purchase_line_id: - line.auto_purchase_line_id.price_unit = line.price_unit res = super().action_confirm() for sale_order in self.sudo(): dest_company = sale_order.partner_id.ref_company_ids @@ -165,7 +157,7 @@ def _check_intercompany_product(self, dest_company): "is not intercompany" ) % sale_line.product_id.name - ) + ) from None def _inter_company_create_purchase_order(self, dest_company): """Create a Purchase Order from the current SO (self) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index c849406c79f..c66e266bb36 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 _, api, fields, models from odoo.exceptions import UserError @@ -29,16 +29,14 @@ def _action_done(self): 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": pick.name, - "product": move_line.product_id.name, - } + "There's no corresponding line in PO %(purchase)s for assigning " + "qty from %(picking)s for product %(product)s" ) + % { + "purchase": purchase.name, + "picking": pick.name, + "product": move_line.product_id.name, + } ) return super()._action_done() 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 37437a10ed5..e45724692f6 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 @@ -40,33 +40,33 @@ def setUpClass(cls): def test_deliver_to_warehouse_a(self): self.purchase_company_a.picking_type_id = self.warehouse_a.in_type_id - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(self.warehouse_a.partner_id, sale.partner_shipping_id) def test_deliver_to_warehouse_b(self): self.purchase_company_a.picking_type_id = self.warehouse_b.in_type_id - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(self.warehouse_b.partner_id, sale.partner_shipping_id) def test_send_from_warehouse_c(self): self.company_b.warehouse_id = self.warehouse_c - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(sale.warehouse_id, self.warehouse_c) def test_send_from_warehouse_d(self): self.company_b.warehouse_id = self.warehouse_d - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual(sale.warehouse_id, self.warehouse_d) def test_purchase_sale_stock_inter_company(self): self.purchase_company_a.notes = "Test note" - sale = self._approve_po() + sale = self._approve_po(self.purchase_company_a) self.assertEqual( sale.partner_shipping_id, self.purchase_company_a.picking_type_id.warehouse_id.partner_id, ) self.assertEqual(sale.warehouse_id, self.warehouse_c) - + def test_sync_picking(self): self.company_a.sync_picking = True self.company_b.sync_picking = True @@ -113,4 +113,4 @@ 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)) \ No newline at end of file + self.assertEqual(len(purchase.picking_ids), len(sale.picking_ids)) diff --git a/purchase_sale_stock_inter_company/views/res_config_view.xml b/purchase_sale_stock_inter_company/views/res_config_view.xml index b8c4635f256..5cf5189118d 100644 --- a/purchase_sale_stock_inter_company/views/res_config_view.xml +++ b/purchase_sale_stock_inter_company/views/res_config_view.xml @@ -17,11 +17,7 @@ /> - + From 7888e752876d1036830615f7ed72e68af3073053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20BEAU?= Date: Mon, 23 Oct 2023 14:45:56 +0200 Subject: [PATCH 09/18] purchase_sale_inter_company: fix validation of several picking. self can have more than one record --- .../models/stock_picking.py | 28 ++++++++----------- .../test_inter_company_purchase_sale_stock.py | 18 ++++++++++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index c66e266bb36..815f8ce5c51 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -42,24 +42,18 @@ def _action_done(self): def button_validate(self): res = super().button_validate() - is_intercompany = self.env["res.company"].search( - [("partner_id", "=", self.partner_id.id)] - ) or self.env["res.company"].search( - [("partner_id", "=", self.partner_id.parent_id.id)] - ) - if ( - is_intercompany - and self.company_id.sync_picking - and self.state == "done" - and self.picking_type_code == "outgoing" - ): - sale_order = self.sale_id - dest_company = sale_order.partner_id.ref_company_ids - for rec in self: - if rec.intercompany_picking_id: - rec._sync_receipt_with_delivery( + for record in self.sudo(): + dest_company = record.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: + record._sync_receipt_with_delivery( dest_company, - sale_order, + record.sale_id, ) return res 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 e45724692f6..8b27bcf5d54 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 @@ -66,6 +66,24 @@ def test_purchase_sale_stock_inter_company(self): self.purchase_company_a.picking_type_id.warehouse_id.partner_id, ) self.assertEqual(sale.warehouse_id, self.warehouse_c) + + def test_confirm_several_picking(self): + """ + Ensure that confirming several picking is not broken + """ + purchase_1 = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + purchase_2 = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + sale_1 = self._approve_po(purchase_1) + sale_2 = self._approve_po(purchase_2) + pickings = sale_1.picking_ids | sale_2.picking_ids + for move in pickings.move_lines: + move.quantity_done = move.product_uom_qty + pickings.button_validate() + self.assertEqual(pickings.mapped("state"), ["done", "done"]) def test_sync_picking(self): self.company_a.sync_picking = True From ad33607ac4619cbee93ce940af7f28a191da3986 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Mon, 26 Aug 2024 12:41:55 +0200 Subject: [PATCH 10/18] [MIG] purchase_sale_inter_company: fix validation of several picking. self can have more than one record, to v15 --- .../tests/test_inter_company_purchase_sale_stock.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8b27bcf5d54..15fa08420ec 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 @@ -66,7 +66,7 @@ def test_purchase_sale_stock_inter_company(self): self.purchase_company_a.picking_type_id.warehouse_id.partner_id, ) self.assertEqual(sale.warehouse_id, self.warehouse_c) - + def test_confirm_several_picking(self): """ Ensure that confirming several picking is not broken From bbb698a7c0768c848f771bc8bf82c91e994ad244 Mon Sep 17 00:00:00 2001 From: Alessandro Uffreduzzi Date: Wed, 6 Dec 2023 15:22:17 +0100 Subject: [PATCH 11/18] [FIX]purchase_sale_inter_company: handle pickings with lots/serials Previously an intercompany picking with tracked products would simply throw an error. With this fix, the method searches for a lot in the destination company that matches the one in the source company (same name and same product). A new lot is created by duplicating the original, if none is found. --- purchase_sale_inter_company/README.rst | 20 +++ purchase_sale_inter_company/__manifest__.py | 1 + .../models/res_company.py | 1 - .../models/res_config.py | 1 - .../readme/CONTRIBUTORS.rst | 5 + .../readme/DESCRIPTION.rst | 4 + .../static/description/index.html | 9 ++ .../tests/test_inter_company_purchase_sale.py | 2 +- .../models/stock_picking.py | 98 +++++++++++--- .../test_inter_company_purchase_sale_stock.py | 121 ++++++++++++++++++ 10 files changed, 243 insertions(+), 19 deletions(-) diff --git a/purchase_sale_inter_company/README.rst b/purchase_sale_inter_company/README.rst index f5fe0653af2..47f7f142dbf 100644 --- a/purchase_sale_inter_company/README.rst +++ b/purchase_sale_inter_company/README.rst @@ -37,6 +37,10 @@ Imagine you have company A and company B in the same Odoo database: * Company A will create a purchase order with company B as supplier. * This module automate the creation of the sale order in company B with company A as customer. +It allows to create a sale order in company A from a purchase order in company B, and keep delivery/receipt pickings synced, including backorders. + +When Company A sends a product tracked by lot or serial number, a new lot/serial number with the same name is created in Company B to match it, if one doesn't already exist. + **Table of contents** .. contents:: @@ -129,6 +133,11 @@ Contributors * `Camptocamp `: * Maksym Yankin + * Alessandro Uffreduzzi + +* Ooops404 + + * Francesco Foresti Maintainers ~~~~~~~~~~~ @@ -143,6 +152,17 @@ OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. +.. |maintainer-aleuffre| image:: https://github.com/aleuffre.png?size=40px + :target: https://github.com/aleuffre + :alt: aleuffre +.. |maintainer-renda-dev| image:: https://github.com/renda-dev.png?size=40px + :target: https://github.com/renda-dev + :alt: renda-dev + +Current `maintainers `__: + +|maintainer-aleuffre| |maintainer-renda-dev| + This module is part of the `OCA/multi-company `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/purchase_sale_inter_company/__manifest__.py b/purchase_sale_inter_company/__manifest__.py index bda6897b02d..b93563cacd4 100644 --- a/purchase_sale_inter_company/__manifest__.py +++ b/purchase_sale_inter_company/__manifest__.py @@ -10,6 +10,7 @@ "category": "Purchase Management", "website": "https://github.com/OCA/multi-company", "author": "Odoo SA, Akretion, Tecnativa, Odoo Community Association (OCA)", + "maintainers": ["aleuffre", "renda-dev"], "license": "AGPL-3", "installable": True, "depends": ["sale", "purchase", "account_invoice_inter_company"], diff --git a/purchase_sale_inter_company/models/res_company.py b/purchase_sale_inter_company/models/res_company.py index b974bf1504f..b9079fa559f 100644 --- a/purchase_sale_inter_company/models/res_company.py +++ b/purchase_sale_inter_company/models/res_company.py @@ -7,7 +7,6 @@ class ResCompany(models.Model): - _inherit = "res.company" so_from_po = fields.Boolean( diff --git a/purchase_sale_inter_company/models/res_config.py b/purchase_sale_inter_company/models/res_config.py index c6d18a1e384..b118af7553c 100644 --- a/purchase_sale_inter_company/models/res_config.py +++ b/purchase_sale_inter_company/models/res_config.py @@ -7,7 +7,6 @@ class InterCompanyRulesConfig(models.TransientModel): - _inherit = "res.config.settings" so_from_po = fields.Boolean( diff --git a/purchase_sale_inter_company/readme/CONTRIBUTORS.rst b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst index d34882913d6..604937a54d3 100644 --- a/purchase_sale_inter_company/readme/CONTRIBUTORS.rst +++ b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst @@ -15,3 +15,8 @@ * `Camptocamp `: * Maksym Yankin + * Alessandro Uffreduzzi + +* Ooops404 + + * Francesco Foresti diff --git a/purchase_sale_inter_company/readme/DESCRIPTION.rst b/purchase_sale_inter_company/readme/DESCRIPTION.rst index aa645c6dce7..bb89af5051d 100644 --- a/purchase_sale_inter_company/readme/DESCRIPTION.rst +++ b/purchase_sale_inter_company/readme/DESCRIPTION.rst @@ -6,3 +6,7 @@ Imagine you have company A and company B in the same Odoo database: * Company A purchase goods from company B. * Company A will create a purchase order with company B as supplier. * This module automate the creation of the sale order in company B with company A as customer. + +It allows to create a sale order in company A from a purchase order in company B, and keep delivery/receipt pickings synced, including backorders. + +When Company A sends a product tracked by lot or serial number, a new lot/serial number with the same name is created in Company B to match it, if one doesn't already exist. diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index 3ddc2e6eaaf..a1635478108 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -378,6 +378,8 @@

        Inter Company Module for Purchase to Sale Order

      • Company A will create a purchase order with company B as supplier.
      • This module automate the creation of the sale order in company B with company A as customer.
      +

      It allows to create a sale order in company A from a purchase order in company B, and keep delivery/receipt pickings synced, including backorders.

      +

      When Company A sends a product tracked by lot or serial number, a new lot/serial number with the same name is created in Company B to match it, if one doesn’t already exist.

      Table of contents

      @@ -483,6 +490,8 @@

      Maintainers

      OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use.

      +

      Current maintainers:

      +

      aleuffre renda-dev

      This module is part of the OCA/multi-company project on GitHub.

      You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

      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 95a36a17554..51b86057e9b 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 @@ -58,7 +58,7 @@ def setUpClass(cls): cls.consumable_product = cls.env["product.product"].create( { "name": "Consumable Product", - "type": "product", + "type": "consu", "categ_id": cls.env.ref("product.product_category_all").id, "qty_available": 100, } diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 815f8ce5c51..683182c469a 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 _, api, fields, models +from odoo import SUPERUSER_ID, _, api, fields, models from odoo.exceptions import UserError @@ -21,24 +21,90 @@ def _action_done(self): 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]}) - for move_line in pick.move_line_ids: - sale_line_id = move_line.move_id.sale_line_id - po_move_lines = sale_line_id.auto_purchase_line_id.move_ids.mapped( - "move_line_ids" + 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 not po_move_lines: - raise UserError( - _( - "There's no corresponding line in PO %(purchase)s for assigning " - "qty from %(picking)s for product %(product)s" + if len(move_lines) != len(po_move_lines): + note = ( + "Mismatch between move lines with the " + "corresponding PO %(purchase)s for assigning " + "quantities and lots from %(picking)s for product %(product)s" + ) % { + "purchase": purchase.name, + "picking": pick.name, + "product": 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, + ), + ) + # 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, ) - % { - "purchase": purchase.name, - "picking": pick.name, - "product": move_line.product_id.name, - } ) - return super()._action_done() + 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): + self.ensure_one() + note = _( + "Failure to confirm picking for PO %(purchase)s. " + "Original picking %(stock)s still confirmed, please check " + "the other side manually." + ) % {"purchase": purchase.name, "stock": self.name} + self.activity_schedule( + "mail.mail_activity_data_warning", + fields.Date.today(), + note=note, + # Try to notify someone relevant + user_id=( + 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, + ), + ) def button_validate(self): res = super().button_validate() 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 15fa08420ec..7ef3f7acf31 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 @@ -22,10 +22,27 @@ def _create_warehouse(cls, code, company): "company_id": company.id, } ) + + @classmethod + def _create_serial_and_quant(cls, product, name, company): + lot = cls.lot_obj.create( + {"product_id": product.id, "name": name, "company_id": company.id} + ) + cls.quant_obj.create( + { + "product_id": product.id, + "location_id": cls.warehouse_a.lot_stock_id.id, + "quantity": 1, + "lot_id": lot.id, + } + ) + return lot @classmethod def setUpClass(cls): super().setUpClass() + cls.lot_obj = cls.env["stock.production.lot"] + cls.quant_obj = cls.env["stock.quant"] # Configure 2 Warehouse per company cls.warehouse_a = cls.env["stock.warehouse"].search( [("company_id", "=", cls.company_a.id)] @@ -37,6 +54,24 @@ def setUpClass(cls): ) cls.warehouse_d = cls._create_warehouse("CB-WD", cls.company_b) cls.company_b.warehouse_id = cls.warehouse_c + cls.stockable_product_serial = cls.env["product.product"].create( + { + "name": "Stockable Product Tracked by Serial", + "type": "product", + "tracking": "serial", + "categ_id": cls.env.ref("product.product_category_all").id, + } + ) + # Add quants for product tracked by serial to supplier + cls.serial_1 = cls._create_serial_and_quant( + cls.stockable_product_serial, "111", cls.company_b + ) + cls.serial_2 = cls._create_serial_and_quant( + cls.stockable_product_serial, "222", cls.company_b + ) + cls.serial_3 = cls._create_serial_and_quant( + cls.stockable_product_serial, "333", cls.company_b + ) def test_deliver_to_warehouse_a(self): self.purchase_company_a.picking_type_id = self.warehouse_a.in_type_id @@ -132,3 +167,89 @@ 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)) + + 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 + """ + # lot 3 already exists in company_a + serial_3_company_a = self._create_serial_and_quant( + self.stockable_product_serial, "333", self.company_a + ) + self.company_a.sync_picking = True + self.company_b.sync_picking = True + + purchase = self._create_purchase_order( + self.partner_company_b, self.stockable_product_serial + ) + sale = self._approve_po(purchase) + + # validate the SO picking + po_picking_id = purchase.picking_ids + so_picking_id = sale.picking_ids + + so_move = so_picking_id.move_lines + so_move.move_line_ids = [ + ( + 0, + 0, + { + "location_id": so_move.location_id.id, + "location_dest_id": so_move.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_move.location_id.id, + "location_dest_id": so_move.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, + "picking_id": so_picking_id.id, + }, + ), + ( + 0, + 0, + { + "location_id": so_move.location_id.id, + "location_dest_id": so_move.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, + }, + ), + ] + so_picking_id.button_validate() + + 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", + ) + self.assertIn( + serial_3_company_a, + po_lots, + msg="Serial 333 already existed, a new one shouldn't have been created", + ) From ff8af96d082b2af57084a6376f40bbbef3480f75 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 27 Dec 2023 10:18:09 +0100 Subject: [PATCH 12/18] [FIX] purchase_sale_inter_company: sync confirmed unlocked purchase updates When the purchase user updates the PO after the SO is generated and always if both are confirmed and unlocked, we sync the new lines to update the demand. TT46773 --- .../static/description/index.html | 1 + .../models/stock_picking.py | 40 +++------ .../test_inter_company_purchase_sale_stock.py | 87 +++++++++++++++++++ 3 files changed, 100 insertions(+), 28 deletions(-) diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index a1635478108..736cdc3b3ee 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -1,3 +1,4 @@ + diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 683182c469a..91f8ad69fa4 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -36,16 +36,19 @@ def _action_done_intercompany_actions(self, purchase): == ic_pick ).mapped("move_line_ids") ) - if len(move_lines) != len(po_move_lines): - note = ( + if not len(move_lines) == len(po_move_lines): + note = _( "Mismatch between move lines with the " - "corresponding PO %(purchase)s for assigning " - "quantities and lots from %(picking)s for product %(product)s" - ) % { - "purchase": purchase.name, - "picking": pick.name, - "product": move.product_id.name, - } + "corresponding PO %s for assigning " + "quantities and lots from %s for product %s" + ) % (purchase.name, pick.name, move.product_id.name) + # Configurable parameter so we don't lock the picking validation + if ( + not self.env["ir.config_parameter"] + .sudo() + .get_param("purchase_sale_inter_company.soft_picking_mismatch") + ): + raise UserError(note) self.activity_schedule( "mail.mail_activity_data_warning", fields.Date.today(), @@ -123,25 +126,6 @@ def button_validate(self): ) return res - @api.model - def _prepare_picking_line_data(self, src_picking, dest_picking): - self.ensure_one() - if self.check_all_done(src_picking): - for line in src_picking.sudo().move_ids_without_package: - line.write({"quantity_done": line.reserved_availability}) - for src_line in src_picking.sudo().move_ids_without_package: - if ( - src_line.product_id - in dest_picking.sudo().move_ids_without_package.mapped("product_id") - and src_line.quantity_done > 0 - ): - dest_move = dest_picking.sudo().move_ids_without_package.filtered( - lambda m: m.product_id == src_line.product_id - ) - dest_move.write( - {"quantity_done": dest_move.quantity_done + src_line.quantity_done} - ) - def _sync_receipt_with_delivery(self, dest_company, sale_order): self.ensure_one() intercompany_user = dest_company.intercompany_sale_user_id 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 7ef3f7acf31..a57c56c5aed 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 @@ -3,7 +3,9 @@ # Copyright 2018-2019 Tecnativa - Carlos Dauden # Copyright 2020 ForgeFlow S.L. (https://www.forgeflow.com) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import re +from odoo.tests.common import Form from odoo.addons.purchase_sale_inter_company.tests.test_inter_company_purchase_sale import ( TestPurchaseSaleInterCompany, @@ -54,6 +56,12 @@ def setUpClass(cls): ) cls.warehouse_d = cls._create_warehouse("CB-WD", cls.company_b) cls.company_b.warehouse_id = cls.warehouse_c + cls.consumable_product_2 = cls.env["product.product"].create( + { + "name": "Consumable Product 2", + "type": "consu", + } + ) cls.stockable_product_serial = cls.env["product.product"].create( { "name": "Stockable Product Tracked by Serial", @@ -253,3 +261,82 @@ def test_sync_picking_lot(self): po_lots, msg="Serial 333 already existed, a new one shouldn't have been created", ) + + def test_update_open_sale_order(self): + """ + When the purchase user request extra product, the sale order gets synched if + it's open. + """ + purchase = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + purchase.picking_type_id = self.warehouse_a.in_type_id + sale = self._approve_po(purchase) + sale.action_confirm() + # Now we add an extra product to the PO and it will show up in the SO + po_form = Form(purchase) + with po_form.order_line.new() as line: + line.product_id = self.consumable_product_2 + line.product_qty = 6 + po_form.save() + # It's synched and the values match + synched_order_line = sale.order_line.filtered( + lambda x: x.product_id == self.consumable_product_2 + ) + self.assertTrue( + bool(synched_order_line), + "The line should have been created in the sale order", + ) + self.assertEqual( + synched_order_line.product_uom_qty, + 6, + "The quantity should be equal to the one set in the purchase order", + ) + # Also the moves match as well + so_picking_id = sale.picking_ids + synched_move = so_picking_id.move_lines.filtered( + lambda x: x.product_id == self.consumable_product_2 + ) + self.assertTrue( + bool(synched_move), + "The move should have been created in the delivery order", + ) + self.assertEqual( + synched_move.product_uom_qty, + 6, + "The quantity should be equal to the one set in the purchase order", + ) + # The quantity is synched as well + purchase_line = purchase.order_line.filtered( + lambda x: x.product_id == self.consumable_product_2 + ).sudo() + purchase_line.product_qty = 8 + self.assertEqual( + synched_order_line.product_uom_qty, + 8, + "The quantity should be equal to the one set in the purchase order", + ) + self.assertEqual( + synched_move.product_uom_qty, + 8, + "The quantity should synched to the one set in the purchase order", + ) + # Let's decrease the quantity + purchase_line.product_qty = 3 + self.assertEqual( + synched_order_line.product_uom_qty, + 3, + "The quantity should decrease as it was in the purchase order", + ) + self.assertEqual( + synched_move.product_uom_qty, + 8, + "The quantity should remain as it was as it can't be decreased from the SO", + ) + # A warning activity is scheduled in the picking + self.assertRegex( + so_picking_id.activity_ids.note, + re.compile( + "3.0 Units of Consumable Product 2.+instead of 8.0 Units", re.DOTALL + ), + ) From 37ca8d137c7ab763b14e7b58d2951f2f5f188003 Mon Sep 17 00:00:00 2001 From: Alessio Renda Date: Wed, 27 Dec 2023 12:42:09 +0100 Subject: [PATCH 13/18] [IMP] purchase_sale_inter_company: manage and sync state --- .../tests/test_inter_company_purchase_sale.py | 1 + .../models/stock_picking.py | 21 +++++++++++++++++++ .../test_inter_company_purchase_sale_stock.py | 8 +++++-- 3 files changed, 28 insertions(+), 2 deletions(-) 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 51b86057e9b..ca80101fde9 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 @@ -199,6 +199,7 @@ def test_update_open_sale_order(self): it's open. """ purchase = self.purchase_company_a + sale = self._approve_po(purchase) sale.action_confirm() # Now we add an extra product to the PO and it will show up in the SO diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 91f8ad69fa4..524462676cb 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -11,6 +11,27 @@ class StockPicking(models.Model): intercompany_picking_id = fields.Many2one(comodel_name="stock.picking", copy=False) + @api.depends("intercompany_picking_id.state") + def _compute_state(self): + """ + If the picking is inter-company, it's an 'incoming' + type of picking, and it has not been validated nor canceled + we compute it's state based on the other picking state + """ + res = super()._compute_state() + for picking in self: + if ( + picking.intercompany_picking_id + and picking.picking_type_code == "incoming" + and picking.state not in ["done", "cancel"] + ): + if picking.intercompany_picking_id.state in ["confirmed", "assigned"]: + picking.state = "waiting" + else: + picking.state = picking.intercompany_picking_id.state + + return res + def _action_done(self): for pick in self.filtered( lambda x: x.location_dest_id.usage == "customer" 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 a57c56c5aed..858683f957d 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 @@ -140,10 +140,13 @@ def test_sync_picking(self): self.assertTrue(purchase.picking_ids) self.assertTrue(sale.picking_ids) - # validate the SO picking po_picking_id = purchase.picking_ids so_picking_id = sale.picking_ids + + # check po_picking state + self.assertEqual(po_picking_id.state, "waiting") + # validate the SO picking so_picking_id.move_lines.quantity_done = 2 self.assertNotEqual(po_picking_id, so_picking_id) @@ -171,7 +174,8 @@ def test_sync_picking(self): po_picking_id.move_lines.quantity_done, so_picking_id.move_lines.quantity_done, ) - + # Check picking state + self.assertEqual(po_picking_id.state, so_picking_id.state) # 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)) From 2dc80523e4b1b9fc06b20ae13cbaa3668323d3c8 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Wed, 28 Aug 2024 11:01:03 +0200 Subject: [PATCH 14/18] [MIG][IMP] purchase_sale_inter_company: manage and sync state to v15 --- purchase_sale_inter_company/static/description/index.html | 1 - purchase_sale_stock_inter_company/models/stock_picking.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index 736cdc3b3ee..a1635478108 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -1,4 +1,3 @@ - diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 524462676cb..344aea57813 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -10,6 +10,7 @@ class StockPicking(models.Model): _inherit = "stock.picking" intercompany_picking_id = fields.Many2one(comodel_name="stock.picking", copy=False) + state = fields.Selection(recursive=True) @api.depends("intercompany_picking_id.state") def _compute_state(self): From 76115e27bfd15a637a6da79d2ccb7d9c3530d2b7 Mon Sep 17 00:00:00 2001 From: ntsirintanis Date: Wed, 28 Aug 2024 11:09:39 +0200 Subject: [PATCH 15/18] fixup! [FIX]purchase_sale_inter_company: handle pickings with lots/serials --- .../models/stock_picking.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 344aea57813..f6e77a5ed63 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -61,9 +61,13 @@ def _action_done_intercompany_actions(self, purchase): if not 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) + "corresponding PO %(purchase)s for assigning " + "quantities and lots from %(picking)s for product %(product)s" + ) % { + "purchase": purchase.name, + "picking": pick.name, + "product": move.product_id.name, + } # Configurable parameter so we don't lock the picking validation if ( not self.env["ir.config_parameter"] From 4c79a409f1b2501c5fb8485497f5e8719a984db5 Mon Sep 17 00:00:00 2001 From: Alessio Renda Date: Mon, 18 Mar 2024 15:33:49 +0100 Subject: [PATCH 16/18] [IMP] purchase_sale_inter_company: remove user error on lines mismatch --- .../models/stock_picking.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index f6e77a5ed63..6c508997cc1 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -58,7 +58,7 @@ def _action_done_intercompany_actions(self, purchase): == ic_pick ).mapped("move_line_ids") ) - if not len(move_lines) == len(po_move_lines): + if len(move_lines) != len(po_move_lines): note = _( "Mismatch between move lines with the " "corresponding PO %(purchase)s for assigning " @@ -68,13 +68,6 @@ def _action_done_intercompany_actions(self, purchase): "picking": pick.name, "product": move.product_id.name, } - # Configurable parameter so we don't lock the picking validation - if ( - not self.env["ir.config_parameter"] - .sudo() - .get_param("purchase_sale_inter_company.soft_picking_mismatch") - ): - raise UserError(note) self.activity_schedule( "mail.mail_activity_data_warning", fields.Date.today(), From 3e67891f8439bc9f5199189d9f572abae293f933 Mon Sep 17 00:00:00 2001 From: Eduard Brahas Date: Mon, 25 Mar 2024 18:19:44 +0100 Subject: [PATCH 17/18] [IMP]purchase_sale_inter_company: lock incoming picking validation if intercompany transfer --- purchase_sale_inter_company/README.rst | 1 + .../readme/CONFIGURATION.rst | 14 ++++++ .../readme/CONTRIBUTORS.rst | 1 + .../static/description/index.html | 2 + .../tests/test_inter_company_purchase_sale.py | 46 ------------------- .../models/res_company.py | 7 +++ .../models/res_config.py | 4 ++ .../models/stock_picking.py | 13 ++++++ .../test_inter_company_purchase_sale_stock.py | 21 +++++++++ .../views/res_config_view.xml | 17 ++++++- 10 files changed, 79 insertions(+), 47 deletions(-) create mode 100644 purchase_sale_inter_company/readme/CONFIGURATION.rst diff --git a/purchase_sale_inter_company/README.rst b/purchase_sale_inter_company/README.rst index 47f7f142dbf..57bc964e26a 100644 --- a/purchase_sale_inter_company/README.rst +++ b/purchase_sale_inter_company/README.rst @@ -138,6 +138,7 @@ Contributors * Ooops404 * Francesco Foresti + * Eduard Brahas Maintainers ~~~~~~~~~~~ diff --git a/purchase_sale_inter_company/readme/CONFIGURATION.rst b/purchase_sale_inter_company/readme/CONFIGURATION.rst new file mode 100644 index 00000000000..962a264e1d8 --- /dev/null +++ b/purchase_sale_inter_company/readme/CONFIGURATION.rst @@ -0,0 +1,14 @@ +To configure this module, you need to: + + +#. Go to the menu Settings > General Settings > Multi-Company and enable ​Sale from purchase + +#. Select the "Warehouse for Sale Orders": it is the warehouse that will be used in the Sale Orders generated from POs coming from other companies.. + +#. Select the "Intercompany Sale User": is it the user that will be used as creator of the Sale Orders generated from POs coming from other companies. + +#. If you check the option "Sale Orders Auto Validation" in the configuration of company B, when you validate a Purchase Order in company A with company B as supplier, the Sale Order will be automatically validated in company B with company A as customer. + +#. If you check the option "Sync picking" (for both companies), validating a picking generated by an inter-company purchase/sale will validate the respective picking for the other company. + +#. If you check the option "Block manual validation of picking in the destination company" is not possible to validate manually the picking in the destination company. diff --git a/purchase_sale_inter_company/readme/CONTRIBUTORS.rst b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst index 604937a54d3..822c430a54d 100644 --- a/purchase_sale_inter_company/readme/CONTRIBUTORS.rst +++ b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst @@ -20,3 +20,4 @@ * Ooops404 * Francesco Foresti + * Eduard Brahas diff --git a/purchase_sale_inter_company/static/description/index.html b/purchase_sale_inter_company/static/description/index.html index a1635478108..874f97e616f 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -1,3 +1,4 @@ + @@ -477,6 +478,7 @@

      Contributors

    • Ooops404 <info@ooops404.com>
    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 ca80101fde9..be7b336d3ca 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 @@ -192,49 +192,3 @@ def test_po_with_contact_as_partner(self): self.assertEqual(len(sale), 1) self.assertEqual(sale.state, "sale") self.assertEqual(sale.partner_id, self.partner_company_a) - - def test_update_open_sale_order(self): - """ - When the purchase user request extra product, the sale order gets synched if - it's open. - """ - purchase = self.purchase_company_a - - sale = self._approve_po(purchase) - sale.action_confirm() - # Now we add an extra product to the PO and it will show up in the SO - po_form = Form(purchase) - with po_form.order_line.new() as line: - line.product_id = self.service_product_2 - line.product_qty = 6 - po_form.save() - # It's synched and the values match - synched_order_line = sale.order_line.filtered( - lambda x: x.product_id == self.service_product_2 - ) - self.assertTrue( - bool(synched_order_line), - "The line should have been created in the sale order", - ) - self.assertEqual( - synched_order_line.product_uom_qty, - 6, - "The quantity should be equal to the one set in the purchase order", - ) - # The quantity is synched as well - purchase_line = purchase.order_line.filtered( - lambda x: x.product_id == self.service_product_2 - ).sudo() - purchase_line.product_qty = 8 - self.assertEqual( - synched_order_line.product_uom_qty, - 8, - "The quantity should be equal to the one set in the purchase order", - ) - # Let's decrease the quantity - purchase_line.product_qty = 3 - self.assertEqual( - synched_order_line.product_uom_qty, - 3, - "The quantity should decrease as it was in the purchase order", - ) diff --git a/purchase_sale_stock_inter_company/models/res_company.py b/purchase_sale_stock_inter_company/models/res_company.py index 209febc800b..9e1b9800420 100644 --- a/purchase_sale_stock_inter_company/models/res_company.py +++ b/purchase_sale_stock_inter_company/models/res_company.py @@ -5,6 +5,10 @@ from odoo import fields, models +SELECTION_SYNC_FAILURE_ACTIONS = [ + ("raise", "Block and raise error"), + ("notify", "Continue, but create activity to notify someone"), +] class ResCompany(models.Model): @@ -21,3 +25,6 @@ class ResCompany(models.Model): help="Sync the receipt from the destination company with the " "delivery from the source company", ) + block_po_manual_picking_validation = fields.Boolean( + string="Block manual validation of picking in the destination company", + ) diff --git a/purchase_sale_stock_inter_company/models/res_config.py b/purchase_sale_stock_inter_company/models/res_config.py index bea6698d926..adb567953f1 100644 --- a/purchase_sale_stock_inter_company/models/res_config.py +++ b/purchase_sale_stock_inter_company/models/res_config.py @@ -25,3 +25,7 @@ class InterCompanyRulesConfig(models.TransientModel): "the delivery from the source company", readonly=False, ) + block_po_manual_picking_validation = fields.Boolean( + related="company_id.block_po_manual_picking_validation", + readonly=False, + ) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index 6c508997cc1..b02ee124291 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -143,6 +143,19 @@ def button_validate(self): dest_company, record.sale_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: + for record in self: + dest_company = record.partner_id.commercial_partner_id.ref_company_ids + if ( + dest_company and record.picking_type_code == "incoming" + ) and record.state in ["done", "waiting", "assigned"]: + raise UserError( + _( + "Manual validation of the picking is not allowed" + " in the destination company." + ) + ) return res def _sync_receipt_with_delivery(self, dest_company, sale_order): 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 858683f957d..b27fbdc3a1b 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 @@ -6,6 +6,7 @@ import re from odoo.tests.common import Form +from odoo.exceptions import UserError from odoo.addons.purchase_sale_inter_company.tests.test_inter_company_purchase_sale import ( TestPurchaseSaleInterCompany, @@ -344,3 +345,23 @@ def test_update_open_sale_order(self): "3.0 Units of Consumable Product 2.+instead of 8.0 Units", re.DOTALL ), ) + + def test_block_manual_validation(self): + """ + Test that the manual validation of the picking is blocked + when the flag is set in the destination company + """ + self.company_a.sync_picking = True + self.company_b.sync_picking = True + self.company_a.block_po_manual_picking_validation = True + self.company_b.block_po_manual_picking_validation = True + purchase = self._create_purchase_order( + self.partner_company_b, self.consumable_product + ) + purchase.button_confirm() + po_picking_id = purchase.picking_ids + # The picking should be in waiting state + self.assertEqual(po_picking_id.state, "waiting") + # The manual validation should be blocked + with self.assertRaises(UserError): + po_picking_id.with_user(self.user_company_a).button_validate() diff --git a/purchase_sale_stock_inter_company/views/res_config_view.xml b/purchase_sale_stock_inter_company/views/res_config_view.xml index 5cf5189118d..fe0d44c4d2d 100644 --- a/purchase_sale_stock_inter_company/views/res_config_view.xml +++ b/purchase_sale_stock_inter_company/views/res_config_view.xml @@ -18,7 +18,22 @@ - From bb403c1ac6bf8a86f7182b46babe5a93705c8612 Mon Sep 17 00:00:00 2001 From: Tom Blauwendraat Date: Thu, 4 Jul 2024 21:58:00 +0200 Subject: [PATCH 18/18] [IMP] purchase_sale_inter_company: Activity instead of hard raise A number of things can go wrong in trying to confirm the PO picking when the SO picking is confirmed. Instead of raising an error and thereby blocking the confirm of the SO picking, provide an option by which the exception can be caught. The SO picking will still be confirmed, but an activity will be posted for someone to deal with the PO picking manually. --- .../models/res_company.py | 1 + .../models/res_company.py | 7 ++ .../models/res_config.py | 10 +++ .../models/stock_picking.py | 17 +++-- .../test_inter_company_purchase_sale_stock.py | 72 +++++++++++++++++++ .../views/res_config_view.xml | 18 +++++ 6 files changed, 121 insertions(+), 4 deletions(-) diff --git a/purchase_sale_inter_company/models/res_company.py b/purchase_sale_inter_company/models/res_company.py index b9079fa559f..6388441434f 100644 --- a/purchase_sale_inter_company/models/res_company.py +++ b/purchase_sale_inter_company/models/res_company.py @@ -6,6 +6,7 @@ from odoo import fields, models + class ResCompany(models.Model): _inherit = "res.company" diff --git a/purchase_sale_stock_inter_company/models/res_company.py b/purchase_sale_stock_inter_company/models/res_company.py index 9e1b9800420..40379b99651 100644 --- a/purchase_sale_stock_inter_company/models/res_company.py +++ b/purchase_sale_stock_inter_company/models/res_company.py @@ -25,6 +25,13 @@ class ResCompany(models.Model): help="Sync the receipt from the destination company with the " "delivery from the source company", ) + sync_picking_failure_action = fields.Selection( + SELECTION_SYNC_FAILURE_ACTIONS, + string="On sync picking failure", + default="raise", + help="Pick action to perform on sync picking failure", + ) 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") diff --git a/purchase_sale_stock_inter_company/models/res_config.py b/purchase_sale_stock_inter_company/models/res_config.py index adb567953f1..6db411d9196 100644 --- a/purchase_sale_stock_inter_company/models/res_config.py +++ b/purchase_sale_stock_inter_company/models/res_config.py @@ -25,7 +25,17 @@ class InterCompanyRulesConfig(models.TransientModel): "the delivery from the source company", readonly=False, ) + sync_picking_failure_action = fields.Selection( + related="company_id.sync_picking_failure_action", + readonly=False, + ) block_po_manual_picking_validation = fields.Boolean( related="company_id.block_po_manual_picking_validation", readonly=False, ) + notify_user_id = fields.Many2one( + "res.users", + related="company_id.notify_user_id", + help="User to notify incase of sync picking failure.", + readonly=False, + ) diff --git a/purchase_sale_stock_inter_company/models/stock_picking.py b/purchase_sale_stock_inter_company/models/stock_picking.py index b02ee124291..74fbeaf04e4 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -139,10 +139,19 @@ def button_validate(self): and record.picking_type_code == "outgoing" ): if record.intercompany_picking_id: - record._sync_receipt_with_delivery( - dest_company, - record.sale_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: for record in self: 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 b27fbdc3a1b..4f812baae76 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 @@ -365,3 +365,75 @@ def test_block_manual_validation(self): # The manual validation should be blocked with self.assertRaises(UserError): po_picking_id.with_user(self.user_company_a).button_validate() + + def test_notify_picking_problem(self): + self.company_a.sync_picking = True + self.company_b.sync_picking = True + 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 + self.company_b.notify_user_id = self.user_company_b + + 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 + + # 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() + + # Test that picking has an activity now + self.assertTrue(len(so_picking_id.activity_ids) > 0) + 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 + ) + self.assertEqual(len(warning_activity), 1) + + # Test the user assigned to the activity + self.assertEqual( + warning_activity.user_id, so_picking_id.company_id.notify_user_id + ) + + def test_raise_picking_problem(self): + self.company_a.sync_picking = True + self.company_b.sync_picking = True + 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.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 + + # 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 + with self.assertRaises(UserError): + so_picking_id.button_validate() diff --git a/purchase_sale_stock_inter_company/views/res_config_view.xml b/purchase_sale_stock_inter_company/views/res_config_view.xml index fe0d44c4d2d..e7b2875fb8f 100644 --- a/purchase_sale_stock_inter_company/views/res_config_view.xml +++ b/purchase_sale_stock_inter_company/views/res_config_view.xml @@ -24,6 +24,24 @@ for="sync_picking" />
    + +