diff --git a/purchase_order_line_merge/README.rst b/purchase_order_line_merge/README.rst new file mode 100644 index 00000000000..cc9eb57fac8 --- /dev/null +++ b/purchase_order_line_merge/README.rst @@ -0,0 +1,121 @@ +========================= +Purchase Order Line Merge +========================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:25882d6ab121930b28d392f5c1a2f6172ff84d9a2a703fff95dd56a01278ec45 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fpurchase--workflow-lightgray.png?logo=github + :target: https://github.com/OCA/purchase-workflow/tree/18.0/purchase_order_line_merge + :alt: OCA/purchase-workflow +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/purchase-workflow-18-0/purchase-workflow-18-0-purchase_order_line_merge + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/purchase-workflow&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows users to select multiple purchase order lines from +different purchase orders and merge them into a single new purchase +order. + +Unlike ``purchase_merge`` which works at the order level, this module +operates at the line level, giving more granular control over which +specific lines to consolidate. + +Key features: + +- Lines with the same product, unit price, unit of measure, and taxes + are automatically grouped into a single line on the resulting + purchase order. +- The default quantity to merge considers both invoiced and received + amounts, defaulting to the available (uninvoiced/unreceived) + quantity. +- The unit price is editable in the wizard, allowing price adjustments + before creating the new order. +- Partial merges are supported: the remaining quantity stays on the + original order. +- Orders that reach zero amount after the merge are automatically + cancelled. + +This module also adds the **Qty Invoiced** and **Qty Received** columns +to the Purchase Order Lines list view for better visibility. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to **Purchase > Orders > Purchase Order Lines**. +2. Select the lines you want to merge. +3. Click **Action > Merge into Purchase Order**. +4. In the wizard, review or change the vendor and order date. +5. Adjust the quantities and/or unit prices if needed. The default + quantity is the available amount (original minus the greater of + invoiced or received). +6. Click **Merge Lines** to create the new purchase order. + +The original purchase order lines will have their quantities reduced by +the merged amount. If all lines of a purchase order reach zero, the +order is automatically cancelled. + +Validations will prevent merging lines from different currencies, +different warehouses, or cancelled/locked orders. + +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 +------- + +* SpearHead + +Contributors +------------ + +- `SpearHead `__ + + - Ricardo Jara + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/purchase-workflow `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/purchase_order_line_merge/__init__.py b/purchase_order_line_merge/__init__.py new file mode 100644 index 00000000000..40272379f72 --- /dev/null +++ b/purchase_order_line_merge/__init__.py @@ -0,0 +1 @@ +from . import wizard diff --git a/purchase_order_line_merge/__manifest__.py b/purchase_order_line_merge/__manifest__.py new file mode 100644 index 00000000000..168dd9a7188 --- /dev/null +++ b/purchase_order_line_merge/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "Purchase Order Line Merge", + "version": "18.0.1.0.0", + "author": "SpearHead, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/purchase-workflow", + "license": "AGPL-3", + "category": "Inventory/Purchase", + "summary": "Merge purchase order lines into a new purchase order", + "depends": ["purchase_order_line_menu", "purchase_stock"], + "data": [ + "security/ir.model.access.csv", + "wizard/purchase_order_line_merge_views.xml", + ], + "installable": True, +} diff --git a/purchase_order_line_merge/pyproject.toml b/purchase_order_line_merge/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/purchase_order_line_merge/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/purchase_order_line_merge/readme/CONTRIBUTORS.md b/purchase_order_line_merge/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..798b797e14b --- /dev/null +++ b/purchase_order_line_merge/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- [SpearHead](https://spearhead.global/) + + - Ricardo Jara \<\> diff --git a/purchase_order_line_merge/readme/DESCRIPTION.md b/purchase_order_line_merge/readme/DESCRIPTION.md new file mode 100644 index 00000000000..79c40790a68 --- /dev/null +++ b/purchase_order_line_merge/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +This module allows users to select multiple purchase order lines from +different purchase orders and merge them into a single new purchase order. + +Unlike `purchase_merge` which works at the order level, this module +operates at the line level, giving more granular control over which +specific lines to consolidate. + +Key features: + +- Lines with the same product, unit price, unit of measure, and taxes are + automatically grouped into a single line on the resulting purchase order. +- The default quantity to merge considers both invoiced and received + amounts, defaulting to the available (uninvoiced/unreceived) quantity. +- The unit price is editable in the wizard, allowing price adjustments + before creating the new order. +- Partial merges are supported: the remaining quantity stays on the + original order. +- Orders that reach zero amount after the merge are automatically + cancelled. + +This module also adds the **Qty Invoiced** and **Qty Received** columns +to the Purchase Order Lines list view for better visibility. diff --git a/purchase_order_line_merge/readme/USAGE.md b/purchase_order_line_merge/readme/USAGE.md new file mode 100644 index 00000000000..62f726dcfe0 --- /dev/null +++ b/purchase_order_line_merge/readme/USAGE.md @@ -0,0 +1,15 @@ +1. Go to **Purchase > Orders > Purchase Order Lines**. +2. Select the lines you want to merge. +3. Click **Action > Merge into Purchase Order**. +4. In the wizard, review or change the vendor and order date. +5. Adjust the quantities and/or unit prices if needed. + The default quantity is the available amount (original minus the + greater of invoiced or received). +6. Click **Merge Lines** to create the new purchase order. + +The original purchase order lines will have their quantities reduced by +the merged amount. If all lines of a purchase order reach zero, the +order is automatically cancelled. + +Validations will prevent merging lines from different currencies, +different warehouses, or cancelled/locked orders. diff --git a/purchase_order_line_merge/security/ir.model.access.csv b/purchase_order_line_merge/security/ir.model.access.csv new file mode 100644 index 00000000000..0fa87596882 --- /dev/null +++ b/purchase_order_line_merge/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_purchase_order_line_merge,purchase.order.line.merge,model_purchase_order_line_merge,purchase.group_purchase_user,1,1,1,0 +access_purchase_order_line_merge_line,purchase.order.line.merge.line,model_purchase_order_line_merge_line,purchase.group_purchase_user,1,1,1,0 diff --git a/purchase_order_line_merge/static/description/index.html b/purchase_order_line_merge/static/description/index.html new file mode 100644 index 00000000000..15eeed4f22a --- /dev/null +++ b/purchase_order_line_merge/static/description/index.html @@ -0,0 +1,467 @@ + + + + + +Purchase Order Line Merge + + + +
+

