diff --git a/purchase_product_pack/README.rst b/purchase_product_pack/README.rst new file mode 100644 index 00000000..41f52e78 --- /dev/null +++ b/purchase_product_pack/README.rst @@ -0,0 +1,109 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Purchase Product Pack +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:174830ce41fbd8cb9d00dc617945dbe3dbdf80407c03659afcd2516798284699 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :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/19.0/purchase_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-19-0/product-pack-19-0-purchase_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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds *Product Pack* functionality to purchase orders. You +can choose a *Pack* in *purchase 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: + +1. Go to *Purchase > Products > Products*, create or select a product + and check *Is Pack?* +2. Set "Product type" and "Pack component price" fields in the *Pack* + page. +3. Add the products to be included in it. +4. Go to *Purchase > Orders > Quotations* and create a Quotation. +5. Add a product that has checked "Is Pack?" +6. 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 *purchase 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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- `Trobz `__: + + - Duong (Tran Quoc) + +Other credits +------------- + + + +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/product-pack `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/purchase_product_pack/__init__.py b/purchase_product_pack/__init__.py new file mode 100644 index 00000000..3d56623e --- /dev/null +++ b/purchase_product_pack/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import models diff --git a/purchase_product_pack/__manifest__.py b/purchase_product_pack/__manifest__.py new file mode 100644 index 00000000..78fbb78c --- /dev/null +++ b/purchase_product_pack/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) +{ + "name": "Purchase Product Pack", + "version": "19.0.1.0.0", + "category": "Purchase", + "summary": "This module allows you to buy product packs", + "website": "https://github.com/OCA/product-pack", + "author": "Camptocamp, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": ["product_pack", "purchase"], + "data": ["security/ir.model.access.csv", "views/product_pack_line_views.xml"], + "demo": [], + "installable": True, +} diff --git a/purchase_product_pack/i18n/it.po b/purchase_product_pack/i18n/it.po new file mode 100644 index 00000000..21e691d2 --- /dev/null +++ b/purchase_product_pack/i18n/it.po @@ -0,0 +1,156 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_product_pack +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-02-12 16:06+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth" +msgstr "Profondità" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth of the product if it is part of a pack." +msgstr "Profondità di un prodotto se fa parte di un collo." + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "Do No Expand Pack Lines" +msgstr "Non espandere le righe del collo" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_child_line_ids +msgid "Lines in pack" +msgstr "Righe nel collo" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_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 merging lines prices on pack (don't show component prices).\n" +"* Ignored: Use product pack price (ignore detail line prices)." +msgstr "" +"Negli ordini di vendita o di acquisto:\n" +"* Dettagliato per componente: dettaglio righe con prezzi.\n" +"* Totalizzate nel prodotto principale: dettaglio righe unendo i prezzi delle " +"righe nel collo (non mostra i prezzi dei componenti).\n" +"* Ignorate: usa il prezzo prodotto del collo (ignora il dettaglio prezzi " +"riga)." + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_type +msgid "" +"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." +msgstr "" +"Negli ordini di ventita o di acquisto:\n" +"* Dettagliati: visualizza i singoli componenti nell'ordine di vendita.\n" +"* Non detagliati: non visualizza i singoli componenti nell'ordine di vendita." + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "Pack" +msgstr "Pacco" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_component_price +msgid "Pack Component Price" +msgstr "Prezzo componente collo" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_type +msgid "Pack Display Type" +msgstr "Visualizza tipo collo" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "Pack Modifiable" +msgstr "Collo modificabile" + +#. module: purchase_product_pack +#: model_terms:ir.ui.view,arch_db:purchase_product_pack.purchase_order_form +msgid "Parent Pack is not modifiable" +msgstr "Il collo padre non è modificabIle" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +#, python-format +msgid "Parent Product" +msgstr "Prodotto padre" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_product +msgid "Product Variant" +msgstr "Variante prodotto" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_pack_line +msgid "Product pack line" +msgstr "Riga collo prodotto" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order +msgid "Purchase Order" +msgstr "Ordine di acquisto" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "Riga ordine di acquisto" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "The pack that contains this product." +msgstr "Il collo che contiene questo prodotto." + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "The parent pack is modifiable" +msgstr "Il collo padre è modificabile" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "" +"This is a technical field in order to check if pack lines has to be expanded" +msgstr "" +"Questo è un campo tecnico per controllare se le righe del collo devono " +"essere espanse" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +#, python-format +msgid "" +"You can not change this line because is part of a pack included in this " +"order" +msgstr "" +"Non si può modificare questa riga perché è parte di un collo incluso in " +"questo ordine" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order.py:0 +#, python-format +msgid "" +"You cannot delete this line because is part of a pack in this purchase " +"order. In order to delete this line you need to delete the pack itself" +msgstr "" +"Non si può cancellare questa riga perché è parte di un collo questo ordine " +"di vendita. Per poter cancellare questa riga bisogna cancellare il collo " +"stesso" diff --git a/purchase_product_pack/i18n/pt.po b/purchase_product_pack/i18n/pt.po new file mode 100644 index 00000000..a8fa5f3b --- /dev/null +++ b/purchase_product_pack/i18n/pt.po @@ -0,0 +1,157 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_product_pack +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2024-12-03 18:21+0000\n" +"Last-Translator: Peter Romão \n" +"Language-Team: none\n" +"Language: pt\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n > 1;\n" +"X-Generator: Weblate 5.6.2\n" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth" +msgstr "Profundidade" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth of the product if it is part of a pack." +msgstr "Profundidade de um artigo se for parte de uma composição." + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "Do No Expand Pack Lines" +msgstr "Não Expandir Linhas de Composições" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_child_line_ids +msgid "Lines in pack" +msgstr "Linhas na Composição" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_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 merging lines prices on pack (don't show component prices).\n" +"* Ignored: Use product pack price (ignore detail line prices)." +msgstr "" +"Em Documentos de venda ou de compra:\n" +"* Detalhado por componente: Linha de detalhes com preços.\n" +"* Totalizado no artigo principal: Linhas de detalhes fundindo os preços das " +"linhas na composição (não mostrar preços dos componentes).\n" +"* Ignorado: Usar o preço dos artigos compostos (ignorar preços das linhas de " +"detalhe)." + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_type +msgid "" +"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." +msgstr "" +"Em documentos de venda ou de compra:\n" +"* Detalhado: Exibir os componentes individualmente no documento de venda.\n" +"*Não Detalhado: Não exibir os componentes individualmente no documento de " +"venda." + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "Pack" +msgstr "Composição" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_component_price +msgid "Pack Component Price" +msgstr "Preço do Componente da Composição" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_type +msgid "Pack Display Type" +msgstr "Tipo de Exibição da Composição" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "Pack Modifiable" +msgstr "Composição Alterável" + +#. module: purchase_product_pack +#: model_terms:ir.ui.view,arch_db:purchase_product_pack.purchase_order_form +msgid "Parent Pack is not modifiable" +msgstr "A Composição Ascendente não é alterável" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +#, python-format +msgid "Parent Product" +msgstr "Artigo Ascendente" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_product +msgid "Product Variant" +msgstr "Variante de Artigo" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_pack_line +msgid "Product pack line" +msgstr "Linha do artigo composto" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order +msgid "Purchase Order" +msgstr "Documento de Compra" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "Linha do Documento de Compra" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "The pack that contains this product." +msgstr "A composição que contém este artigo." + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "The parent pack is modifiable" +msgstr "A composição ascendente é alterável" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "" +"This is a technical field in order to check if pack lines has to be expanded" +msgstr "" +"Este é um campo técnico para verificar se as linhas de composição devem ser " +"expandidas" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +#, python-format +msgid "" +"You can not change this line because is part of a pack included in this " +"order" +msgstr "" +"Não pode alterar esta linha porque faz parte de uma composição incluída " +"neste documento" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order.py:0 +#, python-format +msgid "" +"You cannot delete this line because is part of a pack in this purchase " +"order. In order to delete this line you need to delete the pack itself" +msgstr "" +"Não pode alterar esta linha porque é parte de uma composição incluída neste " +"documento de compra. Para remover esta linha deve remover a própria " +"composição" diff --git a/purchase_product_pack/i18n/purchase_product_pack.pot b/purchase_product_pack/i18n/purchase_product_pack.pot new file mode 100644 index 00000000..739fd1a9 --- /dev/null +++ b/purchase_product_pack/i18n/purchase_product_pack.pot @@ -0,0 +1,133 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * purchase_product_pack +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \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: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_depth +msgid "Depth of the product if it is part of a pack." +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "Do No Expand Pack Lines" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_child_line_ids +msgid "Lines in pack" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_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 merging lines prices on pack (don't show component prices).\n" +"* Ignored: Use product pack price (ignore detail line prices)." +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_type +msgid "" +"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." +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "Pack" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_component_price +msgid "Pack Component Price" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_type +msgid "Pack Display Type" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,field_description:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "Pack Modifiable" +msgstr "" + +#. module: purchase_product_pack +#: model_terms:ir.ui.view,arch_db:purchase_product_pack.purchase_order_form +msgid "Parent Pack is not modifiable" +msgstr "" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +msgid "Parent Product" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_product_pack_line +msgid "Product pack line" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order +msgid "Purchase Order" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model,name:purchase_product_pack.model_purchase_order_line +msgid "Purchase Order Line" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__do_no_expand_pack_lines +msgid "Technical field in order to check if pack lines has to be expanded" +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_parent_line_id +msgid "The pack that contains this product." +msgstr "" + +#. module: purchase_product_pack +#: model:ir.model.fields,help:purchase_product_pack.field_purchase_order_line__pack_modifiable +msgid "The parent pack is modifiable" +msgstr "" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order_line.py:0 +msgid "" +"You can not change this line because is part of a pack included in this " +"order" +msgstr "" + +#. module: purchase_product_pack +#. odoo-python +#: code:addons/purchase_product_pack/models/purchase_order.py:0 +msgid "" +"You cannot delete this line because is part of a pack in this purchase " +"order. In order to delete this line you need to delete the pack itself" +msgstr "" diff --git a/purchase_product_pack/models/__init__.py b/purchase_product_pack/models/__init__.py new file mode 100644 index 00000000..93b259ae --- /dev/null +++ b/purchase_product_pack/models/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import product_pack_line +from . import purchase_order_line +from . import purchase_order +from . import product_product diff --git a/purchase_product_pack/models/product_pack_line.py b/purchase_product_pack/models/product_pack_line.py new file mode 100644 index 00000000..2f479595 --- /dev/null +++ b/purchase_product_pack/models/product_pack_line.py @@ -0,0 +1,57 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import models + + +class ProductPackLine(models.Model): + _inherit = "product.pack.line" + + def get_purchase_order_line_vals(self, line, order): + self.ensure_one() + quantity = self.quantity * line.product_qty + line_vals = { + "order_id": order.id, + "sequence": line.sequence, + "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, + "pack_modifiable": line.product_id.pack_modifiable, + "product_qty": quantity, + } + pol = line.new(line_vals) + vals = pol._convert_to_write(pol._cache) + pack_price_types = {"totalized", "ignored"} + if ( + line.product_id.pack_type == "detailed" + and line.product_id.pack_component_price in pack_price_types + ): + vals["price_unit"] = 0.0 + vals.update( + { + "name": "{}{}".format( + "> " * (line.pack_depth + 1), pol.product_id.name + ), + } + ) + return vals + + def get_seller_cost(self, line): + """This function returns the cost of pack lines if they has seller or not""" + self.ensure_one() + if line: + params = {"order_id": line.order_id} + pack_line_seller = self.product_id._select_seller( + partner_id=line.partner_id, + quantity=self.quantity, + date=line.order_id.date_order and line.order_id.date_order.date(), + uom_id=line.product_uom_id, + params=params, + ) + return ( + pack_line_seller.price * self.quantity + if pack_line_seller + else self.product_id.standard_price * self.quantity + ) + return self.product_id.standard_price * self.quantity diff --git a/purchase_product_pack/models/product_product.py b/purchase_product_pack/models/product_product.py new file mode 100644 index 00000000..e96922b0 --- /dev/null +++ b/purchase_product_pack/models/product_product.py @@ -0,0 +1,16 @@ +from odoo import models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + def pack_cost_compute(self, line): + """This function computes the cost of a product based on the options on pack""" + packs, no_packs = self.split_pack_products() + prices = {} + for product in packs.with_context(prefetch_fields=False): + pack_price = 0.0 + for pack_line in product.sudo().pack_line_ids: + pack_price += pack_line.get_seller_cost(line) + prices[product.id] = pack_price + return prices diff --git a/purchase_product_pack/models/purchase_order.py b/purchase_product_pack/models/purchase_order.py new file mode 100644 index 00000000..5c8eb433 --- /dev/null +++ b/purchase_product_pack/models/purchase_order.py @@ -0,0 +1,75 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import api, models +from odoo.exceptions import UserError +from odoo.fields import Domain + + +class PurchaseOrder(models.Model): + _inherit = "purchase.order" + + def copy_data(self, default=None): + data_list = super().copy_data(default=default) + for data in data_list: + order_lines = data["order_line"] + new_order_lines = [] # Create a new list for modified order_lines + for order_line in order_lines: + pack_parent_id = order_line[2]["pack_parent_line_id"] + pack_parent = self.env["purchase.order.line"].browse(pack_parent_id) + if not (pack_parent and pack_parent.order_id == self): + # Add non-excluded lines to new_order_lines + new_order_lines.append(order_line) + # Update 'order_line' in the data dictionary + data["order_line"] = new_order_lines + return data_list + + @api.onchange("order_line") + def check_pack_line_unlink(self): + # At least on embeded list 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 = set(origin_line_ids) - set(line_ids) + removed_line = self.env["purchase.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( + self.env._( + "You cannot delete this line because is part of a pack in" + " this purchase order. In order to delete this line you need to" + " delete the pack itself" + ) + ) + + def write(self, vals): + if "order_line" in vals: + self._check_deleted_line(vals) + return super().write(vals) + + def _check_deleted_line(self, vals): + """ + When updating a purchase order, this method checks for deleted lines in + the 'order_line' field. If any purchase order lines are marked for deletion, + it also identifies and remove any subpack lines that are associated with + these deleted lines but not marked for deletion. + """ + to_delete_ids = [e[1] for e in vals["order_line"] if e[0] == 2] + subpacks_to_delete_ids = ( + self.env["purchase.order.line"] + .search( + Domain("id", "child_of", to_delete_ids) + & Domain("id", "not in", to_delete_ids) + ) + .ids + ) + if subpacks_to_delete_ids: + for cmd in vals["order_line"]: + if cmd[1] in subpacks_to_delete_ids: + if cmd[0] != 2: + cmd[0] = 2 + subpacks_to_delete_ids.remove(cmd[1]) + return True diff --git a/purchase_product_pack/models/purchase_order_line.py b/purchase_product_pack/models/purchase_order_line.py new file mode 100644 index 00000000..8e983716 --- /dev/null +++ b/purchase_product_pack/models/purchase_order_line.py @@ -0,0 +1,221 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import api, fields, models +from odoo.exceptions import UserError +from odoo.fields import Domain +from odoo.tools.float_utils import float_round + + +class PurchaseOrderLine(models.Model): + _inherit = "purchase.order.line" + _parent_name = "pack_parent_line_id" + + 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( + "purchase.order.line", + "Pack", + help="The pack that contains this product.", + ) + pack_child_line_ids = fields.One2many( + "purchase.order.line", "pack_parent_line_id", "Lines in pack" + ) + pack_modifiable = fields.Boolean(help="The parent pack is modifiable") + + do_no_expand_pack_lines = fields.Boolean( + compute="_compute_do_no_expand_pack_lines", + help="Technical field in order to check if pack lines has to be expanded", + ) + + @api.depends_context("update_prices", "update_pricelist") + def _compute_do_no_expand_pack_lines(self): + do_not_expand = self.env.context.get("update_prices") or self.env.context.get( + "update_pricelist", False + ) + self.update( + { + "do_no_expand_pack_lines": do_not_expand, + } + ) + + def expand_pack_line(self, write=False): + """ + Expand a purchase order line that represents a pack. + This method is used to expand a purchase order line that represents a pack. + It creates individual purchase order lines for the components of the pack + and adds them to the purchase order. + """ + self.ensure_one() + vals_list = [] + if self.product_id.pack_ok and self.pack_type == "detailed": + for subline in self.product_id.get_pack_lines(): + vals = subline.get_purchase_order_line_vals(self, self.order_id) + if write: + pack_child_lines = self.pack_child_line_ids.filtered( + lambda child, subline=subline: child.product_id + == subline.product_id + ) + # if subline already exists we update, if not we create + if existing_subline := pack_child_lines[:1]: + if self.do_no_expand_pack_lines: + vals.pop("product_uom_qty", None) + existing_subline.write(vals) + elif not self.do_no_expand_pack_lines: + vals_list.append(vals) + else: + vals_list.append(vals) + if vals_list: + self.create(vals_list) + + @api.model_create_multi + def create(self, vals_list): + new_vals = [] + res = self.browse() + prod_ids = [vals["product_id"] for vals in vals_list] + products = self.env["product.product"].browse(prod_ids) + for line_vals, product in zip(vals_list, products, strict=False): + if product and product.pack_ok and product.pack_type != "non_detailed": + line = super().create([line_vals]) + line.expand_pack_line() + res |= line + else: + new_vals.append(line_vals) + res |= super().create(new_vals) + return res + + def write(self, vals): + res = super().write(vals) + if "product_id" in vals or "product_qty" in vals: + for record in self: + record.expand_pack_line(write=True) + return res + + @api.onchange( + "product_id", + "product_uom_qty", + "product_uom_id", + "price_unit", + "name", + "tax_ids", + ) + def check_pack_line_modify(self): + """Do not let to edit a purchase 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" + ) + ) + + def action_open_parent_pack_product_view(self): + pack_parent_lines = self.mapped("pack_parent_line_id") + products = pack_parent_lines.mapped("product_id") + return { + "name": self.env._("Parent Product"), + "type": "ir.actions.act_window", + "res_model": "product.product", + "view_type": "form", + "view_mode": "list,form", + "domain": Domain("id", "in", products.ids), + } + + @api.depends("product_qty", "product_uom_id") + def _compute_price_unit_and_date_planned_and_name(self): + """ + This method extends the base '_compute_price_unit_and_date_planned_and_name' + to re-calculate the price unit following options on product-pack + """ + res = super()._compute_price_unit_and_date_planned_and_name() + for line in self: + if not line.product_id or line.invoice_lines: + continue + + params = {"order_id": line.order_id} + seller = line.product_id._select_seller( + partner_id=line.partner_id, + quantity=line.product_qty, + date=line.order_id.date_order and line.order_id.date_order.date(), + uom_id=line.product_uom_id, + params=params, + ) + + prices = line.product_id.pack_cost_compute(line) + # If not prices, no need to re-calculate the price units + if not prices: + continue + cost = prices[line.product_id.id] + # If not seller, use the standard price. It needs a proper + # currency conversion. + if not seller: + unavailable_seller = line.product_id.seller_ids.filtered( + lambda s, line=line: s.partner_id == line.order_id.partner_id + ) + if ( + not unavailable_seller + and line.price_unit + and line.product_uom_id == line._origin.product_uom_id + ): + # Avoid to modify the price unit if there is no price list + # for this partner and + # the line has already one to avoid to override + # unit price set manually. + continue + po_line_uom = line.product_uom_id or line.product_id.uom_po_id + # Using new cost to compute the price_unit + price_unit = line.env["account.tax"]._fix_tax_included_price_company( + line.product_id.uom_id._compute_price(cost, po_line_uom), + line.product_id.supplier_taxes_id, + line.tax_ids, + line.company_id, + ) + price_unit = line.product_id.currency_id._convert( + price_unit, + line.currency_id, + line.company_id, + line.date_order, + False, + ) + line.price_unit = float_round( + price_unit, + precision_digits=max( + line.currency_id.decimal_places, + self.env["decimal.precision"].precision_get("Product Price"), + ), + ) + continue + # Using new cost to compute the price unit + price_unit = ( + line.env["account.tax"]._fix_tax_included_price_company( + cost, + line.product_id.supplier_taxes_id, + line.tax_ids, + line.company_id, + ) + if seller + else 0.0 + ) + price_unit = seller.currency_id._convert( + price_unit, line.currency_id, line.company_id, line.date_order, False + ) + price_unit = float_round( + price_unit, + precision_digits=max( + line.currency_id.decimal_places, + self.env["decimal.precision"].precision_get("Product Price"), + ), + ) + line.price_unit = seller.product_uom_id._compute_price( + price_unit, line.product_uom_id + ) + return res diff --git a/purchase_product_pack/pyproject.toml b/purchase_product_pack/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/purchase_product_pack/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/purchase_product_pack/readme/CONTRIBUTORS.md b/purchase_product_pack/readme/CONTRIBUTORS.md new file mode 100644 index 00000000..60b95d40 --- /dev/null +++ b/purchase_product_pack/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Trobz](https://www.trobz.com): + - Duong (Tran Quoc) diff --git a/purchase_product_pack/readme/CREDITS.md b/purchase_product_pack/readme/CREDITS.md new file mode 100644 index 00000000..e69de29b diff --git a/purchase_product_pack/readme/DESCRIPTION.md b/purchase_product_pack/readme/DESCRIPTION.md new file mode 100644 index 00000000..d7c917a8 --- /dev/null +++ b/purchase_product_pack/readme/DESCRIPTION.md @@ -0,0 +1,4 @@ +This module adds *Product Pack* functionality to purchase orders. You +can choose a *Pack* in *purchase order lines* and see different +behaviors depending on "Pack type" and "Pack component price" fields +options selected on this *Pack*. diff --git a/purchase_product_pack/readme/USAGE.md b/purchase_product_pack/readme/USAGE.md new file mode 100644 index 00000000..1fbd33c0 --- /dev/null +++ b/purchase_product_pack/readme/USAGE.md @@ -0,0 +1,15 @@ +To use this module, you need to: + +1. Go to *Purchase \> Products \> Products*, create or select a product + and check *Is Pack?* +2. Set "Product type" and "Pack component price" fields in the *Pack* + page. +3. Add the products to be included in it. +4. Go to *Purchase \> Orders \> Quotations* and create a Quotation. +5. Add a product that has checked "Is Pack?" +6. 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 *purchase order line* + per component that belong to this Pack. (See *Product pack* module + README.rst file) diff --git a/purchase_product_pack/security/ir.model.access.csv b/purchase_product_pack/security/ir.model.access.csv new file mode 100644 index 00000000..4b79c86a --- /dev/null +++ b/purchase_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_purchase_manager,product.pack.line,model_product_pack_line,purchase.group_purchase_manager,1,1,1,1 diff --git a/purchase_product_pack/static/description/icon.png b/purchase_product_pack/static/description/icon.png new file mode 100644 index 00000000..3a0328b5 Binary files /dev/null and b/purchase_product_pack/static/description/icon.png differ diff --git a/purchase_product_pack/static/description/index.html b/purchase_product_pack/static/description/index.html new file mode 100644 index 00000000..1c37481f --- /dev/null +++ b/purchase_product_pack/static/description/index.html @@ -0,0 +1,459 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Purchase Product Pack

+ +

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

+

This module adds Product Pack functionality to purchase orders. You +can choose a Pack in purchase order lines and see different +behaviors depending on “Pack type” and “Pack component price” fields +options selected on this Pack.

+

Table of contents

+ +
+

Usage

+

To use this module, you need to:

+
    +
  1. Go to Purchase > Products > Products, create or select a product +and check Is Pack?
  2. +
  3. Set “Product type” and “Pack component price” fields in the Pack +page.
  4. +
  5. Add the products to be included in it.
  6. +
  7. Go to Purchase > Orders > Quotations and create a Quotation.
  8. +
  9. Add a product that has checked “Is Pack?”
  10. +
  11. 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 purchase order line per +component that belong to this Pack. (See Product pack module +README.rst file)
  12. +
+
+
+

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

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+
    +
  • Trobz:
      +
    • Duong (Tran Quoc)
    • +
    +
  • +
+
+ +
+

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/product-pack project on GitHub.

+

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

+
+
+
+
+ + diff --git a/purchase_product_pack/tests/__init__.py b/purchase_product_pack/tests/__init__.py new file mode 100644 index 00000000..d3ba0b78 --- /dev/null +++ b/purchase_product_pack/tests/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from . import test_purchase_product_pack diff --git a/purchase_product_pack/tests/common.py b/purchase_product_pack/tests/common.py new file mode 100644 index 00000000..6616307e --- /dev/null +++ b/purchase_product_pack/tests/common.py @@ -0,0 +1,29 @@ +# Copyright 2025 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.addons.product_pack.tests.common import ProductPackCommon + + +class TestPurchaseProductPackBase(ProductPackCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Test purchase pack"}) + cls.pack.standard_price = 5 + cls.component1.standard_price = 12 + cls.component2.standard_price = 18 + cls.purchase_order = cls.env["purchase.order"].create( + {"partner_id": cls.partner.id} + ) + + def _add_po_line(self, product=None, sequence=10): + product = product or self.pack + return self.env["purchase.order.line"].create( + { + "order_id": self.purchase_order.id, + "name": product.name, + "product_id": product.id, + "product_qty": 1, + "sequence": sequence, + } + ) diff --git a/purchase_product_pack/tests/test_purchase_product_pack.py b/purchase_product_pack/tests/test_purchase_product_pack.py new file mode 100644 index 00000000..fd7223dc --- /dev/null +++ b/purchase_product_pack/tests/test_purchase_product_pack.py @@ -0,0 +1,124 @@ +# Copyright 2023 Camptocamp SA +# Copyright 2025 Tecnativa - Pedro M. Baeza +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) + +from odoo import Command + +from .common import TestPurchaseProductPackBase + + +class TestPurchaseProductPack(TestPurchaseProductPackBase): + def test_create_components_cost_order_line(self): + self._add_po_line() + # After create, there will be three lines + self.assertEqual(len(self.purchase_order.order_line), 3) + # Check if sequence is the same as pack product one + for po_line in self.purchase_order.order_line: + self.assertEqual(po_line.sequence, 10) + # The products of those lines are the main product pack and its components + self.assertEqual(self.purchase_order.order_line[0].product_id, self.pack) + self.assertEqual(self.purchase_order.order_line[1].product_id, self.component1) + self.assertEqual(self.purchase_order.order_line[2].product_id, self.component2) + # Check the subtotal on lines + self.assertEqual(self.purchase_order.order_line[0].price_subtotal, 5) + self.assertEqual(self.purchase_order.order_line[1].price_subtotal, 24) + self.assertEqual(self.purchase_order.order_line[2].price_subtotal, 18) + + def test_create_ignored_cost_order_line(self): + self.pack.pack_component_price = "ignored" + self._add_po_line() + # After create, there will be four lines + self.assertEqual(len(self.purchase_order.order_line), 3) + # The products of those four lines are the main product pack and its + # product components + self.assertEqual(self.purchase_order.order_line[0].product_id, self.pack) + self.assertEqual(self.purchase_order.order_line[1].product_id, self.component1) + self.assertEqual(self.purchase_order.order_line[2].product_id, self.component2) + # All component lines have zero as subtotal + self.assertEqual(self.purchase_order.order_line[1].price_subtotal, 0) + self.assertEqual(self.purchase_order.order_line[2].price_subtotal, 0) + # Pack price is different from the sum of component prices + self.assertEqual(self.purchase_order.order_line[0].price_subtotal, 5) + + def test_create_totalized_cost_order_line(self): + self.pack.pack_component_price = "totalized" + self._add_po_line() + # After create, there will be four lines + self.assertEqual(len(self.purchase_order.order_line), 3) + # The products of those four lines are the main product pack and its + # product components + self.assertEqual(self.purchase_order.order_line[0].product_id, self.pack) + self.assertEqual(self.purchase_order.order_line[1].product_id, self.component1) + self.assertEqual(self.purchase_order.order_line[2].product_id, self.component2) + # All component lines have zero as subtotal + self.assertEqual(self.purchase_order.order_line[1].price_subtotal, 0) + self.assertEqual(self.purchase_order.order_line[2].price_subtotal, 0) + # Pack price is equal to the sum of component prices + self.assertEqual(self.purchase_order.order_line[0].price_subtotal, 42) + + def test_create_non_detailed_price_order_line(self): + self.pack.pack_type = "non_detailed" + self._add_po_line() + # After create, there will be only one line, because product_type is + # not a detailed one + self.assertEqual(len(self.purchase_order.order_line), 1) + # Pack price is equal to the sum of component prices + self.assertEqual(self.purchase_order.order_line.price_subtotal, 42) + + def test_update_qty(self): + pack_line = self._add_po_line() + # change qty of main sol and ensure all the quantities have doubled + pack_line.product_qty = 2 + self.assertAlmostEqual(self.purchase_order.order_line[1].product_qty, 4) + self.assertAlmostEqual(self.purchase_order.order_line[2].product_qty, 2) + # Confirm the sale + self.purchase_order.button_confirm() + # Ensure we can still update the quantity + pack_line.product_qty = 4 + self.assertAlmostEqual(self.purchase_order.order_line[1].product_qty, 8) + self.assertAlmostEqual(self.purchase_order.order_line[2].product_qty, 4) + + def test_do_not_expand(self): + pack_line = self._add_po_line() + pack_line_update = pack_line.with_context(update_prices=True) + self.assertTrue(pack_line_update.do_no_expand_pack_lines) + pack_line_update = pack_line.with_context(update_pricelist=True) + self.assertTrue(pack_line_update.do_no_expand_pack_lines) + + def test_create_several_lines(self): + # Create two sale order lines with two pack products + self._add_po_line() + self._add_po_line(sequence=20) + # Check 6 lines are created + self.assertEqual(len(self.purchase_order.order_line), 6) + # Check lines sequences and order are respected + for po_line in self.purchase_order.order_line[:3]: + self.assertEqual(po_line.sequence, 10) + for po_line in self.purchase_order.order_line[3:]: + self.assertEqual(po_line.sequence, 20) + + def test_order_line_detailed_with_seller(self): + self.pack.seller_ids = [ + Command.create({"partner_id": self.partner.id, "min_qty": 1, "price": 25}) + ] + self.component1.seller_ids = [ + Command.create({"partner_id": self.partner.id, "min_qty": 1, "price": 15}) + ] + self._add_po_line() + # Check the subtotal corresponding to seller on lines + self.assertEqual(self.purchase_order.order_line[0].price_subtotal, 25) + # 15 * 2 qty + self.assertEqual(self.purchase_order.order_line[1].price_subtotal, 30) + self.assertEqual(self.purchase_order.order_line[2].price_subtotal, 18) + + def test_order_line_totalized_with_seller(self): + self.component1.seller_ids = [ + Command.create({"partner_id": self.partner.id, "min_qty": 1, "price": 15}) + ] + self.pack.pack_component_price = "totalized" + self._add_po_line() + # Check the subtotal corresponding to seller on lines + # component 1: 15 * 2 qty + component2: 18 + self.assertEqual(self.purchase_order.order_line[0].price_subtotal, 48) + self.assertEqual(self.purchase_order.order_line[1].price_subtotal, 0) + self.assertEqual(self.purchase_order.order_line[2].price_subtotal, 0) diff --git a/purchase_product_pack/views/product_pack_line_views.xml b/purchase_product_pack/views/product_pack_line_views.xml new file mode 100644 index 00000000..c67efeb8 --- /dev/null +++ b/purchase_product_pack/views/product_pack_line_views.xml @@ -0,0 +1,25 @@ + + + + + purchase.order + + + + + +