From 6c71036ce44e4af7be6681f28823948907503a56 Mon Sep 17 00:00:00 2001
From: ernesto
Date: Thu, 19 Sep 2019 11:38:51 -0400
Subject: [PATCH 01/91] [ADD] sale_product_pack: new module
---
sale_product_pack/README.rst | 105 ++++
sale_product_pack/__init__.py | 2 +
sale_product_pack/__manifest__.py | 29 ++
.../demo/product_pack_line_demo.xml | 24 +
sale_product_pack/i18n/es.po | 109 +++++
sale_product_pack/models/__init__.py | 5 +
sale_product_pack/models/product_pack_line.py | 49 ++
sale_product_pack/models/sale_order.py | 31 ++
sale_product_pack/models/sale_order_line.py | 107 +++++
sale_product_pack/readme/CONTRIBUTORS.rst | 4 +
sale_product_pack/readme/DESCRIPTION.rst | 3 +
sale_product_pack/readme/USAGE.rst | 13 +
.../security/ir.model.access.csv | 2 +
.../static/description/index.html | 447 ++++++++++++++++++
sale_product_pack/tests/__init__.py | 3 +
.../tests/test_sale_product_pack.py | 103 ++++
.../views/product_pack_line_views.xml | 27 ++
17 files changed, 1063 insertions(+)
create mode 100644 sale_product_pack/README.rst
create mode 100644 sale_product_pack/__init__.py
create mode 100644 sale_product_pack/__manifest__.py
create mode 100644 sale_product_pack/demo/product_pack_line_demo.xml
create mode 100644 sale_product_pack/i18n/es.po
create mode 100644 sale_product_pack/models/__init__.py
create mode 100644 sale_product_pack/models/product_pack_line.py
create mode 100644 sale_product_pack/models/sale_order.py
create mode 100644 sale_product_pack/models/sale_order_line.py
create mode 100644 sale_product_pack/readme/CONTRIBUTORS.rst
create mode 100644 sale_product_pack/readme/DESCRIPTION.rst
create mode 100644 sale_product_pack/readme/USAGE.rst
create mode 100644 sale_product_pack/security/ir.model.access.csv
create mode 100644 sale_product_pack/static/description/index.html
create mode 100644 sale_product_pack/tests/__init__.py
create mode 100644 sale_product_pack/tests/test_sale_product_pack.py
create mode 100644 sale_product_pack/views/product_pack_line_views.xml
diff --git a/sale_product_pack/README.rst b/sale_product_pack/README.rst
new file mode 100644
index 000000000..636857066
--- /dev/null
+++ b/sale_product_pack/README.rst
@@ -0,0 +1,105 @@
+=================
+Sale product Pack
+=================
+
+.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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%2Fproduct--pack-lightgray.png?logo=github
+ :target: https://github.com/OCA/product-pack/tree/12.0/sale_product_pack
+ :alt: OCA/product-pack
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/product-pack-12-0/product-pack-12-0-sale_product_pack
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+ :target: https://runbot.odoo-community.org/runbot/286/12.0
+ :alt: Try me on Runbot
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+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 selected on this *Pack*.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Usage
+=====
+
+To use this module, you need to:
+
+#. Go to *Sales > Products > Products*, create or select a product and check
+ *Is Pack?*
+#. Set "Product type" and "Pack component price" fields in the *Pack* page.
+#. Add the products to be included in it.
+#. Go to *Sales > Orders > Quotations* and create a Quotation.
+#. Add a product that has checked "Is Pack?"
+#. Save data and you will see an specific behavior depending on "Pack type" and
+ "Pack component price" fields options selected on this *Pack*. For example,
+ for products that has *Detailed* option selected in "Pack type" field you
+ will see one *sale order line* per component that belong to this Pack.
+ (See *Product pack* module README.rst file)
+
+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 smashing it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* NaN·tic
+* ADHOC SA
+* Tecnativa
+
+Contributors
+~~~~~~~~~~~~
+
+* `Tecnativa `_:
+
+ * Ernesto Tejeda
+ * Pedro M. Baeza
+
+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.
+
+.. |maintainer-ernestotejeda| image:: https://github.com/ernestotejeda.png?size=40px
+ :target: https://github.com/ernestotejeda
+ :alt: ernestotejeda
+
+Current `maintainer `__:
+
+|maintainer-ernestotejeda|
+
+This module is part of the `OCA/product-pack `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/sale_product_pack/__init__.py b/sale_product_pack/__init__.py
new file mode 100644
index 000000000..cb45f2710
--- /dev/null
+++ b/sale_product_pack/__init__.py
@@ -0,0 +1,2 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from . import models
diff --git a/sale_product_pack/__manifest__.py b/sale_product_pack/__manifest__.py
new file mode 100644
index 000000000..a7d8117ab
--- /dev/null
+++ b/sale_product_pack/__manifest__.py
@@ -0,0 +1,29 @@
+# Copyright 2019 NaN (http://www.nan-tic.com) - Àngel Àlvarez
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+ 'name': 'Sale product Pack',
+ 'version': '12.0.1.0.0',
+ 'category': 'Sales',
+ 'summary': 'This module allows you to sale product packs',
+ 'website': 'https://github.com/OCA/product-pack',
+ 'author': 'NaN·tic, '
+ 'ADHOC SA, '
+ 'Tecnativa, '
+ 'Odoo Community Association (OCA)',
+ 'maintainers': ['ernestotejeda'],
+ 'license': 'AGPL-3',
+ 'depends': [
+ 'product_pack',
+ 'sale',
+ ],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/product_pack_line_views.xml',
+ ],
+ 'demo': [
+ 'demo/product_pack_line_demo.xml',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+ 'application': False,
+}
diff --git a/sale_product_pack/demo/product_pack_line_demo.xml b/sale_product_pack/demo/product_pack_line_demo.xml
new file mode 100644
index 000000000..0eb7f3fd3
--- /dev/null
+++ b/sale_product_pack/demo/product_pack_line_demo.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sale_product_pack/i18n/es.po b/sale_product_pack/i18n/es.po
new file mode 100644
index 000000000..d688f3c60
--- /dev/null
+++ b/sale_product_pack/i18n/es.po
@@ -0,0 +1,109 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * sale_product_pack
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 12.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2019-09-20 22:46+0000\n"
+"PO-Revision-Date: 2019-09-20 22:46+0000\n"
+"Last-Translator: <>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_sale_order_line__pack_depth
+msgid "Depth"
+msgstr "Profundidad"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,help:sale_product_pack.field_sale_order_line__pack_depth
+msgid "Depth of the product if it is part of a pack."
+msgstr "Profundidad del producto si forma parte de un pack."
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_sale_order_line__pack_child_line_ids
+msgid "Lines in pack"
+msgstr "Líneas en el pack"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,help:sale_product_pack.field_sale_order_line__pack_component_price
+msgid "On sale orders or purchase orders:\n"
+"* Detailed per component: Detail lines with prices.\n"
+"* Totalized in main product: Detail lines totalizing lines prices on pack (don't show component prices).\n"
+"* Ignored: Use product pack price (ignore detail line prices)."
+msgstr "En órdenes de venta u órdenes de compra:\n"
+"* Detallado por componente: Detalla las lineas con precios.\n"
+"* Totalizado en el producto principal: Detalla las lineas mezclando sus precios en el Pack (No muestra los precios de los componentes).\n"
+"* Ignorado: Se usa el precio el Pack (los precios de las lineas son ignorados)."
+
+#. module: sale_product_pack
+#: model:ir.model.fields,help:sale_product_pack.field_sale_order_line__pack_type
+msgid "On sale orders or purchase orders:\n"
+"* Detailed: Display components individually in the sale order.\n"
+"* None Detailed: Do not display components individually in the sale order."
+msgstr "En órdenes de venta u órdenes de compra:\n"
+"* Detallado: Muestra los componentes individualmente en lineas de la Orden de Venta/Orden de Compra\n"
+"* No Detallado: No muestra los componentes individualmente en lineas de la Orden de Venta/Orden de Compra"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_sale_order_line__pack_parent_line_id
+msgid "Pack"
+msgstr "Pack"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_sale_order_line__pack_type
+msgid "Pack Type"
+msgstr "Tipo de Pack"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_sale_order_line__pack_component_price
+msgid "Pack component price"
+msgstr "Precio de los componentes"
+
+#. module: sale_product_pack
+#: model:ir.model,name:sale_product_pack.model_product_product
+msgid "Product"
+msgstr "Producto"
+
+#. module: sale_product_pack
+#: model:ir.model,name:sale_product_pack.model_product_pack_line
+msgid "Product pack line"
+msgstr "Linea de pack"
+
+#. module: sale_product_pack
+#: model:ir.model,name:sale_product_pack.model_sale_order
+msgid "Sale Order"
+msgstr "Pedido de venta"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,field_description:sale_product_pack.field_product_pack_line__sale_discount
+msgid "Sale discount (%)"
+msgstr "Descuento para ventas"
+
+#. module: sale_product_pack
+#: model:ir.model,name:sale_product_pack.model_sale_order_line
+msgid "Sales Order Line"
+msgstr "Línea de pedido de venta"
+
+#. module: sale_product_pack
+#: model:ir.model.fields,help:sale_product_pack.field_sale_order_line__pack_parent_line_id
+msgid "The pack that contains this product."
+msgstr "El pack que contiene este producto."
+
+#. module: sale_product_pack
+#: code:addons/sale_product_pack/models/sale_order_line.py:79
+#, python-format
+msgid "You can not change this line because is part of a pack included in this order"
+msgstr "No puedes cambiar esta línea porque es parte de un pack incluido en este orden."
+
+#. module: sale_product_pack
+#: code:addons/sale_product_pack/models/sale_order.py:28
+#, python-format
+msgid "You can not 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"
+msgstr "No puede eliminar esta línea porque forma parte de un pack en esta orden de venta. Para eliminar esta línea necesitas eliminar el pack."
+
diff --git a/sale_product_pack/models/__init__.py b/sale_product_pack/models/__init__.py
new file mode 100644
index 000000000..cd9997169
--- /dev/null
+++ b/sale_product_pack/models/__init__.py
@@ -0,0 +1,5 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from . import product_pack_line
+from . import sale_order_line
+from . import sale_order
diff --git a/sale_product_pack/models/product_pack_line.py b/sale_product_pack/models/product_pack_line.py
new file mode 100644
index 000000000..9a6910692
--- /dev/null
+++ b/sale_product_pack/models/product_pack_line.py
@@ -0,0 +1,49 @@
+# Copyright 2019 Tecnativa - Ernesto Tejeda
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import api, fields, models
+import odoo.addons.decimal_precision as dp
+
+
+class ProductPack(models.Model):
+ _inherit = 'product.pack.line'
+
+ sale_discount = fields.Float(
+ 'Sale discount (%)',
+ digits=dp.get_precision('sale_discount'),
+ )
+
+ @api.multi
+ def get_sale_order_line_vals(self, line, order):
+ self.ensure_one()
+ quantity = self.quantity * line.product_uom_qty
+ line_vals = {
+ 'order_id': order.id,
+ 'product_id': self.product_id.id or False,
+ 'pack_parent_line_id': line.id,
+ 'pack_depth': line.pack_depth + 1,
+ 'company_id': order.company_id.id,
+ }
+ sol = line.new(line_vals)
+ sol.product_id_change()
+ sol.product_uom_qty = quantity
+ sol.product_uom_change()
+ sol._onchange_discount()
+ vals = sol._convert_to_write(sol._cache)
+
+ sale_discount = 0.0
+ if (line.product_id.pack_component_price == 'detailed'):
+ sale_discount = 100.0 - (
+ (100.0 - sol.discount) * (100.0 - self.sale_discount) / 100.0)
+
+ vals.update({
+ 'discount': sale_discount,
+ 'name': '%s%s' % (
+ '> ' * (line.pack_depth + 1), sol.name
+ ),
+ })
+ return vals
+
+ @api.multi
+ def get_price(self):
+ self.ensure_one()
+ return super().get_price() * (1 - self.sale_discount / 100.0)
diff --git a/sale_product_pack/models/sale_order.py b/sale_product_pack/models/sale_order.py
new file mode 100644
index 000000000..4301cd7fb
--- /dev/null
+++ b/sale_product_pack/models/sale_order.py
@@ -0,0 +1,31 @@
+# Copyright 2019 Tecnativa - Ernesto Tejeda
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import models, api, _
+from odoo.exceptions import UserError
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ @api.multi
+ def copy(self, default=None):
+ sale_copy = super().copy(default)
+ # we unlink pack lines that should not be copied
+ pack_copied_lines = sale_copy.order_line.filtered(
+ lambda l: l.pack_parent_line_id.order_id == self)
+ pack_copied_lines.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
+ """
+ if self._origin.order_line.filtered(
+ lambda x: x.pack_parent_line_id and
+ not x.pack_parent_line_id.product_id.pack_modifiable):
+ raise UserError(_(
+ 'You can not 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'))
diff --git a/sale_product_pack/models/sale_order_line.py b/sale_product_pack/models/sale_order_line.py
new file mode 100644
index 000000000..e66ceb09b
--- /dev/null
+++ b/sale_product_pack/models/sale_order_line.py
@@ -0,0 +1,107 @@
+# Copyright 2019 Tecnativa - Ernesto Tejeda
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from odoo import fields, models, api, _
+from odoo.exceptions import UserError
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ pack_type = fields.Selection(
+ related='product_id.pack_type',
+ )
+ pack_component_price = fields.Selection(
+ related='product_id.pack_component_price',
+ )
+
+ # Fields for common packs
+ pack_depth = fields.Integer(
+ 'Depth',
+ help='Depth of the product if it is part of a pack.'
+ )
+ pack_parent_line_id = fields.Many2one(
+ 'sale.order.line',
+ 'Pack',
+ help='The pack that contains this product.',
+ ondelete="cascade",
+ )
+ pack_child_line_ids = fields.One2many(
+ 'sale.order.line',
+ 'pack_parent_line_id',
+ 'Lines in pack'
+ )
+
+ @api.multi
+ def expand_pack_line(self, write=False):
+ self.ensure_one()
+ # if we are using update_pricelist or checking out on ecommerce we
+ # only want to update prices
+ do_not_expand = self._context.get('update_prices') or \
+ self._context.get('update_pricelist', False)
+ if (
+ self.state == 'draft' and
+ self.product_id.pack_ok and
+ self.pack_type == 'detailed'):
+ for subline in self.product_id.get_pack_lines():
+ vals = subline.get_sale_order_line_vals(self, self.order_id)
+ vals['sequence'] = self.sequence
+ if write:
+ existing_subline = self.search([
+ ('product_id', '=', subline.product_id.id),
+ ('pack_parent_line_id', '=', self.id),
+ ], limit=1)
+ # if subline already exists we update, if not we create
+ if existing_subline:
+ if do_not_expand:
+ vals.pop('product_uom_qty')
+ existing_subline.write(vals)
+ elif not do_not_expand:
+ self.create(vals)
+ else:
+ self.create(vals)
+
+ @api.model
+ def create(self, vals):
+ record = super().create(vals)
+ record.expand_pack_line()
+ return record
+
+ @api.multi
+ def write(self, vals):
+ super().write(vals)
+ if 'product_id' in vals or 'product_uom_qty' in vals:
+ for record in self:
+ record.expand_pack_line(write=True)
+
+ def _get_real_price_currency(
+ self, product, rule_id, qty, uom, pricelist_id):
+ new_list_price, currency_id = super()._get_real_price_currency(
+ product, rule_id, qty, uom, pricelist_id)
+ pack_types = {'totalized', 'ignored'}
+ parent_line = self.pack_parent_line_id
+ if parent_line and parent_line.pack_type == 'details' \
+ and parent_line.pack_component_price in pack_types:
+ new_list_price = 0.0
+ return new_list_price, currency_id
+
+ @api.onchange('product_id', 'product_uom_qty', 'product_uom', 'price_unit',
+ 'discount', 'name', 'tax_id')
+ 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_parent_line_id.product_id.pack_modifiable:
+ raise UserError(_(
+ 'You can not change this line because is part of a pack'
+ ' included in this order'))
+
+ @api.multi
+ def _get_display_price(self, product):
+ # We do this to clean the price if the parent of the
+ # component it's that type
+ pack_types = {'totalized', 'ignored'}
+ parent_line = self.pack_parent_line_id
+ if parent_line.pack_type == 'detailed' \
+ and parent_line.pack_component_price in pack_types:
+ return 0.0
+ return super()._get_display_price(product)
diff --git a/sale_product_pack/readme/CONTRIBUTORS.rst b/sale_product_pack/readme/CONTRIBUTORS.rst
new file mode 100644
index 000000000..b31cef321
--- /dev/null
+++ b/sale_product_pack/readme/CONTRIBUTORS.rst
@@ -0,0 +1,4 @@
+* `Tecnativa `_:
+
+ * Ernesto Tejeda
+ * Pedro M. Baeza
diff --git a/sale_product_pack/readme/DESCRIPTION.rst b/sale_product_pack/readme/DESCRIPTION.rst
new file mode 100644
index 000000000..46463c9b2
--- /dev/null
+++ b/sale_product_pack/readme/DESCRIPTION.rst
@@ -0,0 +1,3 @@
+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 selected on this *Pack*.
diff --git a/sale_product_pack/readme/USAGE.rst b/sale_product_pack/readme/USAGE.rst
new file mode 100644
index 000000000..2d6c8f2de
--- /dev/null
+++ b/sale_product_pack/readme/USAGE.rst
@@ -0,0 +1,13 @@
+To use this module, you need to:
+
+#. Go to *Sales > Products > Products*, create or select a product and check
+ *Is Pack?*
+#. Set "Product type" and "Pack component price" fields in the *Pack* page.
+#. Add the products to be included in it.
+#. Go to *Sales > Orders > Quotations* and create a Quotation.
+#. Add a product that has checked "Is Pack?"
+#. Save data and you will see an specific behavior depending on "Pack type" and
+ "Pack component price" fields options selected on this *Pack*. For example,
+ for products that has *Detailed* option selected in "Pack type" field you
+ will see one *sale order line* per component that belong to this Pack.
+ (See *Product pack* module README.rst file)
diff --git a/sale_product_pack/security/ir.model.access.csv b/sale_product_pack/security/ir.model.access.csv
new file mode 100644
index 000000000..6b968d84d
--- /dev/null
+++ b/sale_product_pack/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_product_pack_line_sale_manager,product.pack.line,model_product_pack_line,sales_team.group_sale_manager,1,1,1,1
diff --git a/sale_product_pack/static/description/index.html b/sale_product_pack/static/description/index.html
new file mode 100644
index 000000000..bc0710391
--- /dev/null
+++ b/sale_product_pack/static/description/index.html
@@ -0,0 +1,447 @@
+
+
+
+
+
+
+Sale product Pack
+
+
+
+
+
Sale product Pack
+
+
+

+
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 selected on this Pack.
+
Table of contents
+
+
+
+
To use this module, you need to:
+
+- Go to Sales > Products > Products, create or select a product and check
+Is Pack?
+- Set “Product type” and “Pack component price” fields in the Pack page.
+- Add the products to be included in it.
+- Go to Sales > Orders > Quotations and create a Quotation.
+- Add a product that has checked “Is Pack?”
+- Save data and you will see an specific behavior depending on “Pack type” and
+“Pack component price” fields options selected on this Pack. For example,
+for products that has Detailed option selected in “Pack type” field you
+will see one sale order line per component that belong to this Pack.
+(See Product pack module README.rst file)
+
+
+
+
+
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 smashing it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+- NaN·tic
+- ADHOC SA
+- Tecnativa
+
+
+
+
+
+- Tecnativa:
+- Ernesto Tejeda
+- Pedro M. Baeza
+
+
+
+
+
+
+
This module is maintained by the OCA.
+

+
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 maintainer:
+

+
This module is part of the OCA/product-pack project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
+
+
+
+
+
diff --git a/sale_product_pack/tests/__init__.py b/sale_product_pack/tests/__init__.py
new file mode 100644
index 000000000..365edcb3d
--- /dev/null
+++ b/sale_product_pack/tests/__init__.py
@@ -0,0 +1,3 @@
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from . import test_sale_product_pack
diff --git a/sale_product_pack/tests/test_sale_product_pack.py b/sale_product_pack/tests/test_sale_product_pack.py
new file mode 100644
index 000000000..2287e7ad5
--- /dev/null
+++ b/sale_product_pack/tests/test_sale_product_pack.py
@@ -0,0 +1,103 @@
+# Copyright 2019 Tecnativa - Ernesto Tejeda
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo.tests import SavepointCase
+
+
+class TestSaleProductPack(SavepointCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.sale_order = cls.env['sale.order'].create({
+ 'partner_id': cls.env.ref('base.res_partner_12').id,
+ })
+
+ def _get_component_prices_sum(self, product_pack):
+ component_prices = 0.0
+ for pack_line in product_pack.get_pack_lines():
+ product_line_price = pack_line.product_id.list_price * (
+ 1 - (pack_line.sale_discount or 0.0) / 100.0)
+ component_prices += (product_line_price * pack_line.quantity)
+ return component_prices
+
+ def test_create_components_price_order_line(self):
+ product_cp = self.env.ref(
+ 'product_pack.product_pack_cpu_detailed_components')
+ self.env['sale.order.line'].create({
+ 'order_id': self.sale_order.id,
+ 'name': product_cp.name,
+ 'product_id': product_cp.id,
+ 'product_uom_qty': 1,
+ })
+ # After create, there will be four lines
+ self.assertEqual(len(self.sale_order.order_line), 4)
+ # The products of those four lines are the main product pack and its
+ # product components
+ self.assertEqual(
+ self.sale_order.order_line.mapped("product_id"),
+ product_cp | product_cp.get_pack_lines().mapped("product_id"))
+
+ def test_create_ignored_price_order_line(self):
+ product_tp = self.env.ref(
+ 'product_pack.product_pack_cpu_detailed_ignored')
+ line = self.env['sale.order.line'].create({
+ 'order_id': self.sale_order.id,
+ 'name': product_tp.name,
+ 'product_id': product_tp.id,
+ 'product_uom_qty': 1,
+ })
+ # After create, there will be four lines
+ self.assertEqual(len(self.sale_order.order_line), 4)
+ # The products of those four lines are the main product pack and its
+ # product components
+ self.assertEqual(
+ self.sale_order.order_line.mapped("product_id"),
+ product_tp | product_tp.get_pack_lines().mapped("product_id"))
+ # All component lines have zero as subtotal
+ self.assertEqual(
+ (self.sale_order.order_line - line).mapped("price_subtotal"),
+ [0, 0, 0])
+ # Pack price is different from the sum of component prices
+ self.assertEqual(line.price_subtotal, 30.75)
+ self.assertNotEqual(self._get_component_prices_sum(product_tp), 30.75)
+
+ def test_create_totalized_price_order_line(self):
+ product_tp = self.env.ref(
+ 'product_pack.product_pack_cpu_detailed_totalized')
+ line = self.env['sale.order.line'].create({
+ 'order_id': self.sale_order.id,
+ 'name': product_tp.name,
+ 'product_id': product_tp.id,
+ 'product_uom_qty': 1,
+ })
+ # After create, there will be four lines
+ self.assertEqual(len(self.sale_order.order_line), 4)
+ # The products of those four lines are the main product pack and its
+ # product components
+ self.assertEqual(
+ self.sale_order.order_line.mapped("product_id"),
+ product_tp | product_tp.get_pack_lines().mapped("product_id"))
+ # All component lines have zero as subtotal
+ self.assertEqual(
+ (self.sale_order.order_line - line).mapped("price_subtotal"),
+ [0, 0, 0])
+ # Pack price is equal to the sum of component prices
+ self.assertEqual(line.price_subtotal, 2662.5)
+ self.assertEqual(self._get_component_prices_sum(product_tp), 2662.5)
+
+ def test_create_non_detailed_price_order_line(self):
+ product_ndtp = self.env.ref(
+ 'product_pack.product_pack_cpu_non_detailed')
+ line = self.env['sale.order.line'].create({
+ 'order_id': self.sale_order.id,
+ 'name': product_ndtp.name,
+ 'product_id': product_ndtp.id,
+ 'product_uom_qty': 1,
+ })
+ # After create, there will be only one line, because product_type is
+ # not a detailed one
+ self.assertEqual(self.sale_order.order_line, line)
+ # Pack price is equal to the sum of component prices
+ self.assertEqual(line.price_subtotal, 2662.5)
+ self.assertEqual(self._get_component_prices_sum(product_ndtp), 2662.5)
diff --git a/sale_product_pack/views/product_pack_line_views.xml b/sale_product_pack/views/product_pack_line_views.xml
new file mode 100644
index 000000000..f640e5e19
--- /dev/null
+++ b/sale_product_pack/views/product_pack_line_views.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ product.pack.line.sale.form
+ product.pack.line
+
+
+
+
+
+
+
+
+
+ product.pack.line.sale.tree
+ product.pack.line
+
+
+
+
+
+
+
+
From c5be2f185411ed06409d7260a8eb4d1daf4d8f95 Mon Sep 17 00:00:00 2001
From: OCA-git-bot
Date: Tue, 22 Oct 2019 15:55:16 +0000
Subject: [PATCH 02/91] [UPD] README.rst + [ADD] icon.png
[ADD] icon.png
---
sale_product_pack/static/description/icon.png | Bin 0 -> 9455 bytes
sale_product_pack/static/description/index.html | 2 +-
2 files changed, 1 insertion(+), 1 deletion(-)
create mode 100644 sale_product_pack/static/description/icon.png
diff --git a/sale_product_pack/static/description/icon.png b/sale_product_pack/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
diff --git a/sale_product_pack/static/description/index.html b/sale_product_pack/static/description/index.html
index bc0710391..1ea5ade67 100644
--- a/sale_product_pack/static/description/index.html
+++ b/sale_product_pack/static/description/index.html
@@ -3,7 +3,7 @@
-
+
Sale product Pack
-
-
Sale Product Pack
+
+
+
+
+
+
-
+
To use this module, you need to:
- Go to Sales > Products > Products, create or select a product and
@@ -407,7 +412,7 @@
-
+
- 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,
@@ -417,7 +422,7 @@
-
+
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
@@ -425,9 +430,9 @@
Do not contact contributors directly about support or help with technical issues.
-
+
-
+
- NaN·tic
- ADHOC SA
@@ -435,7 +440,7 @@
-
+
- Tecnativa:
- Ernesto Tejeda
@@ -463,7 +468,7 @@
-
+
This module is maintained by the OCA.
@@ -478,5 +483,6 @@
+
From 4889f065391efe4b491249caa3d597dba08c615a Mon Sep 17 00:00:00 2001
From: mgu
Date: Mon, 20 Oct 2025 09:36:51 +0200
Subject: [PATCH 90/91] [MIG] sale_product_pack: Migration to 19.0
---
sale_product_pack/README.rst | 14 +++++++++-----
sale_product_pack/__manifest__.py | 2 +-
sale_product_pack/models/product_pack_line.py | 1 -
sale_product_pack/models/sale_order.py | 4 ++--
sale_product_pack/models/sale_order_line.py | 15 +++++++--------
sale_product_pack/pyproject.toml | 8 +++++++-
sale_product_pack/readme/CONTRIBUTORS.md | 2 ++
sale_product_pack/static/description/index.html | 10 +++++++---
sale_product_pack/tests/common.py | 13 ++++++++++++-
sale_product_pack/tests/test_sale_product_pack.py | 2 +-
10 files changed, 48 insertions(+), 23 deletions(-)
diff --git a/sale_product_pack/README.rst b/sale_product_pack/README.rst
index f8a821371..3f3424d24 100644
--- a/sale_product_pack/README.rst
+++ b/sale_product_pack/README.rst
@@ -21,13 +21,13 @@ Sale Product Pack
: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
- :target: https://github.com/OCA/product-pack/tree/18.0/sale_product_pack
+ :target: https://github.com/OCA/product-pack/tree/19.0/sale_product_pack
:alt: OCA/product-pack
.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
- :target: https://translation.odoo-community.org/projects/product-pack-18-0/product-pack-18-0-sale_product_pack
+ :target: https://translation.odoo-community.org/projects/product-pack-19-0/product-pack-19-0-sale_product_pack
: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/product-pack&target_branch=18.0
+ :target: https://runboat.odoo-community.org/builds?repo=OCA/product-pack&target_branch=19.0
:alt: Try me on Runboat
|badge1| |badge2| |badge3| |badge4| |badge5|
@@ -75,7 +75,7 @@ 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 `_.
+`feedback `_.
Do not contact contributors directly about support or help with technical issues.
@@ -115,6 +115,10 @@ Contributors
- Augusto Weiss
- Nicolas Col
+- `Apik `__:
+
+ - Michel Guiheneuf
+
Maintainers
-----------
@@ -136,6 +140,6 @@ Current `maintainer `__:
|maintainer-victoralmau|
-This module is part of the `OCA/product-pack `_ project on GitHub.
+This module is part of the `OCA/product-pack `_ project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/sale_product_pack/__manifest__.py b/sale_product_pack/__manifest__.py
index b77439cb0..ee3fce997 100644
--- a/sale_product_pack/__manifest__.py
+++ b/sale_product_pack/__manifest__.py
@@ -2,7 +2,7 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
{
"name": "Sale Product Pack",
- "version": "18.0.1.0.2",
+ "version": "19.0.1.0.0",
"category": "Sales",
"summary": "This module allows you to sell product packs",
"website": "https://github.com/OCA/product-pack",
diff --git a/sale_product_pack/models/product_pack_line.py b/sale_product_pack/models/product_pack_line.py
index 31e034a11..cb4011087 100644
--- a/sale_product_pack/models/product_pack_line.py
+++ b/sale_product_pack/models/product_pack_line.py
@@ -25,7 +25,6 @@ def get_sale_order_line_vals(self, line, order):
"product_uom_qty": quantity,
}
sol = line.new(line_vals)
- sol._onchange_product_id_warning()
vals = sol._convert_to_write(sol._cache)
pack_price_types = {"totalized", "ignored"}
if (
diff --git a/sale_product_pack/models/sale_order.py b/sale_product_pack/models/sale_order.py
index 62baddc9b..9273b9898 100644
--- a/sale_product_pack/models/sale_order.py
+++ b/sale_product_pack/models/sale_order.py
@@ -1,6 +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 import api, models
from odoo.exceptions import UserError
@@ -31,7 +31,7 @@ def check_pack_line_unlink(self):
and not x.pack_parent_line_id.product_id.pack_modifiable
):
raise UserError(
- _(
+ self.env._(
"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"
diff --git a/sale_product_pack/models/sale_order_line.py b/sale_product_pack/models/sale_order_line.py
index 5ef3d19f5..d9a35b007 100644
--- a/sale_product_pack/models/sale_order_line.py
+++ b/sale_product_pack/models/sale_order_line.py
@@ -1,8 +1,7 @@
# Copyright 2019 Tecnativa - Ernesto Tejeda
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
-from odoo import _, api, fields, models
+from odoo import api, fields, models
from odoo.exceptions import UserError
-from odoo.fields import first
class SaleOrderLine(models.Model):
@@ -56,11 +55,11 @@ def expand_pack_line(self, write=False):
for subline in self.product_id.get_pack_lines():
vals = subline.get_sale_order_line_vals(self, self.order_id)
if write:
- existing_subline = first(
+ existing_subline = (
self.pack_child_line_ids.filtered(
lambda child, s=subline: child.product_id == s.product_id
)
- )
+ )[:1]
# if subline already exists we update, if not we create
if existing_subline:
if self.do_no_expand_pack_lines:
@@ -103,7 +102,7 @@ def write(self, vals):
@api.onchange(
"product_id",
"product_uom_qty",
- "product_uom",
+ "product_uom_id",
"price_unit",
"discount",
"name",
@@ -113,7 +112,7 @@ 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(
- _(
+ self.env._(
"You can not change this line because is part of a pack"
" included in this order"
)
@@ -124,7 +123,7 @@ def action_open_parent_pack_product_view(self):
("id", "in", self.mapped("pack_parent_line_id").mapped("product_id").ids)
]
return {
- "name": _("Parent Product"),
+ "name": self.env._("Parent Product"),
"type": "ir.actions.act_window",
"res_model": "product.product",
"view_type": "form",
@@ -161,7 +160,7 @@ def _get_pack_line_discount(self):
break
return discount
- @api.depends("product_id", "product_uom", "product_uom_qty")
+ @api.depends("product_id", "product_uom_id", "product_uom_qty")
def _compute_discount(self):
res = super()._compute_discount()
for pack_line in self.filtered("pack_parent_line_id"):
diff --git a/sale_product_pack/pyproject.toml b/sale_product_pack/pyproject.toml
index 4231d0ccc..f74e31420 100644
--- a/sale_product_pack/pyproject.toml
+++ b/sale_product_pack/pyproject.toml
@@ -1,3 +1,9 @@
[build-system]
requires = ["whool"]
-build-backend = "whool.buildapi"
+
+[project]
+name = "odoo-addons-oca-product-pak"
+version = "19.0.20251027.0"
+dependencies = [
+ "odoo-addon-product_pack @ git+https://github.com/OCA/product-pack.git@refs/pull/223/head#subdirectory=product_pack",
+]
diff --git a/sale_product_pack/readme/CONTRIBUTORS.md b/sale_product_pack/readme/CONTRIBUTORS.md
index f75511e77..caf04aee0 100644
--- a/sale_product_pack/readme/CONTRIBUTORS.md
+++ b/sale_product_pack/readme/CONTRIBUTORS.md
@@ -11,3 +11,5 @@
- Bruno Zanotti
- Augusto Weiss
- Nicolas Col
+- [Apik](https://apik.cloud/):
+ - Michel Guiheneuf
\ No newline at end of file
diff --git a/sale_product_pack/static/description/index.html b/sale_product_pack/static/description/index.html
index af72d19e4..8f2a614c8 100644
--- a/sale_product_pack/static/description/index.html
+++ b/sale_product_pack/static/description/index.html
@@ -374,7 +374,7 @@ Sale Product Pack
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!! source digest: sha256:d0034cc2b56fe98dabcd701ddded4a3adc81775fbf8857041c8b9ebd13eeb6ad
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
-

+

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
@@ -426,7 +426,7 @@
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.
+feedback.
Do not contact contributors directly about support or help with technical issues.
@@ -465,6 +465,10 @@
Nicolas Col
+
Apik:
+
@@ -478,7 +482,7 @@
promote its widespread use.
Current maintainer:

-
This module is part of the OCA/product-pack project on GitHub.
+
This module is part of the OCA/product-pack project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/sale_product_pack/tests/common.py b/sale_product_pack/tests/common.py
index b5ee22137..5e8bdeecc 100644
--- a/sale_product_pack/tests/common.py
+++ b/sale_product_pack/tests/common.py
@@ -40,10 +40,21 @@ def setUpClass(cls):
],
}
)
+ partner = cls.env["res.partner"].create(
+ {
+ "name": "Customer test",
+ "email": "test@test.example.com",
+ "phone": "+33 601 020 304",
+ "street": "Rue de la mairie",
+ "city": "New York",
+ "zip": "97648",
+ "website": "https://test.exemple.com",
+ }
+ )
cls.sale_order = cls.env["sale.order"].create(
{
"company_id": cls.env.company.id,
- "partner_id": cls.env.ref("base.res_partner_12").id,
+ "partner_id": partner.id,
"pricelist_id": pricelist.id,
}
)
diff --git a/sale_product_pack/tests/test_sale_product_pack.py b/sale_product_pack/tests/test_sale_product_pack.py
index 2868792d8..5e54cf30d 100644
--- a/sale_product_pack/tests/test_sale_product_pack.py
+++ b/sale_product_pack/tests/test_sale_product_pack.py
@@ -8,7 +8,7 @@
class TestSaleProductPack(TestSaleProductPackBase):
def test_create_components_price_order_line(self):
group_discount = self.env.ref("sale.group_discount_per_so_line")
- self.env.user.write({"groups_id": [(4, group_discount.id)]})
+ self.env.user.write({"group_ids": [(4, group_discount.id)]})
self._add_so_line()
# After create, there will be four lines
self.assertEqual(len(self.sale_order.order_line), 3)
From 78bb5dbcb9843431ef6b65bbf99a40e51a84cf32 Mon Sep 17 00:00:00 2001
From: Franco Leyes
Date: Thu, 13 Nov 2025 19:36:47 +0000
Subject: [PATCH 91/91] [IMP] product_pack,sale_product_pack: Allow modifiable
non-detailed packs
This change extends the pack_modifiable functionality to work with
non_detailed packs, allowing users to automatically expand packs into
editable component lines.
**Benefits:**
- Minimal code changes to existing modules
- No new models or complex intermediate structures needed
- Users can mark a non_detailed pack as modifiable and it will
automatically expand into editable detailed lines
- Uses Odoo's native section collapsing (collapse_composition) to keep
the interface clean and organized
- Component lines can be edited individually (quantities, prices, discounts)
**Use case:**
When a user creates a pack with pack_type='non_detailed' and
pack_modifiable=True, upon adding it to a sale order, it will
automatically transform into a collapsible section with detailed lines
that can be edited, providing flexibility while maintaining visual
organization.
---
product_pack/models/product_template.py | 140 ++++++++++++++++++
product_pack/tests/test_product_pack.py | 97 ++++++++++++
sale_product_pack/models/sale_order_line.py | 75 +++++++++-
.../tests/test_sale_product_pack.py | 26 ++++
4 files changed, 334 insertions(+), 4 deletions(-)
create mode 100644 product_pack/models/product_template.py
create mode 100644 product_pack/tests/test_product_pack.py
diff --git a/product_pack/models/product_template.py b/product_pack/models/product_template.py
new file mode 100644
index 000000000..8c5384946
--- /dev/null
+++ b/product_pack/models/product_template.py
@@ -0,0 +1,140 @@
+# Copyright 2019 Tecnativa - Ernesto Tejeda
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ pack_type = fields.Selection(
+ [("detailed", "Detailed"), ("non_detailed", "Non Detailed")],
+ string="Pack Display Type",
+ help="On sale orders or purchase orders:\n"
+ "* Detailed: Display components individually in the sale order.\n"
+ "* Non Detailed: Do not display components individually in the"
+ " sale order.",
+ )
+ pack_component_price = fields.Selection(
+ [
+ ("detailed", "Detailed per component"),
+ ("totalized", "Totalized in main product"),
+ ("ignored", "Ignored"),
+ ],
+ help="On sale orders or purchase orders:\n"
+ "* Detailed per component: Detail lines with prices.\n"
+ "* Totalized in main product: Detail lines merging "
+ "lines prices on pack (don't show component prices).\n"
+ "* Ignored: Use product pack price (ignore detail line prices).",
+ )
+ pack_ok = fields.Boolean(
+ "Is Pack?",
+ help="Is a Product Pack?",
+ )
+ pack_line_ids = fields.One2many(
+ related="product_variant_ids.pack_line_ids",
+ )
+ used_in_pack_line_ids = fields.One2many(
+ related="product_variant_ids.used_in_pack_line_ids",
+ readonly=True,
+ )
+ pack_modifiable = fields.Boolean(
+ help="If you check this field yo will be able to edit "
+ "sale/purchase order line relate to its component.\n"
+ "For 'Non Detailed' packs, this will automatically expand the pack "
+ "into editable detailed lines.",
+ )
+ pack_modifiable_invisible = fields.Boolean(
+ compute="_compute_pack_modifiable_invisible",
+ help="Technical field in order to compute the availability of the "
+ "Pack Modifiable field",
+ )
+
+ def _get_pack_modifiable_invisible_depends(self):
+ return ["pack_type", "pack_component_price"]
+
+ @api.depends(lambda self: self._get_pack_modifiable_invisible_depends())
+ def _compute_pack_modifiable_invisible(self):
+ """
+ The pack modifiable field is visible when:
+ - Pack Display Type is 'Detailed' and Pack Component Price is
+ 'Detailed per component'
+ - Pack Display Type is 'Non Detailed'
+ """
+ for product in self:
+ product.pack_modifiable_invisible = (
+ product.pack_type == "detailed"
+ and product.pack_component_price != "detailed"
+ )
+
+ @api.onchange("pack_type", "pack_component_price")
+ def onchange_pack_type(self):
+ products = self.filtered(
+ lambda x: x.pack_modifiable
+ and x.pack_type == "detailed"
+ and x.pack_component_price != "detailed"
+ )
+ for rec in products:
+ rec.pack_modifiable = False
+
+ @api.constrains("company_id", "product_variant_ids")
+ def _check_pack_line_company(self):
+ """Check packs are related to packs of same company."""
+ for rec in self:
+ for line in rec.pack_line_ids:
+ if (
+ line.product_id.company_id and rec.company_id
+ ) and line.product_id.company_id != rec.company_id:
+ raise ValidationError(
+ self.env._(
+ "Pack lines products company must be the same as the "
+ "parent product company"
+ )
+ )
+ for line in rec.used_in_pack_line_ids:
+ if (
+ line.product_id.company_id and rec.company_id
+ ) and line.parent_product_id.company_id != rec.company_id:
+ raise ValidationError(
+ self.env._(
+ "Pack lines products company must be the same as the "
+ "parent product company"
+ )
+ )
+
+ def write(self, vals):
+ """We remove from product.product to avoid error."""
+ _vals = vals.copy()
+ if vals.get("pack_line_ids", False):
+ self.product_variant_ids.write({"pack_line_ids": vals.get("pack_line_ids")})
+ _vals.pop("pack_line_ids")
+ return super().write(_vals)
+
+ def _is_pack_to_be_handled(self):
+ """Method for getting if a template is a computable pack.
+
+ :return: True or False.
+ """
+ self.ensure_one()
+ is_pack = False
+ if self.env.context.get("whole_pack_price"):
+ # We could need to check the price of the whole pack (e.g.: e-commerce)
+ is_pack = (
+ self.pack_ok
+ and self.pack_type == "detailed"
+ and self.pack_component_price == "detailed"
+ )
+ is_pack |= self.pack_ok and (
+ (self.pack_type == "detailed" and self.pack_component_price == "totalized")
+ or self.pack_type == "non_detailed"
+ )
+ return is_pack
+
+ def split_pack_products(self):
+ """Split products and the pack in 2 separate recordsets.
+
+ :return: [packs, no_packs]
+ """
+ packs = self.filtered(lambda p: p._is_pack_to_be_handled())
+ return packs, (self - packs)
diff --git a/product_pack/tests/test_product_pack.py b/product_pack/tests/test_product_pack.py
new file mode 100644
index 000000000..90e5ec048
--- /dev/null
+++ b/product_pack/tests/test_product_pack.py
@@ -0,0 +1,97 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from psycopg2 import IntegrityError
+
+from odoo import Command, exceptions
+from odoo.tests import Form
+from odoo.tools import mute_logger
+
+from .common import ProductPackCommon
+
+
+class TestProductPack(ProductPackCommon):
+ def test_product_pack_recursion(self):
+ """Add pack product in its pack lines and check the constraint raises."""
+ with self.assertRaises(exceptions.ValidationError):
+ self.pack.pack_line_ids = [
+ Command.create({"product_id": self.pack.id, "quantity": 1.0})
+ ]
+
+ @mute_logger("odoo.sql_db")
+ def test_product_in_pack_unique(self):
+ """Add product that is already in the pack and check the constraint raises."""
+ with self.assertRaises(IntegrityError), self.env.cr.savepoint():
+ self.pack.pack_line_ids = [
+ Command.create({"product_id": self.component1.id, "quantity": 1.0})
+ ]
+
+ def test_get_pack_line_price(self):
+ # Check pack line price from product one
+ self.component2.list_price = 30
+ self.assertEqual(
+ self.pack_line2._pack_line_price_compute("list_price")[self.component2.id],
+ 30,
+ )
+
+ def test_get_pack_lst_price(self):
+ """Check pack lst_price if totalized from components."""
+ self.pack.pack_component_price = "totalized"
+ self.assertEqual(self.pack.lst_price, 70)
+
+ def test_pack_company(self):
+ """Try to assign pack lines with product that do not belong to pack company."""
+ with self.assertRaises(exceptions.ValidationError), self.env.cr.savepoint():
+ self.component1.company_id = self.company_2
+
+ def test_pack_line_company(self):
+ """Try to assign pack lines with product that do not belong to pack company."""
+ with self.assertRaises(exceptions.ValidationError), self.env.cr.savepoint():
+ self.pack.company_id = self.company_2
+
+ def test_pack_type(self):
+ """Change pack type from detailed to non detailed."""
+ self.pack.pack_modifiable = True
+ with Form(self.pack.product_tmpl_id) as pack_form:
+ pack_form.pack_type = "non_detailed"
+ self.assertTrue(pack_form.pack_modifiable)
+
+ def test_pack_modifiable(self):
+ # Pack is detailed with component price as detailed
+ # Pack modifiable invisible should be False
+ self.assertFalse(self.pack.pack_modifiable_invisible)
+ # Set the Pack as non detailed
+ # Pack modifiable invisible should be False
+ self.pack.pack_type = "non_detailed"
+ self.assertFalse(self.pack.pack_modifiable_invisible)
+ # Set the Pack as detailed with component price as totalized
+ # Pack modifiable invisible should be True
+ self.pack.pack_type = "detailed"
+ self.pack.pack_component_price = "totalized"
+ self.assertTrue(self.pack.pack_modifiable_invisible)
+
+ def test_pack_price_with_pricelist_context_detailed(self):
+ price = self.pack.with_context(
+ whole_pack_price=True, pricelist=self.discount_pricelist.id
+ )._get_contextual_price()
+ self.assertEqual(price, 72) # 80 (10 + 70) with 10% discount
+
+ def test_pack_price_with_pricelist_context_totalized(self):
+ self.pack.pack_component_price = "totalized"
+ price = self.pack.with_context(
+ whole_pack_price=True, pricelist=self.discount_pricelist.id
+ )._get_contextual_price()
+ self.assertEqual(price, 63) # 70 with 10% discount
+
+ def test_pack_price_with_pricelist_context_ignored(self):
+ self.pack.pack_component_price = "ignored"
+ price = self.pack.with_context(
+ whole_pack_price=True, pricelist=self.discount_pricelist.id
+ )._get_contextual_price()
+ self.assertEqual(price, 9) # 10 with 10% discount
+
+ def test_pack_price_with_pricelist_context_non_detailed(self):
+ self.pack.pack_type = "non_detailed"
+ price = self.pack.with_context(
+ pricelist=self.discount_pricelist.id
+ )._get_contextual_price()
+ self.assertEqual(price, 63) # 70 with 10% discount
diff --git a/sale_product_pack/models/sale_order_line.py b/sale_product_pack/models/sale_order_line.py
index d9a35b007..f941fc30a 100644
--- a/sale_product_pack/models/sale_order_line.py
+++ b/sale_product_pack/models/sale_order_line.py
@@ -73,6 +73,52 @@ def expand_pack_line(self, write=False):
if vals_list:
self.create(vals_list)
+ def action_transform_pack_to_lines(self):
+ """
+ Transform non_detailed pack with pack_modifiable into detailed lines:
+ 1. Create a section line with the pack product name
+ 2. Create individual editable lines for each component with their
+ qty and discount
+ 3. Delete the original pack line
+ """
+ self.ensure_one()
+
+ if (
+ not self.product_id.pack_ok
+ or self.pack_type != "non_detailed"
+ or not self.product_id.pack_modifiable
+ ):
+ return self.env["sale.order.line"]
+
+ pack_name = self.product_id.display_name
+ pack_sequence = self.sequence
+ order = self.order_id
+
+ # Create section line for the pack
+ section_vals = {
+ "order_id": order.id,
+ "display_type": "line_section",
+ "name": pack_name,
+ "sequence": pack_sequence,
+ "collapse_composition": True,
+ }
+ section_line = self.env["sale.order.line"].create(section_vals)
+ created_lines = section_line
+
+ # Create editable component lines
+ for idx, pack_line in enumerate(self.product_id.get_pack_lines(), start=1):
+ component_vals = pack_line.get_sale_order_line_vals(self, order)
+ component_vals.update(
+ {
+ "sequence": pack_sequence + idx,
+ }
+ )
+ created_lines += self.env["sale.order.line"].create(component_vals)
+
+ # Delete the original pack line
+ self.unlink()
+ return created_lines
+
@api.model_create_multi
def create(self, vals_list):
"""Only when strictly necessary (a product is a pack) will be created line
@@ -80,14 +126,28 @@ def create(self, vals_list):
"""
product_ids = [elem.get("product_id") for elem in vals_list]
products = self.env["product.product"].browse(product_ids)
- if any(p.pack_ok and p.pack_type != "non_detailed" for p in products):
+ if any(
+ p.pack_ok
+ and (
+ p.pack_type == "detailed"
+ or (p.pack_type == "non_detailed" and p.pack_modifiable)
+ )
+ for p in products
+ ):
res = self.browse()
for elem in vals_list:
line = super().create([elem])
product = line.product_id
- res += line
- if product and product.pack_ok and product.pack_type != "non_detailed":
- line.expand_pack_line()
+ if product and product.pack_ok:
+ if product.pack_type == "detailed":
+ res += line
+ line.expand_pack_line()
+ elif (
+ product.pack_type == "non_detailed" and product.pack_modifiable
+ ):
+ res += line.action_transform_pack_to_lines()
+ else:
+ res += line
return res
else:
return super().create(vals_list)
@@ -97,6 +157,13 @@ def write(self, vals):
if "product_id" in vals or "product_uom_qty" in vals:
for record in self:
record.expand_pack_line(write=True)
+ if (
+ "product_id" in vals
+ and record.product_id.pack_ok
+ and record.pack_type == "non_detailed"
+ and record.product_id.pack_modifiable
+ ):
+ record.action_transform_pack_to_lines()
return res
@api.onchange(
diff --git a/sale_product_pack/tests/test_sale_product_pack.py b/sale_product_pack/tests/test_sale_product_pack.py
index 5e54cf30d..8634f2a71 100644
--- a/sale_product_pack/tests/test_sale_product_pack.py
+++ b/sale_product_pack/tests/test_sale_product_pack.py
@@ -137,3 +137,29 @@ 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_non_detailed_modifiable_pack(self):
+ """Test non_detailed pack with pack_modifiable auto-expands."""
+ # Configure pack as non_detailed with pack_modifiable
+ self.pack.pack_type = "non_detailed"
+ self.pack.pack_modifiable = True
+ self._add_so_line()
+ # After create, pack should be expanded into:
+ # 1 section line + 2 component lines
+ self.assertEqual(len(self.sale_order.order_line), 3)
+ # First line should be a section with pack name
+ section_line = self.sale_order.order_line[0]
+ self.assertEqual(section_line.display_type, "line_section")
+ self.assertEqual(section_line.name, self.pack.display_name)
+ self.assertTrue(section_line.collapse_composition)
+ # Next lines should be the components
+ self.assertEqual(self.sale_order.order_line[1].product_id, self.component1)
+ self.assertEqual(self.sale_order.order_line[2].product_id, self.component2)
+ # Component lines should have proper quantities and prices
+ self.assertEqual(self.sale_order.order_line[1].product_uom_qty, 2)
+ self.assertEqual(self.sale_order.order_line[2].product_uom_qty, 1)
+ self.assertAlmostEqual(self.sale_order.order_line[1].price_unit, 20)
+ self.assertAlmostEqual(self.sale_order.order_line[2].price_unit, 30)
+ # Lines should be editable (no pack_parent_line_id)
+ self.assertFalse(self.sale_order.order_line[1].pack_parent_line_id)
+ self.assertFalse(self.sale_order.order_line[2].pack_parent_line_id)