diff --git a/eslint.config.cjs b/eslint.config.cjs index 0d5731f89..fdd78c5e3 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -191,7 +191,7 @@ const config = [{ }, }, { - files: ["**/*.esm.js"], + files: ["**/*.esm.js", "**/*test.js"], languageOptions: { ecmaVersion: 2024, diff --git a/sale_product_pack/README.rst b/sale_product_pack/README.rst index f8a821371..6efcbf418 100644 --- a/sale_product_pack/README.rst +++ b/sale_product_pack/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ================= Sale Product Pack ================= @@ -17,7 +13,7 @@ Sale Product Pack .. |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/license-AGPL--3-blue.png +.. |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%2Fproduct--pack-lightgray.png?logo=github @@ -88,6 +84,7 @@ Authors * NaN·tic * ADHOC SA * Tecnativa +* ACSONE SA/NV Contributors ------------ @@ -108,6 +105,7 @@ Contributors - `Acsone `__: - Maxime Franco + - Stéphane Mangin - `ADHOC SA `__: @@ -115,6 +113,11 @@ Contributors - Augusto Weiss - Nicolas Col +- `Atrium res + Informatica `__ + + - Stéphane Mangin + Maintainers ----------- diff --git a/sale_product_pack/__manifest__.py b/sale_product_pack/__manifest__.py index b77439cb0..fa4fce2e2 100644 --- a/sale_product_pack/__manifest__.py +++ b/sale_product_pack/__manifest__.py @@ -1,12 +1,14 @@ # Copyright 2019 NaN (http://www.nan-tic.com) - Àngel Àlvarez +# Copyright 2026 ACSONE SA/NV () # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). { "name": "Sale Product Pack", - "version": "18.0.1.0.2", + "version": "18.0.1.1.0", "category": "Sales", "summary": "This module allows you to sell product packs", "website": "https://github.com/OCA/product-pack", - "author": "NaN·tic, ADHOC SA, Tecnativa, Odoo Community Association (OCA)", + "author": "NaN·tic, ADHOC SA, Tecnativa, ACSONE SA/NV," + " Odoo Community Association (OCA)", "maintainers": ["victoralmau"], "license": "AGPL-3", "depends": ["product_pack", "sale"], @@ -15,5 +17,13 @@ "demo/product_pack_line_demo.xml", "demo/sale_pack_demo.xml", ], + "assets": { + "web.assets_backend": [ + "sale_product_pack/static/src/js/**/*.js", + ], + "web.assets_unit_tests": [ + "sale_product_pack/static/tests/sale_order_line.esm.test.js", + ], + }, "installable": True, } diff --git a/sale_product_pack/models/sale_order.py b/sale_product_pack/models/sale_order.py index 62baddc9b..9146bdd0b 100644 --- a/sale_product_pack/models/sale_order.py +++ b/sale_product_pack/models/sale_order.py @@ -1,7 +1,6 @@ # Copyright 2019 Tecnativa - Ernesto Tejeda # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo import _, api, models -from odoo.exceptions import UserError +from odoo import models class SaleOrder(models.Model): @@ -13,34 +12,20 @@ def copy(self, default=None): pack_copied_lines = sale_copy.order_line.filtered( lambda line: line.pack_parent_line_id.order_id == self ) - pack_copied_lines.unlink() + pack_copied_lines.with_context(pack_children_force_unlink=True).unlink() return sale_copy - @api.onchange("order_line") - def check_pack_line_unlink(self): - """At least on embeded tree editable view odoo returns a recordset on - _origin.order_line only when lines are unlinked and this is exactly - what we need - """ - origin_line_ids = self._origin.order_line.ids - line_ids = self.order_line.ids - removed_line_ids = list(set(origin_line_ids) - set(line_ids)) - removed_line = self.env["sale.order.line"].browse(removed_line_ids) - if removed_line.filtered( - lambda x: x.pack_parent_line_id - and not x.pack_parent_line_id.product_id.pack_modifiable - ): - raise UserError( - _( - "You cannot delete this line because is part of a pack in" - " this sale order. In order to delete this line you need to" - " delete the pack itself" - ) - ) - def write(self, vals): + pack_parent_delete_ids = [] if "order_line" in vals: to_delete_ids = [e[1] for e in vals["order_line"] if e[0] == 2] + if to_delete_ids: + pack_parent_delete_ids = ( + self.env["sale.order.line"] + .browse(to_delete_ids) + .filtered(lambda line: line.pack_child_line_ids) + .ids + ) subpacks_to_delete_ids = ( self.env["sale.order.line"] .search( @@ -56,6 +41,11 @@ def write(self, vals): subpacks_to_delete_ids.remove(cmd[1]) for to_delete_id in subpacks_to_delete_ids: vals["order_line"].append([2, to_delete_id, False]) + if pack_parent_delete_ids: + return super( + SaleOrder, + self.with_context(pack_parent_delete_ids=pack_parent_delete_ids), + ).write(vals) return super().write(vals) def _get_update_prices_lines(self): diff --git a/sale_product_pack/models/sale_order_line.py b/sale_product_pack/models/sale_order_line.py index 5ef3d19f5..3db720f98 100644 --- a/sale_product_pack/models/sale_order_line.py +++ b/sale_product_pack/models/sale_order_line.py @@ -4,6 +4,16 @@ from odoo.exceptions import UserError from odoo.fields import first +IMMUTABLE_CHILD_FIELDS = [ + "product_id", + "product_uom_qty", + "product_uom", + "price_unit", + "discount", + "name", + "tax_id", +] + class SaleOrderLine(models.Model): _inherit = "sale.order.line" @@ -100,22 +110,37 @@ def write(self, vals): record.expand_pack_line(write=True) return res - @api.onchange( - "product_id", - "product_uom_qty", - "product_uom", - "price_unit", - "discount", - "name", - "tax_id", - ) + def unlink(self): + # Avoid removing of a component line if the parent line is not also being + # removed and the pack is not modifiable + forcing_context = self.env.context.get("pack_children_force_unlink", False) + pack_parent_delete_ids = self.env.context.get("pack_parent_delete_ids", []) + if not forcing_context and self.filtered( + lambda x: x.pack_parent_line_id + and x.pack_parent_line_id.id not in pack_parent_delete_ids + and ( + x.pack_parent_line_id not in self + or x.pack_parent_line_id in x.order_id.order_line + ) + and not x.pack_parent_line_id.product_id.pack_modifiable + ): + raise UserError( + _( + "You cannot delete this line because it is part of a pack in" + " this sale order. In order to delete this line you need to" + " delete the pack itself." + ) + ) + return super().unlink() + + @api.onchange(*IMMUTABLE_CHILD_FIELDS) def check_pack_line_modify(self): """Do not let to edit a sale order line if this one belongs to pack""" if self._origin.pack_parent_line_id and not self._origin.pack_modifiable: raise UserError( _( - "You can not change this line because is part of a pack" - " included in this order" + "You can not change this line because it is part of a pack" + " included in this order." ) ) diff --git a/sale_product_pack/readme/CONTRIBUTORS.md b/sale_product_pack/readme/CONTRIBUTORS.md index f75511e77..1c5917333 100644 --- a/sale_product_pack/readme/CONTRIBUTORS.md +++ b/sale_product_pack/readme/CONTRIBUTORS.md @@ -7,7 +7,10 @@ - Daniel Reis \<\> - [Acsone](https://www.acsone.eu/): - Maxime Franco + - Stéphane Mangin \<\> - [ADHOC SA](https://www.adhoc.com.ar): - Bruno Zanotti - Augusto Weiss - Nicolas Col +- [Atrium res Informatica](https://www.malt.fr/profile/stephanemangin) + - Stéphane Mangin \<\> diff --git a/sale_product_pack/static/description/index.html b/sale_product_pack/static/description/index.html index af72d19e4..9f8f8c5df 100644 --- a/sale_product_pack/static/description/index.html +++ b/sale_product_pack/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Sale Product Pack -
+
+

Sale Product Pack

- - -Odoo Community Association - -
-

Sale Product Pack

-

Beta License: AGPL-3 OCA/product-pack Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/product-pack Translate me on Weblate Try me on Runboat

This module adds Product Pack functionality to sales orders. You can choose a Pack in sales order lines and see different behaviors depending on “Pack type” and “Pack component price” fields options @@ -394,7 +389,7 @@

Sale Product Pack

-

Usage

+

Usage

To use this module, you need to:

  1. Go to Sales > Products > Products, create or select a product and @@ -412,7 +407,7 @@

    Usage

-

Known issues / Roadmap

+

Known issues / Roadmap

  • If this module is installed and stock module is installed too, when you create a Sale order for a Non detailed Pack and you confirm it, @@ -422,7 +417,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 @@ -430,17 +425,18 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • NaN·tic
  • ADHOC SA
  • Tecnativa
  • +
  • ACSONE SA/NV
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -483,6 +485,5 @@

Maintainers

-
diff --git a/sale_product_pack/static/src/js/sale_order_line.esm.js b/sale_product_pack/static/src/js/sale_order_line.esm.js new file mode 100644 index 000000000..d9a5c50d4 --- /dev/null +++ b/sale_product_pack/static/src/js/sale_order_line.esm.js @@ -0,0 +1,140 @@ +/** @odoo-module **/ +/* Copyright 2026 ACSONE SA/NV */ +/* Copyright 2026 Atrium res Informatica */ +import {AlertDialog} from "@web/core/confirmation_dialog/confirmation_dialog"; +import {_t} from "@web/core/l10n/translation"; +import {patch} from "@web/core/utils/patch"; +import {StaticList} from "@web/model/relational_model/static_list"; + +patch(StaticList.prototype, { + _getRecordIdentifier(record) { + return record?.resId || record?.id || null; + }, + + _isSaleOrderLine(record) { + return Boolean(record && record.resModel === "sale.order.line"); + }, + + _isPackChildRecord(record) { + return ( + this._isSaleOrderLine(record) && + Boolean(record.data && record.data.pack_parent_line_id) + ); + }, + + _isPackParentRecord(record) { + return ( + this._isSaleOrderLine(record) && + !(record.data && record.data.pack_parent_line_id) + ); + }, + + _getParentId(record) { + return record?.data?.pack_parent_line_id?.[0] || null; + }, + + _getPackChildRecords(parentRecord, records) { + const sourceRecords = Array.isArray(records) ? records : []; + const parentId = this._getRecordIdentifier(parentRecord); + if (!parentId) { + return []; + } + return sourceRecords.filter( + (record) => + this._isPackChildRecord(record) && + this._getParentId(record) === parentId + ); + }, + + _expandRecordsToDelete(records) { + // When a pack parent is deleted from the list, include its current child + // lines in the same delete batch so backend constraints stay consistent. + const selectedRecords = Array.isArray(records) ? records : []; + const currentRecords = Array.isArray(this.records) ? this.records : []; + const result = [...selectedRecords]; + const seen = new Set( + selectedRecords.map((record) => this._getRecordIdentifier(record)) + ); + + for (const record of selectedRecords) { + if (!this._isPackParentRecord(record)) { + continue; + } + for (const childRecord of this._getPackChildRecords( + record, + currentRecords + )) { + const childId = this._getRecordIdentifier(childRecord); + if (!seen.has(childId)) { + result.push(childRecord); + seen.add(childId); + } + } + } + return result; + }, + + _isChildDeletedWithoutParent(record, selectedParentIds) { + return ( + this._canBeDeleted(record) && + !selectedParentIds.has(this._getParentId(record)) + ); + }, + + _canBeDeleted(record) { + // Protected children of non-modifiable packs cannot be removed directly. + return ( + this._isPackChildRecord(record) && + !(record.data && record.data.pack_modifiable) + ); + }, + + _alertNotUnlinkable() { + const body = _t( + "Cannot delete this line because it is part of a pack. Delete the pack itself." + ); + + this.model.env.services.dialog.add(AlertDialog, { + title: _t("Deletion not allowed"), + body: body, + }); + }, + + async delete(record) { + return this.deleteRecords([record]); + }, + + async _superDeleteRecords(records) { + // Compatibility fallback: some StaticList contexts expose only `delete`. + if (super.deleteRecords) { + return super.deleteRecords(records); + } + for (const record of records) { + await super.delete(record); + } + }, + + async deleteRecords(records) { + if (!Array.isArray(records)) { + if (super.deleteRecords) { + return super.deleteRecords(...arguments); + } + return super.delete(records); + } + const recordsToDelete = this._expandRecordsToDelete(records); + const selectedParentIds = new Set( + recordsToDelete + .filter((record) => !this._isPackChildRecord(record)) + .map((record) => this._getRecordIdentifier(record)) + ); + const hasProtectedChild = recordsToDelete.some((record) => + this._isChildDeletedWithoutParent(record, selectedParentIds) + ); + if (hasProtectedChild) { + // Block only when a protected child is targeted without its parent. + this._alertNotUnlinkable(); + return; + } + return this._superDeleteRecords(recordsToDelete); + }, +}); diff --git a/sale_product_pack/static/tests/sale_order_line.esm.test.js b/sale_product_pack/static/tests/sale_order_line.esm.test.js new file mode 100644 index 000000000..e2cfa2b55 --- /dev/null +++ b/sale_product_pack/static/tests/sale_order_line.esm.test.js @@ -0,0 +1,126 @@ +/** @odoo-module **/ +/* Copyright 2026 Atrium res Informatica */ + +import {describe, expect, test} from "@odoo/hoot"; + +import {StaticList} from "@web/model/relational_model/static_list"; + +function makeContext(overrides = {}) { + return Object.assign(Object.create(StaticList.prototype), overrides); +} + +function makeSaleChildRecord({packModifiable = false} = {}) { + return { + resModel: "sale.order.line", + data: { + pack_parent_line_id: [99, "Pack"], + pack_modifiable: packModifiable, + }, + }; +} + +function makeSaleParentRecord({id = 99, packModifiable = false} = {}) { + return { + resModel: "sale.order.line", + id, + data: { + pack_parent_line_id: false, + pack_modifiable: packModifiable, + }, + }; +} + +describe("sale_order_line.esm", () => { + test("_canBeDeleted is true only for pack child", () => { + const context = makeContext(); + + expect( + StaticList.prototype._canBeDeleted.call( + context, + makeSaleChildRecord({packModifiable: false}) + ) + ).toBe(true); + expect( + StaticList.prototype._canBeDeleted.call( + context, + makeSaleChildRecord({packModifiable: true}) + ) + ).toBe(false); + expect( + StaticList.prototype._canBeDeleted.call(context, { + resModel: "sale.order.line", + data: {pack_parent_line_id: false, pack_modifiable: false}, + }) + ).toBe(false); + }); + + test("delete on protected child opens warning dialog", async () => { + let alertCalled = false; + let superDeleteCount = 0; + const context = makeContext({ + _alertNotUnlinkable: () => { + alertCalled = true; + }, + _superDeleteRecords: () => { + superDeleteCount += 1; + }, + }); + + await StaticList.prototype.delete.call( + context, + makeSaleChildRecord({packModifiable: false}) + ); + + expect(alertCalled).toBe(true); + expect(superDeleteCount).toBe(0); + }); + + test("deleteRecords on protected children opens warning", async () => { + let alertCalled = false; + let superDeleteCount = 0; + const context = makeContext({ + _alertNotUnlinkable: () => { + alertCalled = true; + }, + _superDeleteRecords: () => { + superDeleteCount += 1; + }, + }); + + await StaticList.prototype.deleteRecords.call(context, [ + makeSaleChildRecord({packModifiable: false}), + makeSaleChildRecord({packModifiable: false}), + ]); + + expect(alertCalled).toBe(true); + expect(superDeleteCount).toBe(0); + }); + + test("deleteRecords on parent expands to children", async () => { + const scenarios = [{packModifiable: false}, {packModifiable: true}]; + + for (const {packModifiable} of scenarios) { + const parent = makeSaleParentRecord({packModifiable}); + const child = makeSaleChildRecord({packModifiable: false}); + let superDeleteRecordsArg = null; + let alertCalled = false; + const context = makeContext({ + records: [parent, child], + _alertNotUnlinkable: () => { + alertCalled = true; + }, + _superDeleteRecords: (records) => { + superDeleteRecordsArg = records; + }, + }); + + await StaticList.prototype.deleteRecords.call(context, [parent]); + + expect(Array.isArray(superDeleteRecordsArg)).toBe(true); + expect(superDeleteRecordsArg).toHaveLength(2); + expect(superDeleteRecordsArg.includes(parent)).toBe(true); + expect(superDeleteRecordsArg.includes(child)).toBe(true); + expect(alertCalled).toBe(false); + } + }); +}); diff --git a/sale_product_pack/tests/__init__.py b/sale_product_pack/tests/__init__.py index 365edcb3d..4feb0dd71 100644 --- a/sale_product_pack/tests/__init__.py +++ b/sale_product_pack/tests/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import test_sale_product_pack +from . import test_sale_order diff --git a/sale_product_pack/tests/test_sale_order.py b/sale_product_pack/tests/test_sale_order.py new file mode 100644 index 000000000..e6f532eff --- /dev/null +++ b/sale_product_pack/tests/test_sale_order.py @@ -0,0 +1,108 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import Command +from odoo.exceptions import UserError + +from .common import TestSaleProductPackBase + + +class TestSaleOrder(TestSaleProductPackBase): + def test_unlink_pack_line(self): + """Test the unlink of a pack product line and its components.""" + pack_line = self._add_so_line() + # Verify 3 lines are created (1 pack + 2 components) + self.assertEqual(len(self.sale_order.order_line), 3) + # Unlink the pack line and its components together + pack_line.pack_child_line_ids.with_context( + pack_children_force_unlink=True + ).unlink() + pack_line.unlink() + # Verify all pack lines are deleted + self.assertEqual(len(self.sale_order.order_line), 0) + + def test_unlink_component_line_should_fail(self): + """Test that unlinking a component line should raise an error.""" + pack_line = self._add_so_line() + component_line = self.sale_order.order_line.filtered( + lambda line: line.pack_parent_line_id == pack_line + )[0] + # Try to unlink component line directly + with self.assertRaises(UserError): + component_line.unlink() + # Verify line still exists + self.assertEqual(len(self.sale_order.order_line), 3) + + def test_unlink_component_line_with_forcing_context(self): + """Test that unlinking a component line should raise an error.""" + pack_line = self._add_so_line() + component_line = self.sale_order.order_line.filtered( + lambda line: line.pack_parent_line_id == pack_line + )[0] + # Unlink component line directly + component_line.with_context(pack_children_force_unlink=True).unlink() + # Verify line has been deleted + self.assertEqual(len(self.sale_order.order_line), 2) + + def test_unlink_multiple_packs(self): + """Test unlinking multiple pack lines.""" + pack_line1 = self._add_so_line() + pack_line2 = self._add_so_line(sequence=20) + # Verify 6 lines are created (2 packs + 4 components) + self.assertEqual(len(self.sale_order.order_line), 6) + # Unlink first pack + pack_line1.pack_child_line_ids.with_context( + pack_children_force_unlink=True + ).unlink() + pack_line1.unlink() + # Verify 3 lines remain (1 pack + 2 components of pack2) + self.assertEqual(len(self.sale_order.order_line), 3) + # Verify remaining lines belong to pack_line2 + for line in self.sale_order.order_line: + if line.pack_parent_line_id: + self.assertEqual(line.pack_parent_line_id, pack_line2) + + def test_unlink_pack_line_with_confirmed_order(self): + """Test that unlinking a pack line from a confirmed sale order raises error.""" + pack_line = self._add_so_line() + self.assertEqual(len(self.sale_order.order_line), 3) + # Confirm the sale order + self.sale_order.action_confirm() + # Try to unlink the pack line - should raise error + with self.assertRaises(UserError): + pack_line.unlink() + + def test_unlink_non_pack_line_with_pack_lines(self): + """Test unlinking a non-pack line from an order with pack lines.""" + # Create a non-pack product line + product = self.env["product.product"].create({"name": "Test product"}) + non_pack_line = self.env["sale.order.line"].create( + { + "order_id": self.sale_order.id, + "name": product.name, + "product_id": product.id, + "product_uom_qty": 1, + "sequence": 5, + } + ) + # Add a pack line + self._add_so_line() + # Verify 4 lines are created (1 non-pack + 1 pack + 2 components) + self.assertEqual(len(self.sale_order.order_line), 4) + # Unlink the non-pack line + non_pack_line.unlink() + # Verify 3 lines remain (1 pack + 2 components) + self.assertEqual(len(self.sale_order.order_line), 3) + # Verify pack and component lines still exist + self.assertEqual(self.sale_order.order_line[0].product_id, self.pack) + self.assertEqual(self.sale_order.order_line[1].product_id, self.component1) + self.assertEqual(self.sale_order.order_line[2].product_id, self.component2) + + def test_unlink_pack_line_should_unlink_children(self): + """Unlinking the parent should remove children too.""" + pack_line = self._add_so_line() + self.assertEqual(len(self.sale_order.order_line), 3) + + self.sale_order.write({"order_line": [Command.delete(pack_line.id)]}) + + self.assertEqual(len(self.sale_order.order_line), 0) diff --git a/sale_product_pack/tests/test_sale_product_pack.py b/sale_product_pack/tests/test_sale_product_pack.py index 2868792d8..bcae455b8 100644 --- a/sale_product_pack/tests/test_sale_product_pack.py +++ b/sale_product_pack/tests/test_sale_product_pack.py @@ -2,6 +2,9 @@ # Copyright 2025 Tecnativa - Pedro M. Baeza # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.exceptions import UserError +from odoo.tests import Form + from .common import TestSaleProductPackBase @@ -137,3 +140,14 @@ def test_create_several_lines_02(self): self.assertEqual(self.sale_order.order_line[2].product_id, self.component1) self.assertEqual(self.sale_order.order_line[3].product_id, self.component2) self.assertEqual(self.sale_order.order_line[4].product_id, product) + + def test_message_assertions_for_quantity_change_on_child_order_line(self): + """At meats test one of the constraints that raises UserError when trying to + change the quantity of a component line directly.""" + pack_line = self._add_so_line() + component_line = self.sale_order.order_line.filtered( + lambda line: line.pack_parent_line_id == pack_line + )[0] + with Form(component_line) as line: + with self.assertRaises(UserError): + line.product_uom_qty = 10 diff --git a/sale_stock_product_pack/README.rst b/sale_stock_product_pack/README.rst index 9f1bc1d77..8080ac6db 100644 --- a/sale_stock_product_pack/README.rst +++ b/sale_stock_product_pack/README.rst @@ -6,7 +6,7 @@ Sale Stock Product Pack ======================= -.. +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! @@ -94,7 +94,7 @@ promote its widespread use. Current `maintainer `__: -|maintainer-pedrobaeza| +|maintainer-pedrobaeza| This module is part of the `OCA/product-pack `_ project on GitHub.