Purchase Order Line Merge

+ + +

Beta License: AGPL-3 OCA/purchase-workflow Translate me on Weblate Try me on Runboat

+

This module allows users to select multiple purchase order lines from +different purchase orders and merge them into a single new purchase +order.

+

Unlike purchase_merge which works at the order level, this module +operates at the line level, giving more granular control over which +specific lines to consolidate.

+

Key features:

+
    +
  • Lines with the same product, unit price, unit of measure, and taxes +are automatically grouped into a single line on the resulting +purchase order.
  • +
  • The default quantity to merge considers both invoiced and received +amounts, defaulting to the available (uninvoiced/unreceived) +quantity.
  • +
  • The unit price is editable in the wizard, allowing price adjustments +before creating the new order.
  • +
  • Partial merges are supported: the remaining quantity stays on the +original order.
  • +
  • Orders that reach zero amount after the merge are automatically +cancelled.
  • +
+

This module also adds the Qty Invoiced and Qty Received columns +to the Purchase Order Lines list view for better visibility.

+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Purchase > Orders > Purchase Order Lines.
  2. +
  3. Select the lines you want to merge.
  4. +
  5. Click Action > Merge into Purchase Order.
  6. +
  7. In the wizard, review or change the vendor and order date.
  8. +
  9. Adjust the quantities and/or unit prices if needed. The default +quantity is the available amount (original minus the greater of +invoiced or received).
  10. +
  11. Click Merge Lines to create the new purchase order.
  12. +
