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..cb5cfd760e2 100644 --- a/account_invoice_inter_company/tests/test_inter_company_invoice.py +++ b/account_invoice_inter_company/tests/test_inter_company_invoice.py @@ -31,10 +31,7 @@ def setUpClass(cls): } ) 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.partner_company_a = cls.company_a.partner_id cls.company_b = cls.env["res.company"].create( { "name": "Company B", @@ -45,9 +42,7 @@ def setUpClass(cls): } ) 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.partner_company_b = cls.company_b.partner_id cls.child_partner_company_b = cls.env["res.partner"].create( { "name": "Child, Company B", @@ -56,7 +51,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( { diff --git a/purchase_sale_inter_company/README.rst b/purchase_sale_inter_company/README.rst index 571177899af..57bc964e26a 100644 --- a/purchase_sale_inter_company/README.rst +++ b/purchase_sale_inter_company/README.rst @@ -37,11 +37,29 @@ 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:: :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 +74,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 ====================== @@ -105,6 +133,12 @@ Contributors * `Camptocamp `: * Maksym Yankin + * Alessandro Uffreduzzi + +* Ooops404 + + * Francesco Foresti + * Eduard Brahas Maintainers ~~~~~~~~~~~ @@ -119,6 +153,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..6388441434f 100644 --- a/purchase_sale_inter_company/models/res_company.py +++ b/purchase_sale_inter_company/models/res_company.py @@ -6,8 +6,8 @@ from odoo import fields, models -class ResCompany(models.Model): +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/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/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/CONTRIBUTORS.rst b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst index d34882913d6..822c430a54d 100644 --- a/purchase_sale_inter_company/readme/CONTRIBUTORS.rst +++ b/purchase_sale_inter_company/readme/CONTRIBUTORS.rst @@ -15,3 +15,9 @@ * `Camptocamp `: * Maksym Yankin + * Alessandro Uffreduzzi + +* Ooops404 + + * Francesco Foresti + * Eduard Brahas 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/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..874f97e616f 100644 --- a/purchase_sale_inter_company/static/description/index.html +++ b/purchase_sale_inter_company/static/description/index.html @@ -1,3 +1,4 @@ + @@ -8,10 +9,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 +276,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 +302,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -377,35 +379,53 @@

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

    +
    +

    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 @@ -415,7 +435,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 @@ -423,9 +443,9 @@

    Bug Tracker

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

    -

    Credits

    +

    Credits

    -

    Authors

    +

    Authors

    • Odoo SA
    • Akretion
    • @@ -433,7 +453,7 @@

      Authors

    -

    Contributors

    +

    Contributors

    -

    Maintainers

    +

    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.

    +

    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 9b0d4cd8a88..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 @@ -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": "consu", + "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,52 +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) - - 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() - 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/__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/__init__.py b/purchase_sale_stock_inter_company/models/__init__.py index fd0b2e801f8..50bb11730d8 100644 --- a/purchase_sale_stock_inter_company/models/__init__.py +++ b/purchase_sale_stock_inter_company/models/__init__.py @@ -1,4 +1,6 @@ from . import purchase_order 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/res_company.py b/purchase_sale_stock_inter_company/models/res_company.py index 2ee88f5b1a2..40379b99651 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): @@ -16,3 +20,18 @@ 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", + ) + 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 d1fad81c6a9..6db411d9196 100644 --- a/purchase_sale_stock_inter_company/models/res_config.py +++ b/purchase_sale_stock_inter_company/models/res_config.py @@ -18,3 +18,24 @@ 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, + ) + 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/sale_order.py b/purchase_sale_stock_inter_company/models/sale_order.py new file mode 100644 index 00000000000..c993dd272ed --- /dev/null +++ b/purchase_sale_stock_inter_company/models/sale_order.py @@ -0,0 +1,249 @@ +# 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, models +from odoo.exceptions import UserError + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + def action_confirm(self): + res = super().action_confirm() + for sale_order in self.sudo(): + dest_company = sale_order.partner_id.ref_company_ids + if ( + sale_order.auto_purchase_order_id + and dest_company + and dest_company.sync_picking + ): + pickings = sale_order.picking_ids + po_company = sale_order.sudo().auto_purchase_order_id.company_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: + # 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 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"].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"].with_user( + po_company.intercompany_sale_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.with_user( + po_company.intercompany_sale_user_id.id + ).copy( + { + "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, + } + ) + 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.with_user( + po_company.intercompany_sale_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.with_user( + purchase_picking.company_id.intercompany_sale_user_id.id + ).write( + { + "intercompany_picking_id": pick.id, + "note": pick.note, + "create_date": pick.create_date, + "state": pick.state, + } + ) + new_pick = purchase_picking + else: + new_pick = purchase_picking.with_user( + po_company.intercompany_sale_user_id.id + ).copy( + { + "move_ids_without_package": [ + (6, False, new_moves.ids) + ], + "move_line_ids_without_package": [ + (6, False, new_move_lines.ids) + ], + "intercompany_picking_id": pick.id, + "note": pick.note, + "create_date": pick.create_date, + "state": pick.state, + } + ) + 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 + + 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 + ) from None + + 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_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 ee0a030b3da..74fbeaf04e4 100644 --- a/purchase_sale_stock_inter_company/models/stock_picking.py +++ b/purchase_sale_stock_inter_company/models/stock_picking.py @@ -2,18 +2,38 @@ # Copyright 2018 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, fields, models +from odoo import SUPERUSER_ID, _, 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) + state = fields.Selection(recursive=True) - def action_done(self): - # Only DropShip pickings - po_picks = self.browse() + @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" ).sudo(): @@ -21,37 +41,168 @@ def action_done(self): if not purchase: continue purchase.picking_ids.write({"intercompany_picking_id": pick.id}) - 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" + if not pick.intercompany_picking_id and purchase.picking_ids[0]: + pick.write({"intercompany_picking_id": purchase.picking_ids[0]}) + pick._action_done_intercompany_actions(purchase) + return super()._action_done() + + def _action_done_intercompany_actions(self, purchase): + self.ensure_one() + try: + pick = self + for move in pick.move_lines: + move_lines = move.move_line_ids + po_move_lines = ( + move.sale_line_id.auto_purchase_line_id.move_ids.filtered( + lambda x, ic_pick=pick.intercompany_picking_id: x.picking_id + == ic_pick + ).mapped("move_line_ids") ) - 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 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, + ) + ) + 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() + 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: + 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: + 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( _( - "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, - } + "Manual validation of the picking is not allowed" + " in the destination company." ) ) - # 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 res + + def _sync_receipt_with_delivery(self, dest_company, sale_order): + self.ensure_one() + intercompany_user = dest_company.intercompany_sale_user_id + purchase_order = sale_order.auto_purchase_order_id.sudo() + if not (purchase_order and purchase_order.picking_ids): + raise UserError(_("PO does not exist or has no receipts")) + if self.intercompany_picking_id: + dest_picking = self.intercompany_picking_id.with_user(intercompany_user.id) + 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 + ) + ) + dest_picking_move.sudo().write( + { + "quantity_done": picking_move.quantity_done, + } + ) + dest_picking.sudo().with_context( + cancel_backorder=bool( + self.env.context.get("picking_ids_not_to_backorder") + ) + )._action_done() + + def _update_extra_data_in_picking(self, picking): + if hasattr(self, "_cal_weight"): # from delivery module + self._cal_weight() diff --git a/purchase_sale_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.

    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..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 @@ -3,7 +3,10 @@ # 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.exceptions import UserError from odoo.addons.purchase_sale_inter_company.tests.test_inter_company_purchase_sale import ( TestPurchaseSaleInterCompany, @@ -22,10 +25,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,32 +57,383 @@ 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", + "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 - 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_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 + 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) + + 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) + 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, + ) + # 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)) + + 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", + ) + + 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 + ), + ) + + 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() + + 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 f6445f26df7..e7b2875fb8f 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,43 @@ 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)]} + + + +