+

The original purchase order lines will have their quantities reduced by +the merged amount. If all lines of a purchase order reach zero, the +order is automatically cancelled.

+

Validations will prevent merging lines from different currencies, +different warehouses, or cancelled/locked orders.

+
+
+

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

+
    +
  • SpearHead
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

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.

+

This module is part of the OCA/purchase-workflow project on GitHub.

+

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

+
+
+
+ + diff --git a/purchase_order_line_merge/tests/__init__.py b/purchase_order_line_merge/tests/__init__.py new file mode 100644 index 00000000000..3d649ba6388 --- /dev/null +++ b/purchase_order_line_merge/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 SpearHead +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_purchase_order_line_merge diff --git a/purchase_order_line_merge/tests/test_purchase_order_line_merge.py b/purchase_order_line_merge/tests/test_purchase_order_line_merge.py new file mode 100644 index 00000000000..aafc4a8b246 --- /dev/null +++ b/purchase_order_line_merge/tests/test_purchase_order_line_merge.py @@ -0,0 +1,268 @@ +from odoo import Command +from odoo.exceptions import UserError +from odoo.tests import tagged + +from odoo.addons.base.tests.common import BaseCommon + + +@tagged("post_install", "-at_install") +class TestPurchaseOrderLineMerge(BaseCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test Vendor"}) + cls.partner_2 = cls.env["res.partner"].create({"name": "Test Vendor 2"}) + cls.product_1 = cls.env["product.product"].create( + {"name": "Product A", "type": "consu"} + ) + cls.product_2 = cls.env["product.product"].create( + {"name": "Product B", "type": "consu"} + ) + cls.tax = cls.env["account.tax"].create( + { + "name": "Tax 15%", + "type_tax_use": "purchase", + "amount": 15, + } + ) + cls.po_1 = cls.env["purchase.order"].create({"partner_id": cls.partner.id}) + cls.po_line_1 = cls.env["purchase.order.line"].create( + { + "order_id": cls.po_1.id, + "product_id": cls.product_1.id, + "product_uom": cls.product_1.uom_id.id, + "product_qty": 10.0, + "price_unit": 100.0, + "taxes_id": [Command.set(cls.tax.ids)], + } + ) + cls.po_line_2 = cls.env["purchase.order.line"].create( + { + "order_id": cls.po_1.id, + "product_id": cls.product_2.id, + "product_uom": cls.product_2.uom_id.id, + "product_qty": 5.0, + "price_unit": 200.0, + } + ) + cls.po_2 = cls.env["purchase.order"].create({"partner_id": cls.partner.id}) + cls.po_line_3 = cls.env["purchase.order.line"].create( + { + "order_id": cls.po_2.id, + "product_id": cls.product_1.id, + "product_uom": cls.product_1.uom_id.id, + "product_qty": 8.0, + "price_unit": 100.0, + "taxes_id": [Command.set(cls.tax.ids)], + } + ) + + def _create_wizard(self, line_ids, vals=None): + ctx = {"active_ids": line_ids, "active_model": "purchase.order.line"} + wizard = ( + self.env["purchase.order.line.merge"].with_context(**ctx).create(vals or {}) + ) + return wizard + + def test_default_vals(self): + """Default values: partner and quantity based on source quantity.""" + # Partner auto-filled when all lines share the same vendor + ctx = { + "active_ids": (self.po_line_1 | self.po_line_3).ids, + "active_model": "purchase.order.line", + } + defaults = ( + self.env["purchase.order.line.merge"] + .with_context(**ctx) + .default_get(["partner_id", "line_ids"]) + ) + self.assertEqual(defaults.get("partner_id"), self.partner.id) + self.assertEqual(len(defaults.get("line_ids", [])), 2) + wizard = self._create_wizard(self.po_line_1.ids) + self.assertEqual(wizard.line_ids.quantity, 10.0) + + def test_default_vals_excludes_canceled_lines(self): + """Canceled source lines are ignored in wizard initialization.""" + po_canceled = self.env["purchase.order"].create({"partner_id": self.partner.id}) + canceled_line = self.env["purchase.order.line"].create( + { + "order_id": po_canceled.id, + "product_id": self.product_1.id, + "product_uom": self.product_1.uom_id.id, + "product_qty": 2.0, + "price_unit": 25.0, + } + ) + po_canceled.button_cancel() + wizard = self._create_wizard((self.po_line_1 | canceled_line).ids) + self.assertEqual(wizard.line_ids.mapped("source_line_id"), self.po_line_1) + + def test_validation_quantity(self): + """Only checks for at least one positive quantity to merge.""" + # Negative values are ignored by merge selection (quantity > 0) + wizard = self._create_wizard(self.po_line_1.ids) + wizard.line_ids.quantity = -1.0 + with self.assertRaisesRegex( + UserError, r"No lines with quantity greater than zero" + ): + wizard.action_merge() + # All zero + wizard.line_ids.quantity = 0.0 + with self.assertRaisesRegex( + UserError, r"No lines with quantity greater than zero" + ): + wizard.action_merge() + + def test_validation_order_constraints(self): + """Order-level validations: currencies, warehouses, and non-draft state.""" + # Different currencies + currency_eur = self.env.ref("base.EUR") + po_eur = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "currency_id": currency_eur.id, + } + ) + po_line_eur = self.env["purchase.order.line"].create( + { + "order_id": po_eur.id, + "product_id": self.product_1.id, + "product_uom": self.product_1.uom_id.id, + "product_qty": 5.0, + "price_unit": 50.0, + } + ) + wizard = self._create_wizard((self.po_line_1 | po_line_eur).ids) + with self.assertRaisesRegex(UserError, r"different currencies"): + wizard.action_merge() + # Different warehouses + warehouse_2 = self.env["stock.warehouse"].create( + {"name": "Warehouse 2", "code": "WH2"} + ) + po_wh2 = self.env["purchase.order"].create( + { + "partner_id": self.partner.id, + "picking_type_id": warehouse_2.in_type_id.id, + } + ) + po_line_wh2 = self.env["purchase.order.line"].create( + { + "order_id": po_wh2.id, + "product_id": self.product_1.id, + "product_uom": self.product_1.uom_id.id, + "product_qty": 5.0, + "price_unit": 50.0, + } + ) + wizard = self._create_wizard((self.po_line_1 | po_line_wh2).ids) + with self.assertRaisesRegex(UserError, r"different warehouses"): + wizard.action_merge() + # Non-draft order is currently allowed (placeholder hook, no validation yet) + po_confirmed = self.env["purchase.order"].create( + {"partner_id": self.partner.id} + ) + po_line_confirmed = self.env["purchase.order.line"].create( + { + "order_id": po_confirmed.id, + "product_id": self.product_1.id, + "product_uom": self.product_1.uom_id.id, + "product_qty": 5.0, + "price_unit": 50.0, + } + ) + po_confirmed.button_confirm() + wizard = self._create_wizard(po_line_confirmed.ids) + result = wizard.action_merge() + self.assertTrue(self.env["purchase.order"].browse(result["res_id"]).exists()) + + def test_merge_basic(self): + """Basic merge creates a new PO with correct lines.""" + wizard = self._create_wizard( + (self.po_line_1 | self.po_line_2).ids, + ) + self.assertEqual(wizard.partner_id, self.partner) + self.assertEqual(len(wizard.line_ids), 2) + result = wizard.action_merge() + new_po = self.env["purchase.order"].browse(result["res_id"]) + self.assertTrue(new_po.exists()) + self.assertEqual(new_po.partner_id, self.partner) + self.assertEqual(len(new_po.order_line), 2) + line_a = new_po.order_line.filtered(lambda ln: ln.product_id == self.product_1) + self.assertEqual(line_a.product_qty, 10.0) + self.assertEqual(line_a.price_unit, 100.0) + line_b = new_po.order_line.filtered(lambda ln: ln.product_id == self.product_2) + self.assertEqual(line_b.product_qty, 5.0) + self.assertEqual(line_b.price_unit, 200.0) + + def test_merge_same_product(self): + """Same product lines are grouped; origin references source POs.""" + wizard = self._create_wizard( + (self.po_line_1 | self.po_line_3).ids, + ) + result = wizard.action_merge() + new_po = self.env["purchase.order"].browse(result["res_id"]) + # Same product/price/uom/taxes → single line with summed qty + self.assertEqual(len(new_po.order_line), 1) + self.assertEqual(new_po.order_line.product_qty, 18.0) + self.assertEqual(new_po.order_line.price_unit, 100.0) + # Origin field references both source POs + self.assertIn(self.po_1.name, new_po.origin) + self.assertIn(self.po_2.name, new_po.origin) + + def test_merge_same_product_different_discount(self): + """Same product lines with different discount must not be grouped.""" + self.po_line_1.discount = 5.0 + self.po_line_3.discount = 10.0 + wizard = self._create_wizard((self.po_line_1 | self.po_line_3).ids) + result = wizard.action_merge() + new_po = self.env["purchase.order"].browse(result["res_id"]) + self.assertEqual(len(new_po.order_line), 2) + discounts = sorted(new_po.order_line.mapped("discount")) + self.assertEqual(discounts, [5.0, 10.0]) + + def test_merge_updates_source(self): + """Merge reduces source qty, cancels empty orders, handles partial.""" + # Full merge: original lines go to zero, order gets cancelled + wizard = self._create_wizard( + (self.po_line_1 | self.po_line_2).ids, + ) + wizard.action_merge() + self.assertEqual(self.po_line_1.product_qty, 0.0) + self.assertEqual(self.po_line_2.product_qty, 0.0) + self.assertEqual(self.po_1.state, "cancel") + # Partial merge: only moved qty is subtracted + wizard = self._create_wizard(self.po_line_3.ids) + wizard.line_ids.quantity = 3.0 + wizard.action_merge() + self.assertEqual(self.po_line_3.product_qty, 5.0) + + def test_merge_updates_only_filtered_lines(self): + """Only lines selected for merge update source quantities.""" + wizard = self._create_wizard((self.po_line_1 | self.po_line_2).ids) + line_1 = wizard.line_ids.filtered( + lambda ln: ln.source_line_id == self.po_line_1 + ) + line_2 = wizard.line_ids.filtered( + lambda ln: ln.source_line_id == self.po_line_2 + ) + line_1.quantity = 2.0 + line_2.quantity = -1.0 + wizard.action_merge() + self.assertEqual(self.po_line_1.product_qty, 8.0) + self.assertEqual(self.po_line_2.product_qty, 5.0) + + def test_computed_fields(self): + """Subtotal and editable price behavior.""" + wizard = self._create_wizard(self.po_line_1.ids) + line = wizard.line_ids + # Subtotal = quantity * price_unit + self.assertEqual(line.price_subtotal, line.quantity * 100.0) + line.quantity = 3.0 + self.assertEqual(line.price_subtotal, 3.0 * 100.0) + # Editable price updates subtotal + line.price_unit = 150.0 + self.assertEqual(line.price_subtotal, 3.0 * 150.0) + # Edited price carries through to the new PO + result = wizard.action_merge() + new_po = self.env["purchase.order"].browse(result["res_id"]) + self.assertEqual(new_po.order_line.price_unit, 150.0) diff --git a/purchase_order_line_merge/wizard/__init__.py b/purchase_order_line_merge/wizard/__init__.py new file mode 100644 index 00000000000..53a2d551ffb --- /dev/null +++ b/purchase_order_line_merge/wizard/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 SpearHead +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import purchase_order_line_merge diff --git a/purchase_order_line_merge/wizard/purchase_order_line_merge.py b/purchase_order_line_merge/wizard/purchase_order_line_merge.py new file mode 100644 index 00000000000..b9b3181d4b4 --- /dev/null +++ b/purchase_order_line_merge/wizard/purchase_order_line_merge.py @@ -0,0 +1,228 @@ +from odoo import Command, api, fields, models +from odoo.exceptions import UserError + + +class PurchaseOrderLineMerge(models.TransientModel): + _name = "purchase.order.line.merge" + _description = "Merge Purchase Order Lines" + + partner_id = fields.Many2one( + comodel_name="res.partner", + string="Vendor", + required=True, + ) + date_order = fields.Datetime( + string="Order Date", + required=True, + default=fields.Datetime.now, + ) + line_ids = fields.One2many( + comodel_name="purchase.order.line.merge.line", + inverse_name="wizard_id", + string="Lines", + ) + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + active_ids = self.env.context.get("active_ids", []) + if not active_ids: + return res + po_lines = self.env["purchase.order.line"].browse(active_ids) + po_lines = self._filter_mergeable_lines(po_lines) + # Auto-fill vendor if all selected lines belong to the same one + res["partner_id"] = self._get_default_partner(po_lines) + res["line_ids"] = [ + Command.create(self._prepare_default_line_vals(line)) for line in po_lines + ] + return res + + @api.model + def _filter_mergeable_lines(self, po_lines): + """Return the purchase lines eligible for merge initialization.""" + return po_lines.filtered(lambda line: line.state != "cancel") + + @api.model + def _get_default_partner(self, po_lines): + """Return the vendor id if all lines share the same one, else False.""" + partners = po_lines.mapped("partner_id") + if len(partners) == 1: + return partners.id + return False + + @api.model + def _prepare_default_line_vals(self, po_line): + """Prepare wizard line values from a purchase order line.""" + return { + "source_line_id": po_line.id, + "original_qty": po_line.product_qty, + "quantity": po_line.product_qty, + "price_unit": po_line.price_unit, + } + + def _check_merge_allowed(self): + self.ensure_one() + source_orders = self.line_ids.mapped("source_line_id.order_id") + currencies = source_orders.mapped("currency_id") + if len(currencies) > 1: + raise UserError( + self.env._( + "Cannot merge lines with different currencies. " + "Please select lines with the same currency." + ) + ) + picking_types = source_orders.mapped("picking_type_id") + if len(picking_types) > 1: + raise UserError( + self.env._( + "Cannot merge lines from different warehouses. " + "Please select lines with the same warehouse." + ) + ) + + def _get_lines_to_merge(self): + """Return wizard lines that pass merge validation.""" + return self.line_ids.filtered(lambda line: line._is_mergeable_for_merge()) + + def action_merge(self): + self.ensure_one() + self._check_merge_allowed() + lines_to_merge = self._get_lines_to_merge() + if not lines_to_merge: + raise UserError( + self.env._("No lines with quantity greater than zero to merge.") + ) + merged = {} + for line in lines_to_merge: + key = line._get_merge_key() + if key not in merged: + merged[key] = { + "product_id": line.product_id, + "price_unit": line.price_unit, + "product_uom": line.product_uom, + "taxes_id": line.taxes_id, + "discount": line.discount, + "quantity": 0.0, + } + merged[key]["quantity"] += line.quantity + order_vals = self._prepare_purchase_order_vals() + order_vals["order_line"] = [ + Command.create(self._prepare_purchase_order_line_vals(vals)) + for vals in merged.values() + ] + new_order = self.env["purchase.order"].create(order_vals) + self._update_source_line_quantities(lines_to_merge) + self._cancel_empty_source_orders() + return self._action_view_purchase_order(new_order) + + def _update_source_line_quantities(self, lines_to_merge): + """Update source purchase lines with remaining quantities after merge.""" + for line in lines_to_merge: + remaining = line.original_qty - line.quantity + line.source_line_id.write({"product_qty": remaining}) + + def _cancel_empty_source_orders(self): + """Cancel source purchase orders whose total becomes zero.""" + source_orders = self.line_ids.mapped("source_line_id.order_id") + orders_to_cancel = source_orders.filtered(lambda order: order.amount_total == 0) + orders_to_cancel.button_cancel() + + def _prepare_purchase_order_vals(self): + self.ensure_one() + source_orders = self.line_ids.mapped("source_line_id.order_id") + return { + "partner_id": self.partner_id.id, + "payment_term_id": (self.partner_id.property_supplier_payment_term_id.id), + "date_order": self.date_order, + "origin": ", ".join(source_orders.mapped("name")), + "currency_id": source_orders[0].currency_id.id, + "picking_type_id": source_orders[0].picking_type_id.id, + } + + def _prepare_purchase_order_line_vals(self, vals): + return { + "product_id": vals["product_id"].id, + "name": vals["product_id"].display_name, + "product_uom": vals["product_uom"].id, + "product_qty": vals["quantity"], + "price_unit": vals["price_unit"], + "date_planned": self.date_order, + "taxes_id": [Command.set(vals["taxes_id"].ids)], + "discount": vals["discount"], + } + + def _action_view_purchase_order(self, purchase_order): + return purchase_order.with_context(create=False)._get_records_action( + name=self.env._("Purchase Order") + ) + + +class PurchaseOrderLineMergeLine(models.TransientModel): + _name = "purchase.order.line.merge.line" + _description = "Purchase Order Line Merge Line" + + wizard_id = fields.Many2one( + comodel_name="purchase.order.line.merge", + required=True, + ondelete="cascade", + ) + source_line_id = fields.Many2one( + comodel_name="purchase.order.line", + string="Source Line", + required=True, + readonly=True, + ) + order_id = fields.Many2one( + related="source_line_id.order_id", + ) + product_id = fields.Many2one( + related="source_line_id.product_id", + ) + product_uom = fields.Many2one( + related="source_line_id.product_uom", + ) + price_unit = fields.Float( + string="Unit Price", + digits="Product Price", + ) + taxes_id = fields.Many2many( + related="source_line_id.taxes_id", + ) + discount = fields.Float( + related="source_line_id.discount", + ) + original_qty = fields.Float( + digits="Product Unit of Measure", + readonly=True, + ) + quantity = fields.Float( + string="Quantity to Merge", + digits="Product Unit of Measure", + ) + price_subtotal = fields.Float( + string="Subtotal", + compute="_compute_price_subtotal", + ) + + @api.depends("quantity", "price_unit") + def _compute_price_subtotal(self): + for line in self: + line.price_subtotal = line.quantity * line.price_unit + + def _get_merge_key(self): + self.ensure_one() + return ( + self.product_id.id, + self.price_unit, + self.product_uom.id, + tuple(sorted(self.taxes_id.ids)), + self.discount, + ) + + def _is_mergeable_for_merge(self): + """Return True when this line should be included in merge processing.""" + self.ensure_one() + # Hook for line-level rules. + # In inherited modules, raise UserError("...") here to provide a + # custom validation message for this line. + return self.quantity > 0 diff --git a/purchase_order_line_merge/wizard/purchase_order_line_merge_views.xml b/purchase_order_line_merge/wizard/purchase_order_line_merge_views.xml new file mode 100644 index 00000000000..3afdc38ec05 --- /dev/null +++ b/purchase_order_line_merge/wizard/purchase_order_line_merge_views.xml @@ -0,0 +1,55 @@ + + + + purchase.order.line.merge.form + purchase.order.line.merge + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Merge into Purchase Order + purchase.order.line.merge + form + new + + list + +