From 6629a73b95f9a4834a5fd929a813b60e6d81f36d Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Fri, 6 Feb 2026 13:59:54 -0500 Subject: [PATCH] [19.0][ADD] product_uom_packaging: replaces removed product.packaging Introduces product.uom.packaging model to bridge products, UoMs, and package types with product-specific package dimensions. Features: - product.uom.packaging model with template, variant, UoM, qty, and package type fields - Uniqueness constraints on packaging configuration and name - Variant-scoping support (template-wide or variant-specific) - Multi-company support with per-company isolation - Package type planning field on stock.move and stock.move.line - Weight and volume compatibility validation on stock.move.line - Incompatibility warning aggregation on stock.picking - Product form integration with computed inverse packaging_ids - Standalone list/form/search views with menu action --- product_uom_packaging/README.rst | 126 +++++ product_uom_packaging/__init__.py | 1 + product_uom_packaging/__manifest__.py | 35 ++ product_uom_packaging/models/__init__.py | 8 + .../models/product_product.py | 110 ++++ .../models/product_template.py | 13 + .../models/product_uom_packaging.py | 149 ++++++ product_uom_packaging/models/stock_move.py | 14 + .../models/stock_move_line.py | 73 +++ product_uom_packaging/models/stock_picking.py | 40 ++ product_uom_packaging/pyproject.toml | 6 + product_uom_packaging/readme/CONTRIBUTORS.md | 1 + product_uom_packaging/readme/DESCRIPTION.md | 18 + product_uom_packaging/readme/USAGE.md | 7 + .../security/ir.model.access.csv | 3 + .../static/description/index.html | 470 ++++++++++++++++++ .../tests/tours/package_warning_ui_test.js | 18 + product_uom_packaging/tests/__init__.py | 9 + ...ine_product_specific_package_dimensions.py | 187 +++++++ ...late_first_packaging_with_variant_scope.py | 184 +++++++ .../test_uc3_view_packaging_by_product.py | 106 ++++ .../test_uc4_product_form_integration.py | 227 +++++++++ .../tests/test_uc5_multi_company_support.py | 112 +++++ ...t_uc6_package_type_planning_stock_moves.py | 161 ++++++ ...st_uc7_package_type_validation_guidance.py | 466 +++++++++++++++++ .../views/product_product_views.xml | 99 ++++ .../views/product_uom_packaging_views.xml | 112 +++++ .../views/stock_move_line_views.xml | 84 ++++ .../views/stock_move_views.xml | 35 ++ .../views/stock_picking_views.xml | 20 + 30 files changed, 2894 insertions(+) create mode 100644 product_uom_packaging/README.rst create mode 100644 product_uom_packaging/__init__.py create mode 100644 product_uom_packaging/__manifest__.py create mode 100644 product_uom_packaging/models/__init__.py create mode 100644 product_uom_packaging/models/product_product.py create mode 100644 product_uom_packaging/models/product_template.py create mode 100644 product_uom_packaging/models/product_uom_packaging.py create mode 100644 product_uom_packaging/models/stock_move.py create mode 100644 product_uom_packaging/models/stock_move_line.py create mode 100644 product_uom_packaging/models/stock_picking.py create mode 100644 product_uom_packaging/pyproject.toml create mode 100644 product_uom_packaging/readme/CONTRIBUTORS.md create mode 100644 product_uom_packaging/readme/DESCRIPTION.md create mode 100644 product_uom_packaging/readme/USAGE.md create mode 100644 product_uom_packaging/security/ir.model.access.csv create mode 100644 product_uom_packaging/static/description/index.html create mode 100644 product_uom_packaging/static/tests/tours/package_warning_ui_test.js create mode 100644 product_uom_packaging/tests/__init__.py create mode 100644 product_uom_packaging/tests/test_uc1_define_product_specific_package_dimensions.py create mode 100644 product_uom_packaging/tests/test_uc2_template_first_packaging_with_variant_scope.py create mode 100644 product_uom_packaging/tests/test_uc3_view_packaging_by_product.py create mode 100644 product_uom_packaging/tests/test_uc4_product_form_integration.py create mode 100644 product_uom_packaging/tests/test_uc5_multi_company_support.py create mode 100644 product_uom_packaging/tests/test_uc6_package_type_planning_stock_moves.py create mode 100644 product_uom_packaging/tests/test_uc7_package_type_validation_guidance.py create mode 100644 product_uom_packaging/views/product_product_views.xml create mode 100644 product_uom_packaging/views/product_uom_packaging_views.xml create mode 100644 product_uom_packaging/views/stock_move_line_views.xml create mode 100644 product_uom_packaging/views/stock_move_views.xml create mode 100644 product_uom_packaging/views/stock_picking_views.xml diff --git a/product_uom_packaging/README.rst b/product_uom_packaging/README.rst new file mode 100644 index 00000000000..8a1ade08c00 --- /dev/null +++ b/product_uom_packaging/README.rst @@ -0,0 +1,126 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +===================== +Product UoM Packaging +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:78154503ae387e6d2fb01e10858678bea98a026add6ae6b94ccc2d4dd69c8737 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/19.0/product_uom_packaging + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-19-0/product-attribute-19-0-product_uom_packaging + :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-attribute&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides a bridge between products, units of measure, and +package types. + +In Odoo 19, the ``product.packaging`` model was removed and packaging +functionality was merged into the UoM system. However, UoMs are shared +across products, which means you cannot specify product-specific package +dimensions. + +This module introduces a ``product.uom.packaging`` model that allows you +to: + +- Define which package type (with physical dimensions) applies to a + specific product when sold/purchased in a specific UoM +- For example: "Product A in a 12-pack uses a 12x12x12 box" while + "Product B in a 12-pack uses a 6x12x8 box" + +The package type (from ``stock.package.type``) provides: + +- Physical dimensions (length, width, height) +- Weight limits (base weight, max weight) +- Barcode for the package type + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. Go to Inventory > Configuration > Products > Product UoM Packaging +2. Create a new record linking a product to a UoM and optionally a + package type +3. The package type defines the physical dimensions used when the + product is packaged in that UoM + +Alternatively, this information can be managed directly from the product +form (requires extending the product views, which may be done in a +separate module). + +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 +------- + +* Bemade Inc. + +Contributors +------------ + +- Marc Durepos marc@bemade.org, Bemade Inc. + +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-mdurepos| image:: https://github.com/mdurepos.png?size=40px + :target: https://github.com/mdurepos + :alt: mdurepos + +Current `maintainer `__: + +|maintainer-mdurepos| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_uom_packaging/__init__.py b/product_uom_packaging/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_uom_packaging/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_uom_packaging/__manifest__.py b/product_uom_packaging/__manifest__.py new file mode 100644 index 00000000000..99d153e05d2 --- /dev/null +++ b/product_uom_packaging/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2025 Bemade Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Product UoM Packaging", + "summary": """ + Link products to UoMs with package type for dimensions""", + "version": "19.0.1.0.0", + "development_status": "Alpha", + "license": "LGPL-3", + "author": "Bemade Inc., Odoo Community Association (OCA)", + "website": "https://github.com/OCA/product-attribute", + "maintainers": ["mdurepos"], + "depends": [ + "product", + "stock", + ], + "data": [ + "security/ir.model.access.csv", + "views/product_uom_packaging_views.xml", + "views/product_product_views.xml", + "views/stock_move_views.xml", + "views/stock_move_line_views.xml", + "views/stock_picking_views.xml", + ], + "assets": { + "web.assets_unit_tests": [ + "product_uom_packaging/static/tests/**/*", + ("remove", "product_uom_packaging/static/tests/tours/**/*"), + ], + "web.assets_tests": [ + "product_uom_packaging/static/tests/tours/**/*", + ], + }, +} diff --git a/product_uom_packaging/models/__init__.py b/product_uom_packaging/models/__init__.py new file mode 100644 index 00000000000..036cd89309d --- /dev/null +++ b/product_uom_packaging/models/__init__.py @@ -0,0 +1,8 @@ +from . import ( + product_uom_packaging, + product_template, + product_product, + stock_move, + stock_move_line, + stock_picking, +) diff --git a/product_uom_packaging/models/product_product.py b/product_uom_packaging/models/product_product.py new file mode 100644 index 00000000000..f53e1359574 --- /dev/null +++ b/product_uom_packaging/models/product_product.py @@ -0,0 +1,110 @@ +from odoo import api, fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + # Stored Many2many relationship (inverse of product_variant_ids) + variant_packaging_ids = fields.Many2many( + "product.uom.packaging", + relation="product_uom_packaging_variant_rel", + column1="product_id", + column2="packaging_id", + string="Variant-Specific Packaging", + help="Packaging configurations specifically for this variant.", + ) + + # Computed field for template-level packaging (read-only) + template_packaging_ids = fields.Many2many( + "product.uom.packaging", + compute="_compute_template_packaging_ids", + string="Template Packaging", + help="Packaging configurations inherited from product template.", + ) + + # Combined field (computed, read-only for display) + packaging_ids = fields.Many2many( + "product.uom.packaging", + compute="_compute_packaging_ids", + inverse="_inverse_packaging_ids", + string="All Packaging", + help="All packaging for this variant (template + variant).", + ) + + def action_open_template(self): + """Open the product template form view.""" + self.ensure_one() + return { + "type": "ir.actions.act_window", + "res_model": "product.template", + "res_id": self.product_tmpl_id.id, + "view_mode": "form", + "target": "current", + } + + @api.depends("product_tmpl_id.packaging_ids") + def _compute_template_packaging_ids(self): + """Compute template-level packaging configurations inherited by this variant.""" + for product in self: + template_configs = self.env["product.uom.packaging"].search( + [ + ("product_tmpl_id", "=", product.product_tmpl_id.id), + ("company_id", "=", self.env.company.id), + ("product_variant_ids", "=", False), + ] + ) + product.template_packaging_ids = template_configs + + @api.depends("template_packaging_ids", "variant_packaging_ids") + def _compute_packaging_ids(self): + """Compute all packaging configurations for this variant.""" + for product in self: + # Combine template-level and variant-level configs + product.packaging_ids = ( + product.template_packaging_ids + product.variant_packaging_ids + ) + + def _inverse_packaging_ids(self): + """ + Inverse function to handle changes to packaging configurations + on the product template but not related to any specific variants. + """ + for product in self: + # Get current template packaging (excluding variant-specific ones) + current_template_packaging = product.product_tmpl_id.packaging_ids.filtered( + lambda p: not p.product_variant_ids + ) + + # Get desired packaging from computed field (excl. variant-specific) + variant_packaging = product.variant_packaging_ids + desired_packaging = product.packaging_ids.filtered( + lambda p, vp=variant_packaging: p not in vp + ) + + # Find packaging to remove (in current but not in desired) + to_remove = current_template_packaging - desired_packaging + # Find packaging to add (in desired but not in current) + to_add = desired_packaging - current_template_packaging + + # Remove unwanted packaging + if to_remove: + to_remove.unlink() + + # Add new packaging by setting the correct template. Only clear + # product_variant_ids if it was already empty (template-level) + if to_add: + for packaging in to_add: + if not packaging.product_variant_ids: + packaging.write( + { + "product_tmpl_id": product.product_tmpl_id.id, + "product_variant_ids": False, + } + ) + else: + # This is variant-specific packaging, just set the template + packaging.write( + { + "product_tmpl_id": product.product_tmpl_id.id, + } + ) diff --git a/product_uom_packaging/models/product_template.py b/product_uom_packaging/models/product_template.py new file mode 100644 index 00000000000..4a127407805 --- /dev/null +++ b/product_uom_packaging/models/product_template.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + # Stored One2many for direct template-level packaging management + packaging_ids = fields.One2many( + "product.uom.packaging", + "product_tmpl_id", + string="Packaging Configurations", + help="Packaging configurations for this product template.", + ) diff --git a/product_uom_packaging/models/product_uom_packaging.py b/product_uom_packaging/models/product_uom_packaging.py new file mode 100644 index 00000000000..6f6fef8d7a0 --- /dev/null +++ b/product_uom_packaging/models/product_uom_packaging.py @@ -0,0 +1,149 @@ +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProductUomPackaging(models.Model): + _name = "product.uom.packaging" + _description = "Product UoM Packaging Configuration" + _order = "sequence, id" + + name = fields.Char( + help="Display name for the packaging configuration.", + required=True, + ) + sequence = fields.Integer( + default=10, + help="Order in which packaging configurations are displayed.", + ) + + # Core fields - template is required, variants are optional + product_tmpl_id = fields.Many2one( + "product.template", + "Product Template", + required=True, + index=True, + help="Product template this packaging configuration belongs to.", + ) + product_variant_ids = fields.Many2many( + comodel_name="product.product", + relation="product_uom_packaging_variant_rel", + column1="packaging_id", + column2="product_id", + string="Product Variants", + help="Limit to specific variants. If empty, applies to all variants.", + ) + + # Packaging details + uom_id = fields.Many2one( + "uom.uom", + "Unit of Measure", + required=True, + ondelete="restrict", + index=True, + help="Unit of measure for this packaging configuration.", + ) + qty = fields.Float( + "Qty per Package", + default=1.0, + required=True, + help="How many of the selected UoM fit inside one package. " + "For example, if the UoM is 'Pack of 6' and qty is 2, " + "the package contains 2 packs of 6 (12 units total).", + ) + package_type_id = fields.Many2one( + "stock.package.type", + "Package Type", + required=True, + ondelete="restrict", + help="Package type defines the physical dimensions of the packaging.", + ) + company_id = fields.Many2one( + "res.company", + "Company", + default=lambda self: self.env.company, + index=True, + help="Company this packaging configuration belongs to.", + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if not vals.get("name") and vals.get("package_type_id"): + package_type = self.env["stock.package.type"].browse( + vals["package_type_id"] + ) + vals["name"] = package_type.name + return super().create(vals_list) + + @api.onchange("package_type_id") + def _onchange_package_type_id(self): + """Default name to package type name when package type is set.""" + for record in self: + if record.package_type_id and not record.name: + record.name = record.package_type_id.name + + @api.constrains("product_tmpl_id", "product_variant_ids") + def _check_product_variant_ids(self): + """Ensure product_variant_ids belongs to the product_tmpl_id.""" + for record in self: + if record.product_variant_ids: + if any( + variant.product_tmpl_id != record.product_tmpl_id + for variant in record.product_variant_ids + ): + raise ValidationError( + self.env._( + "All product variants must belong to the same " + "product template." + ) + ) + + @api.constrains( + "product_tmpl_id", + "product_variant_ids", + "company_id", + "uom_id", + "package_type_id", + "qty", + ) + def _check_unique_packaging(self): + """Ensure only one packaging per template/UoM/qty/package_type/company.""" + for record in self: + template = record.product_tmpl_id + unicity_keys = [ + ( + r.product_tmpl_id.id, + r.uom_id.id, + r.company_id.id, + tuple(r.product_variant_ids.ids), + r.package_type_id.id, + r.qty, + ) + for r in template.packaging_ids + ] + if len(unicity_keys) != len(set(unicity_keys)): + raise ValidationError( + self.env._( + "Duplicate packaging configuration found for the same " + "product template, UoM, quantity, company, " + "and package type." + ) + ) + + @api.constrains("name", "product_tmpl_id", "company_id") + def _check_unique_name(self): + """Ensure packaging name is unique per product template and company.""" + for record in self: + template = record.product_tmpl_id + name_keys = [ + (r.name, r.product_tmpl_id.id, r.company_id.id) + for r in template.packaging_ids + if r.name + ] + if len(name_keys) != len(set(name_keys)): + raise ValidationError( + self.env._( + "Packaging name must be unique per product template " + "and company." + ) + ) diff --git a/product_uom_packaging/models/stock_move.py b/product_uom_packaging/models/stock_move.py new file mode 100644 index 00000000000..8983eb8f9a6 --- /dev/null +++ b/product_uom_packaging/models/stock_move.py @@ -0,0 +1,14 @@ +# Copyright 2025 Bemade Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + package_type_id = fields.Many2one( + "stock.package.type", + string="Intended Package Type", + help="Package type intended for this stock move (for planning and guidance).", + ) diff --git a/product_uom_packaging/models/stock_move_line.py b/product_uom_packaging/models/stock_move_line.py new file mode 100644 index 00000000000..17fb135cf99 --- /dev/null +++ b/product_uom_packaging/models/stock_move_line.py @@ -0,0 +1,73 @@ +# Copyright 2025 Bemade Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class StockMoveLine(models.Model): + _inherit = "stock.move.line" + + package_type_id = fields.Many2one( + "stock.package.type", + help="Package type for this stock move line.", + ) + + package_compatibility_warning = fields.Text( + compute="_compute_package_compatibility", + help="Warning message when product doesn't fit in package type.", + ) + + is_package_incompatible = fields.Boolean( + string="Package Incompatible", + compute="_compute_package_compatibility", + help="True when package type is incompatible with product.", + ) + + @api.depends("package_type_id", "product_id", "quantity", "product_uom_id") + def _compute_package_compatibility(self): + """Compute package compatibility warnings based on weight and volume + constraints""" + for line in self: + line.package_compatibility_warning = False + line.is_package_incompatible = False + + if not line.package_type_id or not line.product_id: + continue + + # Check weight compatibility + if line.package_type_id.max_weight > 0: + total_weight = line.product_id.weight * line.quantity + if total_weight > line.package_type_id.max_weight: + max_w = line.package_type_id.max_weight + line.package_compatibility_warning = ( + f"Weight incompatibility: Product weight" + f" ({total_weight:.1f} kg) exceeds" + f" package max weight ({max_w:.1f} kg)" + ) + line.is_package_incompatible = True + continue + + # Check volume compatibility (if package has dimensions) + if ( + line.package_type_id.height > 0 + and line.package_type_id.width > 0 + and line.package_type_id.packaging_length > 0 + ): + # Simple volume check - this is a basic implementation + # In practice, you might want more sophisticated volume calculations + package_volume = ( + line.package_type_id.height + * line.package_type_id.width + * line.package_type_id.packaging_length + ) + + # For now, just warn if product has volume info that seems incompatible + # This could be enhanced with actual product volume calculations + if hasattr(line.product_id, "volume") and line.product_id.volume > 0: + if line.product_id.volume > package_volume: + line.package_compatibility_warning = ( + f"Volume incompatibility: Product volume " + f"({line.product_id.volume:.1f}) " + f"exceeds package volume ({package_volume:.1f})" + ) + line.is_package_incompatible = True diff --git a/product_uom_packaging/models/stock_picking.py b/product_uom_packaging/models/stock_picking.py new file mode 100644 index 00000000000..2dee4d833ad --- /dev/null +++ b/product_uom_packaging/models/stock_picking.py @@ -0,0 +1,40 @@ +# Copyright 2025 Bemade Inc. +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + package_incompatibility_warning = fields.Text( + compute="_compute_package_incompatibility_warning", + help="Aggregated warning messages for package incompatibilities in move lines.", + ) + + has_package_incompatibility = fields.Boolean( + compute="_compute_package_incompatibility_warning", + help="True when any move line has package incompatibility.", + ) + + @api.depends( + "move_line_ids.is_package_incompatible", + "move_line_ids.package_compatibility_warning", + ) + def _compute_package_incompatibility_warning(self): + """Aggregate package compatibility warnings from all move lines""" + for picking in self: + warnings = [] + for line in picking.move_line_ids: + if line.is_package_incompatible and line.package_compatibility_warning: + product_name = line.product_id.display_name or "Unknown" + warnings.append( + f"• {product_name}: {line.package_compatibility_warning}" + ) + + if warnings: + picking.package_incompatibility_warning = "\n".join(warnings) + picking.has_package_incompatibility = True + else: + picking.package_incompatibility_warning = False + picking.has_package_incompatibility = False diff --git a/product_uom_packaging/pyproject.toml b/product_uom_packaging/pyproject.toml new file mode 100644 index 00000000000..bfe8ce7b71b --- /dev/null +++ b/product_uom_packaging/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "odoo-addon-product_uom_packaging" + +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_uom_packaging/readme/CONTRIBUTORS.md b/product_uom_packaging/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..1e238e5fa55 --- /dev/null +++ b/product_uom_packaging/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Marc Durepos , Bemade Inc. diff --git a/product_uom_packaging/readme/DESCRIPTION.md b/product_uom_packaging/readme/DESCRIPTION.md new file mode 100644 index 00000000000..77bbff4b43e --- /dev/null +++ b/product_uom_packaging/readme/DESCRIPTION.md @@ -0,0 +1,18 @@ +This module provides a bridge between products, units of measure, and package types. + +In Odoo 19, the `product.packaging` model was removed and packaging functionality +was merged into the UoM system. However, UoMs are shared across products, which +means you cannot specify product-specific package dimensions. + +This module introduces a `product.uom.packaging` model that allows you to: + +- Define which package type (with physical dimensions) applies to a specific + product when sold/purchased in a specific UoM +- For example: "Product A in a 12-pack uses a 12x12x12 box" while + "Product B in a 12-pack uses a 6x12x8 box" + +The package type (from `stock.package.type`) provides: + +- Physical dimensions (length, width, height) +- Weight limits (base weight, max weight) +- Barcode for the package type diff --git a/product_uom_packaging/readme/USAGE.md b/product_uom_packaging/readme/USAGE.md new file mode 100644 index 00000000000..4303559d83f --- /dev/null +++ b/product_uom_packaging/readme/USAGE.md @@ -0,0 +1,7 @@ +1. Go to Inventory > Configuration > Products > Product UoM Packaging +2. Create a new record linking a product to a UoM and optionally a package type +3. The package type defines the physical dimensions used when the product is + packaged in that UoM + +Alternatively, this information can be managed directly from the product form +(requires extending the product views, which may be done in a separate module). diff --git a/product_uom_packaging/security/ir.model.access.csv b/product_uom_packaging/security/ir.model.access.csv new file mode 100644 index 00000000000..7170e294326 --- /dev/null +++ b/product_uom_packaging/security/ir.model.access.csv @@ -0,0 +1,3 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_product_uom_packaging_user,product.uom.packaging.user,model_product_uom_packaging,base.group_user,1,0,0,0 +access_product_uom_packaging_manager,product.uom.packaging.manager,model_product_uom_packaging,stock.group_stock_manager,1,1,1,1 diff --git a/product_uom_packaging/static/description/index.html b/product_uom_packaging/static/description/index.html new file mode 100644 index 00000000000..ecd7a30afac --- /dev/null +++ b/product_uom_packaging/static/description/index.html @@ -0,0 +1,470 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product UoM Packaging

+ +

Alpha License: LGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

This module provides a bridge between products, units of measure, and +package types.

+

In Odoo 19, the product.packaging model was removed and packaging +functionality was merged into the UoM system. However, UoMs are shared +across products, which means you cannot specify product-specific package +dimensions.

+

This module introduces a product.uom.packaging model that allows you +to:

+
    +
  • Define which package type (with physical dimensions) applies to a +specific product when sold/purchased in a specific UoM
  • +
  • For example: “Product A in a 12-pack uses a 12x12x12 box” while +“Product B in a 12-pack uses a 6x12x8 box”
  • +
+

The package type (from stock.package.type) provides:

+
    +
  • Physical dimensions (length, width, height)
  • +
  • Weight limits (base weight, max weight)
  • +
  • Barcode for the package type
  • +
+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+
    +
  1. Go to Inventory > Configuration > Products > Product UoM Packaging
  2. +
  3. Create a new record linking a product to a UoM and optionally a +package type
  4. +
  5. The package type defines the physical dimensions used when the +product is packaged in that UoM
  6. +
+

Alternatively, this information can be managed directly from the product +form (requires extending the product views, which may be done in a +separate module).

+
+
+

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

+
    +
  • Bemade Inc.
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

mdurepos

+

This module is part of the OCA/product-attribute project on GitHub.

+

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

+
+
+
+
+ + diff --git a/product_uom_packaging/static/tests/tours/package_warning_ui_test.js b/product_uom_packaging/static/tests/tours/package_warning_ui_test.js new file mode 100644 index 00000000000..d55990aa196 --- /dev/null +++ b/product_uom_packaging/static/tests/tours/package_warning_ui_test.js @@ -0,0 +1,18 @@ +import {registry} from "@web/core/registry"; + +registry.category("web_tour.tours").add("test_package_warning_ui_display", { + steps: () => [ + { + trigger: ".o_form_view", + content: "Wait for picking form to load", + }, + { + // UC15: Package Type Validation Guidance + // The warning should be displayed when product weight exceeds package max_weight + // Look for alert-warning banner on the picking form + trigger: ".o_form_view .alert-warning:contains('Weight')", + content: + "Check if package compatibility warning banner is displayed on picking form", + }, + ], +}); diff --git a/product_uom_packaging/tests/__init__.py b/product_uom_packaging/tests/__init__.py new file mode 100644 index 00000000000..0233bb5df97 --- /dev/null +++ b/product_uom_packaging/tests/__init__.py @@ -0,0 +1,9 @@ +from . import ( + test_uc1_define_product_specific_package_dimensions, + test_uc2_template_first_packaging_with_variant_scope, + test_uc3_view_packaging_by_product, + test_uc4_product_form_integration, + test_uc5_multi_company_support, + test_uc6_package_type_planning_stock_moves, + test_uc7_package_type_validation_guidance, +) diff --git a/product_uom_packaging/tests/test_uc1_define_product_specific_package_dimensions.py b/product_uom_packaging/tests/test_uc1_define_product_specific_package_dimensions.py new file mode 100644 index 00000000000..8fea35f2a51 --- /dev/null +++ b/product_uom_packaging/tests/test_uc1_define_product_specific_package_dimensions.py @@ -0,0 +1,187 @@ +""" +Test UC1: Define Product-Specific Package Dimensions + +As a warehouse manager, I want to specify which package type +(with dimensions) applies to a product in a given UoM +so that shipping costs and storage planning are accurate. + +Acceptance Criteria: +- Can create a link between product, UoM, and package type +- Quantity defaults to 1.0 +- Same product/UoM/package_type with different qty is allowed (e.g. half/full pallet) +- Duplicate product/UoM/package_type/qty is rejected +- Name defaults to package type name, is user-editable, unique per product/company +""" + +from odoo.exceptions import ValidationError +from odoo.tests import Form +from odoo.tests.common import TransactionCase +from odoo.tools.misc import mute_logger + + +class TestUC1DefineProductSpecificPackageDimensions(TransactionCase): + """Test UC1: Define Product-Specific Package Dimensions""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + # Products + cls.product_bricks = cls.env["product.product"].create({"name": "Bricks"}) + cls.product_soda = cls.env["product.product"].create({"name": "Soda Cans"}) + + # UoMs + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + # Package Types with different dimensions + cls.package_box_12x12x12 = cls.env["stock.package.type"].create( + { + "name": "12x12x12 Box", + "packaging_length": 12, + "width": 12, + "height": 12, + "base_weight": 0.8, + "max_weight": 15, + } + ) + cls.package_box_6x12x8 = cls.env["stock.package.type"].create( + { + "name": "6x12x8 Box", + "packaging_length": 6, + "width": 12, + "height": 8, + "base_weight": 0.3, + "max_weight": 8, + } + ) + + def test_quantity_defaults_to_one(self): + """UC1: Quantity field defaults to 1.0.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_6x12x8.id, + } + ) + self.assertEqual(packaging.qty, 1.0) + + def test_same_package_type_different_quantities(self): + """UC1: Same product/UoM/package_type with different qty is allowed. + + E.g. "Half-Pallet" (16 bricks) and "Full-Pallet" (32 bricks) + both use the same pallet type. + """ + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 16.0, + "name": "Half Pallet", + } + ) + full_pallet = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 32.0, + "name": "Full Pallet", + } + ) + self.assertTrue(full_pallet.exists()) + + @mute_logger("odoo.sql_db") + def test_cannot_duplicate_product_uom_package_type_qty(self): + """UC1: Cannot create duplicate product/UoM/package_type/qty combination.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 12.0, + } + ) + with self._assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 12.0, + } + ) + + def test_name_defaults_to_package_type_name(self): + """UC1: Name defaults to the package type's name.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_box_12x12x12.id, + } + ) + self.assertEqual(packaging.name, self.package_box_12x12x12.name) + + def test_name_is_user_editable(self): + """UC1: Name can be overridden by the user via the form view.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + } + ) + with Form(packaging) as f: + f.name = "Full Pallet" + self.assertEqual(packaging.name, "Full Pallet") + + @mute_logger("odoo.sql_db") + def test_name_unique_per_product_company(self): + """UC1: Cannot have duplicate names for the same product/company.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "name": "Full Pallet", + "qty": 32.0, + } + ) + with self._assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "name": "Full Pallet", + "qty": 16.0, + } + ) + + @mute_logger("odoo.sql_db") + def test_cannot_duplicate_config_even_with_different_names(self): + """UC1: Duplicate product/UoM/package_type/qty/company rejected even + when names differ.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 10.0, + "name": "Name A", + } + ) + with self._assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_bricks.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_box_12x12x12.id, + "qty": 10.0, + "name": "Name B", + } + ) diff --git a/product_uom_packaging/tests/test_uc2_template_first_packaging_with_variant_scope.py b/product_uom_packaging/tests/test_uc2_template_first_packaging_with_variant_scope.py new file mode 100644 index 00000000000..ffe903634a2 --- /dev/null +++ b/product_uom_packaging/tests/test_uc2_template_first_packaging_with_variant_scope.py @@ -0,0 +1,184 @@ +""" +Test UC2: Template-First Packaging with Variant Scope Limiting + +As a product manager, I want to define packaging at the template level +with optional variant-specific scope limiting so that variants can share + common packaging while allowing specific overrides. + +Acceptance Criteria: +- Every packaging configuration has a product_tmpl_id (required) +- Optionally has product_variant_ids to limit scope to specific variants +- If product_variant_ids is empty → applies to ALL variants of the template +- If product_variant_ids has variants → applies ONLY to those specific variants +- Cannot create duplicate template/UoM/package_type/qty combinations +- All selected variants must belong to the specified product template +""" + +from psycopg2 import IntegrityError + +from odoo.exceptions import ValidationError +from odoo.fields import Command +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + + +class TestUC2TemplateFirstPackagingWithVariantScope(TransactionCase): + """Test UC2: Template-First Packaging with Variant Scope Limiting""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + # Create attribute and values for variant generation + cls.attr_size = cls.env["product.attribute"].create({"name": "Size"}) + cls.attr_val_small = cls.env["product.attribute.value"].create( + {"name": "Small", "attribute_id": cls.attr_size.id} + ) + cls.attr_val_large = cls.env["product.attribute.value"].create( + {"name": "Large", "attribute_id": cls.attr_size.id} + ) + + # Create a template with multiple variants + cls.template = cls.env["product.template"].create( + { + "name": "Multi-Variant Product", + "attribute_line_ids": [ + Command.create( + { + "attribute_id": cls.attr_size.id, + "value_ids": [ + Command.set( + [cls.attr_val_small.id, cls.attr_val_large.id] + ) + ], + } + ) + ], + } + ) + + # Get the generated variants + cls.variants = cls.template.product_variant_ids + cls.variant_small = cls.variants[0] + cls.variant_large = cls.variants[1] + + # UoMs + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + # Package Types + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + + @mute_logger("odoo.sql_db") + def test_packaging_configuration_requires_template(self): + """UC2: Every packaging configuration has a product_tmpl_id (required).""" + with self.assertRaises(IntegrityError): + self.ProductUomPackaging.create( + { + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + } + ) + + def test_empty_variant_ids_applies_to_all_variants(self): + """UC2: Empty product_variant_ids means packaging applies to all variants.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + } + ) + self.assertFalse(packaging.product_variant_ids) + + def test_non_empty_variant_ids_limits_scope(self): + """UC2: Non-empty product_variant_ids limits scope to those variants.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "product_variant_ids": [Command.set([self.variant_small.id])], + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + self.assertEqual(packaging.product_variant_ids, self.variant_small) + + def test_variants_must_belong_to_template(self): + """UC2: All selected variants must belong to the specified product template.""" + other_template = self.env["product.template"].create({"name": "Other Product"}) + other_variant = other_template.product_variant_ids[0] + + with self.assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "product_variant_ids": [Command.set([other_variant.id])], + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + + def test_duplicate_template_uom_package_type_qty_rejected(self): + """UC2: Cannot create duplicate template/UoM/package_type/qty combinations.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + "qty": 12.0, + } + ) + with self.assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + "qty": 12.0, + } + ) + + def test_template_and_variant_specific_can_coexist(self): + """UC2: Template-level and variant-specific packaging can coexist.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + "name": "Small Box (Dozen)", + } + ) + variant_packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "product_variant_ids": [Command.set([self.variant_small.id])], + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + "name": "Small Box (Unit)", + } + ) + self.assertTrue(variant_packaging.product_variant_ids) + + def test_multiple_variants_in_single_packaging(self): + """UC2: Single packaging can apply to multiple variants.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.template.id, + "product_variant_ids": [ + Command.set([self.variant_small.id, self.variant_large.id]) + ], + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + self.assertEqual(len(packaging.product_variant_ids), 2) diff --git a/product_uom_packaging/tests/test_uc3_view_packaging_by_product.py b/product_uom_packaging/tests/test_uc3_view_packaging_by_product.py new file mode 100644 index 00000000000..6daa0f1d0ce --- /dev/null +++ b/product_uom_packaging/tests/test_uc3_view_packaging_by_product.py @@ -0,0 +1,106 @@ +""" +Test UC3: Packaging Views & Navigation + +As a warehouse planner, I want standalone views for packaging configurations +so that I can browse, search, and manage them outside of the product form. + +Acceptance Criteria: +- Standalone form view exposes all key fields (name, product, UoM, qty, + package type, sequence, company) +- Search view allows filtering by product, UoM, and package type +- Menu action exists under Inventory > Configuration +- Fields are editable via the standalone form +""" + +from lxml import etree + +from odoo.tests import Form +from odoo.tests.common import TransactionCase + + +class TestUC3PackagingViews(TransactionCase): + """Test UC3: Packaging Views & Navigation""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + cls.product = cls.env["product.product"].create({"name": "Product A"}) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + + def test_form_view_has_expected_fields(self): + """UC3: Standalone form view exposes all key fields.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + with Form(packaging) as f: + self.assertTrue(hasattr(f, "name")) + self.assertTrue(hasattr(f, "product_tmpl_id")) + self.assertTrue(hasattr(f, "uom_id")) + self.assertTrue(hasattr(f, "qty")) + self.assertTrue(hasattr(f, "package_type_id")) + self.assertTrue(hasattr(f, "sequence")) + + def test_form_view_fields_are_editable(self): + """UC3: Key fields can be edited via the standalone form.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + with Form(packaging) as f: + f.name = "Custom Name" + f.uom_id = self.uom_dozen + f.qty = 24.0 + f.sequence = 5 + + self.assertEqual(packaging.name, "Custom Name") + self.assertEqual(packaging.uom_id, self.uom_dozen) + self.assertEqual(packaging.qty, 24.0) + self.assertEqual(packaging.sequence, 5) + + def test_search_view_has_filter_fields(self): + """UC3: Search view allows filtering by product, UoM, and package type.""" + views = self.ProductUomPackaging.get_views( + [(False, "search")], + ) + arch = etree.fromstring(views["views"]["search"]["arch"]) + field_names = [f.get("name") for f in arch.findall(".//field")] + self.assertIn("product_tmpl_id", field_names) + self.assertIn("uom_id", field_names) + self.assertIn("package_type_id", field_names) + + def test_list_view_has_expected_columns(self): + """UC3: List view shows name, product, UoM, qty, and package type.""" + views = self.ProductUomPackaging.get_views( + [(False, "list")], + ) + arch = etree.fromstring(views["views"]["list"]["arch"]) + field_names = [f.get("name") for f in arch.findall(".//field")] + for expected in ("name", "product_tmpl_id", "uom_id", "qty", "package_type_id"): + self.assertIn(expected, field_names) + + def test_menu_action_exists(self): + """UC3: Menu action opens the packaging list/form views.""" + action = self.env.ref("product_uom_packaging.product_uom_packaging_action") + self.assertEqual(action.res_model, "product.uom.packaging") + self.assertIn("list", action.view_mode) + self.assertIn("form", action.view_mode) diff --git a/product_uom_packaging/tests/test_uc4_product_form_integration.py b/product_uom_packaging/tests/test_uc4_product_form_integration.py new file mode 100644 index 00000000000..52742152e3a --- /dev/null +++ b/product_uom_packaging/tests/test_uc4_product_form_integration.py @@ -0,0 +1,227 @@ +""" +Test UC4: Product Form Integration + +As a product manager, I want to see and manage packaging configurations +on the product form's inventory tab +so that I can manage all packaging settings from one place. + +Acceptance Criteria: +- Template form shows packaging_ids as editable inline list +- Can create template-level and variant-specific packaging via form +- Variant form shows packaging_ids (read-only) +- Inverse updates product_tmpl_id when adding packaging +- action_open_template navigates to the template form +""" + +from typing import cast + +from odoo.fields import Command +from odoo.tests import Form, O2MProxy +from odoo.tests.common import TransactionCase + + +class TestUC4ProductFormIntegration(TransactionCase): + """Test UC4: Product Form Integration""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + cls.product_a = cls.env["product.product"].create({"name": "Product A"}) + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + cls.package_large = cls.env["stock.package.type"].create( + { + "name": "Large Box", + "packaging_length": 30, + "width": 20, + "height": 15, + "base_weight": 1.0, + "max_weight": 25, + } + ) + + def _create_multi_variant_template(self): + """Helper to create a template with Small/Large variants.""" + attr_size = self.env["product.attribute"].create({"name": "Size"}) + val_small = self.env["product.attribute.value"].create( + {"name": "Small", "attribute_id": attr_size.id} + ) + val_large = self.env["product.attribute.value"].create( + {"name": "Large", "attribute_id": attr_size.id} + ) + template = self.env["product.template"].create( + { + "name": "Multi-Variant Product", + "uom_id": self.uom_unit.id, + "attribute_line_ids": [ + Command.create( + { + "attribute_id": attr_size.id, + "value_ids": [Command.set([val_small.id, val_large.id])], + } + ) + ], + } + ) + return template + + def test_product_form_shows_packaging_ids(self): + """UC4: Product variant form exposes packaging_ids.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + with Form(self.product_a) as f: + self.assertEqual(len(f.packaging_ids), 1) + + def test_template_form_shows_packaging_ids(self): + """UC4: Product template form exposes packaging_ids as editable list.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + with Form(self.product_a.product_tmpl_id) as f: + self.assertEqual(len(f.packaging_ids), 1) + + def test_template_form_can_create_packaging(self): + """UC4: Can create packaging inline on the template form.""" + template = self._create_multi_variant_template() + + with Form(template) as f: + with cast(O2MProxy, f.packaging_ids).new() as line: + line.uom_id = self.uom_dozen + line.qty = 12.0 + line.package_type_id = self.package_large + f.save() + + packaging = self.ProductUomPackaging.search( + [("product_tmpl_id", "=", template.id)] + ) + self.assertEqual(len(packaging), 1) + self.assertFalse(packaging.product_variant_ids) + + def test_template_form_can_create_variant_specific_packaging(self): + """UC4: Can create variant-specific packaging inline on the template form.""" + template = self._create_multi_variant_template() + variant_small = template.product_variant_ids[0] + + with Form(template) as f: + with cast(O2MProxy, f.packaging_ids).new() as line: + line.uom_id = self.uom_unit + line.package_type_id = self.package_small + line.product_variant_ids.add(variant_small) + f.save() + + packaging = self.ProductUomPackaging.search( + [("product_tmpl_id", "=", template.id)] + ) + self.assertEqual(packaging.product_variant_ids, variant_small) + + def test_template_form_shows_variant_identification(self): + """UC4: Template form shows variant_ids for variant-scoped packaging.""" + template = self._create_multi_variant_template() + variant_small = template.product_variant_ids[0] + + self.ProductUomPackaging.create( + { + "product_tmpl_id": template.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + "name": "Small Box (All)", + } + ) + self.ProductUomPackaging.create( + { + "product_tmpl_id": template.id, + "product_variant_ids": [Command.set([variant_small.id])], + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_large.id, + } + ) + + with Form(template) as f: + self.assertEqual(len(f.packaging_ids), 2) + line_0 = cast(O2MProxy, f.packaging_ids).edit(0) + self.assertFalse(line_0.product_variant_ids) + line_1 = cast(O2MProxy, f.packaging_ids).edit(1) + self.assertTrue(line_1.product_variant_ids) + + def test_product_form_can_delete_packaging(self): + """UC4: Can delete packaging from the product form.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + with Form(self.product_a) as f: + f.packaging_ids.remove(index=0) + f.save() + self.assertFalse(packaging.exists()) + + def test_inverse_updates_template(self): + """UC4: Inverse updates product_tmpl_id when adding packaging.""" + product_b = self.env["product.product"].create({"name": "Product B"}) + other_packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": product_b.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_small.id, + } + ) + self.product_a.packaging_ids = self.product_a.packaging_ids + other_packaging + self.assertEqual( + other_packaging.product_tmpl_id, self.product_a.product_tmpl_id + ) + + def test_inverse_updates_template_for_variant_specific_packaging(self): + """UC4: Inverse sets product_tmpl_id for variant-specific packaging + without clearing product_variant_ids.""" + template = self._create_multi_variant_template() + variant_small = template.product_variant_ids[0] + variant_large = template.product_variant_ids[1] + + # Create packaging scoped to variant_large on this template + variant_packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": template.id, + "product_variant_ids": [Command.set([variant_large.id])], + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_large.id, + } + ) + + # Directly assign it into variant_small's packaging_ids. + # variant_packaging is NOT in variant_small.variant_packaging_ids, + # but it HAS product_variant_ids set, so the inverse else branch fires. + variant_small.packaging_ids = variant_small.packaging_ids + variant_packaging + + # The inverse should have set product_tmpl_id without clearing + # product_variant_ids (the else branch). + self.assertEqual(variant_packaging.product_tmpl_id, template) + self.assertTrue(variant_packaging.product_variant_ids) + + def test_action_open_template(self): + """UC4: action_open_template returns correct action.""" + action = self.product_a.action_open_template() + self.assertEqual(action["res_model"], "product.template") + self.assertEqual(action["res_id"], self.product_a.product_tmpl_id.id) diff --git a/product_uom_packaging/tests/test_uc5_multi_company_support.py b/product_uom_packaging/tests/test_uc5_multi_company_support.py new file mode 100644 index 00000000000..b78d7779920 --- /dev/null +++ b/product_uom_packaging/tests/test_uc5_multi_company_support.py @@ -0,0 +1,112 @@ +""" +Test UC5: Multi-Company Support + +As a multi-company user, I want to have different packaging configurations per company +so that each warehouse/company can define their own packaging standards. + +Acceptance Criteria: +- Company field on packaging configuration +- Unique constraint includes company +- Records are filtered by current company in standard views +""" + +from odoo.exceptions import ValidationError +from odoo.tests import Form +from odoo.tests.common import TransactionCase +from odoo.tools.misc import mute_logger + + +class TestUC5MultiCompanySupport(TransactionCase): + """Test UC5: Multi-Company Support""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + # Products + cls.product_a = cls.env["product.product"].create({"name": "Product A"}) + + # UoMs + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + # Companies + cls.company_main = cls.env.company + cls.company_other = cls.env["res.company"].create({"name": "Other Company"}) + + # Package Types + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + + def test_default_company_assignment(self): + """UC5: New records default to current user's company.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + } + ) + self.assertEqual(packaging.company_id, self.env.company) + + def test_same_config_allowed_for_different_companies(self): + """UC5: Same template/UoM/package_type can exist for different companies.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "company_id": self.company_main.id, + "package_type_id": self.package_small.id, + } + ) + packaging_other = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "company_id": self.company_other.id, + "package_type_id": self.package_small.id, + } + ) + self.assertEqual(packaging_other.company_id, self.company_other) + + def test_company_field_in_form(self): + """UC5: company_id is accessible on the packaging form.""" + packaging = self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + } + ) + with Form(packaging) as f: + self.assertTrue(hasattr(f, "company_id")) + + @mute_logger("odoo.sql_db") + def test_duplicate_within_same_company_rejected(self): + """UC5: Cannot create duplicate template/UoM/package_type + within same company.""" + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "company_id": self.company_main.id, + "package_type_id": self.package_small.id, + } + ) + with self._assertRaises(ValidationError): + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "company_id": self.company_main.id, + "package_type_id": self.package_small.id, + } + ) diff --git a/product_uom_packaging/tests/test_uc6_package_type_planning_stock_moves.py b/product_uom_packaging/tests/test_uc6_package_type_planning_stock_moves.py new file mode 100644 index 00000000000..6975c2cc6b7 --- /dev/null +++ b/product_uom_packaging/tests/test_uc6_package_type_planning_stock_moves.py @@ -0,0 +1,161 @@ +""" +Test UC6: Package Type Planning on Stock Moves + +As a warehouse planner, I want to specify the intended package type on stock moves +so that pickers know what packaging to use for the transfer. + +Acceptance Criteria: +- Stock move form shows package type field (optional) +- Package type dropdown filtered by product's packaging configurations +- Package type defaults from product packaging configuration (lowest sequence) +- Package type is informational only - doesn't affect reservation logic +- Can leave empty if no specific packaging required +""" + +from odoo.tests import Form +from odoo.tests.common import TransactionCase + + +class TestUC6PackageTypePlanningStockMoves(TransactionCase): + """Test UC6: Package Type Planning on Stock Moves""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + # Products + cls.product_a = cls.env["product.product"].create({"name": "Product A"}) + + # UoMs + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + cls.uom_dozen = cls.env.ref("uom.product_uom_dozen") + + # Package Types + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + cls.package_large = cls.env["stock.package.type"].create( + { + "name": "Large Box", + "packaging_length": 20, + "width": 15, + "height": 12, + "base_weight": 1.0, + "max_weight": 25, + } + ) + + # Stock locations + cls.location_stock = cls.env.ref("stock.stock_location_stock") + cls.location_customers = cls.env.ref("stock.stock_location_customers") + + def test_stock_move_form_shows_package_type_field(self): + """UC6: Stock move form shows package type field (optional).""" + # Create a stock move + stock_move = self.env["stock.move"].create( + { + "product_id": self.product_a.id, + "product_uom": self.uom_unit.id, + "product_uom_qty": 10.0, + "location_id": self.location_stock.id, + "location_dest_id": self.location_customers.id, + } + ) + with Form(stock_move) as move_form: + move_form.package_type_id = self.package_small + + def test_package_type_dropdown_filtered_by_product_packaging(self): + """ + UC6: Package type dropdown filtered by product's packaging configurations. + """ + # Create packaging configurations for product A + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_dozen.id, + "package_type_id": self.package_small.id, + "sequence": 10, + } + ) + + self.ProductUomPackaging.create( + { + "product_tmpl_id": self.product_a.product_tmpl_id.id, + "uom_id": self.uom_unit.id, + "package_type_id": self.package_large.id, + "sequence": 5, + } + ) + + # Create stock move + stock_move = self.env["stock.move"].create( + { + "product_id": self.product_a.id, + "product_uom": self.uom_unit.id, + "product_uom_qty": 10.0, + "location_id": self.location_stock.id, + "location_dest_id": self.location_customers.id, + } + ) + + # Verify that both package types from product packaging configs + # can be set on the stock move + product_package_types = self.product_a.product_tmpl_id.packaging_ids.mapped( + "package_type_id" + ) + self.assertIn(self.package_small, product_package_types) + self.assertIn(self.package_large, product_package_types) + + # Verify package_type_id can be set on the move form + with Form(stock_move) as move_form: + move_form.package_type_id = self.package_small + self.assertEqual(stock_move.package_type_id, self.package_small) + + with Form(stock_move) as move_form: + move_form.package_type_id = self.package_large + self.assertEqual(stock_move.package_type_id, self.package_large) + + def test_move_line_form_shows_package_type(self): + """UC6: Package type appears in stock move line form view.""" + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.env.ref("stock.picking_type_out").id, + "partner_id": self.env["res.partner"] + .create({"name": "Test Customer"}) + .id, + } + ) + + move = self.env["stock.move"].create( + { + "picking_id": picking.id, + "product_id": self.product_a.id, + "product_uom": self.uom_unit.id, + "location_id": self.location_stock.id, + "location_dest_id": self.location_customers.id, + } + ) + + move_line = self.env["stock.move.line"].create( + { + "move_id": move.id, + "product_id": self.product_a.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": self.package_small.id, + } + ) + + with Form(move_line) as f: + self.assertEqual(f.package_type_id, self.package_small) + f.package_type_id = self.env["stock.package.type"] + + self.assertFalse(move_line.package_type_id) diff --git a/product_uom_packaging/tests/test_uc7_package_type_validation_guidance.py b/product_uom_packaging/tests/test_uc7_package_type_validation_guidance.py new file mode 100644 index 00000000000..94dc3e15953 --- /dev/null +++ b/product_uom_packaging/tests/test_uc7_package_type_validation_guidance.py @@ -0,0 +1,466 @@ +""" +Test UC7: Package Type Validation Guidance + +As a warehouse manager, I want to receive warnings when package types seem inappropriate +so that we avoid packaging mistakes. + +Acceptance Criteria: +- Warning when product weight exceeds package type's max_weight +- Warning when product volume seems incompatible with package dimensions +- Warnings are informational (don't block operations) +- Validation considers product weight + package base weight +- Volume calculation: length × width × height of package type +""" + +from odoo.tests import tagged +from odoo.tests.common import HttpCase, TransactionCase + + +class TestUC7PackageTypeValidationGuidance(TransactionCase): + """Test UC7: Package Type Validation Guidance""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ProductUomPackaging = cls.env["product.uom.packaging"] + + # Products + cls.product_a = cls.env["product.product"].create({"name": "Product A"}) + + # UoMs + cls.uom_unit = cls.env.ref("uom.product_uom_unit") + + # Package Types + cls.package_small = cls.env["stock.package.type"].create( + { + "name": "Small Box", + "packaging_length": 10, + "width": 10, + "height": 10, + "base_weight": 0.5, + "max_weight": 10, + } + ) + + def test_weight_validation_warning(self): + """UC7: Warning when product weight exceeds package type's max_weight.""" + light_package = self.env["stock.package.type"].create( + { + "name": "Light Package", + "max_weight": 5.0, + } + ) + + heavy_product = self.env["product.product"].create( + { + "name": "Heavy Product", + "weight": 10.0, + } + ) + + move_line = self.env["stock.move.line"].create( + { + "product_id": heavy_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": light_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertTrue( + hasattr(move_line, "package_compatibility_warning"), + "stock.move.line should have package_compatibility_warning field", + ) + + warning_message = move_line.package_compatibility_warning + self.assertIsNotNone( + warning_message, "Warning should be generated for weight incompatibility" + ) + self.assertIn( + "weight", warning_message.lower(), "Warning message should mention weight" + ) + self.assertIn( + "10.0", warning_message, "Warning should show actual product weight" + ) + self.assertIn("5.0", warning_message, "Warning should show package max weight") + + self.assertTrue( + hasattr(move_line, "is_package_incompatible"), + "stock.move.line should have is_package_incompatible field", + ) + self.assertTrue( + move_line.is_package_incompatible, + "Incompatible flag should be True for weight issues", + ) + + # Test compatible case (no warning) + compatible_move_line = self.env["stock.move.line"].create( + { + "product_id": self.product_a.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": light_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertFalse( + compatible_move_line.is_package_incompatible, + "Compatible flag should be False for valid weight", + ) + self.assertFalse( + compatible_move_line.package_compatibility_warning, + "No warning should be generated for compatible weight", + ) + + def test_weight_validation_multiple_quantities(self): + """UC7: Warning when multiple product quantities exceed max_weight.""" + medium_package = self.env["stock.package.type"].create( + { + "name": "Medium Package", + "max_weight": 7.0, + } + ) + + medium_product = self.env["product.product"].create( + { + "name": "Medium Product", + "weight": 5.0, + } + ) + + # 1 qty (5 lbs) should be compatible with 7 lbs package + compatible_move_line = self.env["stock.move.line"].create( + { + "product_id": medium_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": medium_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertFalse( + compatible_move_line.is_package_incompatible, + "Single 5 lb item should be compatible with 7 lb package", + ) + self.assertFalse( + compatible_move_line.package_compatibility_warning, + "No warning should be generated for compatible weight", + ) + + # 2 qty (10 lbs) should be incompatible with 7 lbs package + incompatible_move_line = self.env["stock.move.line"].create( + { + "product_id": medium_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 2.0, + "package_type_id": medium_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertTrue( + incompatible_move_line.is_package_incompatible, + "Two 5 lb items should be incompatible with 7 lb package", + ) + self.assertIn( + "Weight incompatibility", + incompatible_move_line.package_compatibility_warning, + ) + self.assertIn( + "10.0", + incompatible_move_line.package_compatibility_warning, + ) + self.assertIn( + "7.0", + incompatible_move_line.package_compatibility_warning, + ) + + # Edge case: exactly at limit (10 lb package) + large_package = self.env["stock.package.type"].create( + { + "name": "Large Package", + "max_weight": 10.0, + } + ) + + edge_case_move_line = self.env["stock.move.line"].create( + { + "product_id": medium_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 2.0, + "package_type_id": large_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertFalse( + edge_case_move_line.is_package_incompatible, + "Weight exactly at package limit should be compatible", + ) + self.assertFalse( + edge_case_move_line.package_compatibility_warning, + "No warning should be generated at exact limit", + ) + + def test_volume_incompatibility_warning(self): + """UC7: Warning when product volume exceeds package volume.""" + # Package with dimensions but no weight limit (max_weight=0 means no limit) + small_package = self.env["stock.package.type"].create( + { + "name": "Tiny Package", + "packaging_length": 2, + "width": 2, + "height": 2, + "max_weight": 0, + } + ) + + bulky_product = self.env["product.product"].create( + { + "name": "Bulky Product", + "volume": 100.0, + } + ) + + move_line = self.env["stock.move.line"].create( + { + "product_id": bulky_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": small_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertTrue( + move_line.is_package_incompatible, + "Volume exceeding package should be incompatible", + ) + self.assertIn( + "Volume incompatibility", + move_line.package_compatibility_warning, + ) + self.assertIn("100.0", move_line.package_compatibility_warning) + self.assertIn("8.0", move_line.package_compatibility_warning) + + def test_picking_no_incompatibility_warning(self): + """UC7: Picking with compatible move lines shows no warning.""" + compatible_package = self.env["stock.package.type"].create( + { + "name": "Compatible Package", + "max_weight": 100.0, + } + ) + + light_product = self.env["product.product"].create( + { + "name": "Light Product", + "weight": 1.0, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.env.ref("stock.picking_type_out").id, + "partner_id": self.env["res.partner"] + .create({"name": "Test Customer"}) + .id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.env["stock.move"].create( + { + "picking_id": picking.id, + "product_id": light_product.id, + "product_uom_qty": 1.0, + "product_uom": self.uom_unit.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + ) + + picking.action_confirm() + picking.action_assign() + + move_line = picking.move_line_ids[0] + move_line.package_type_id = compatible_package + + self.assertFalse( + picking.has_package_incompatibility, + "Compatible picking should have no incompatibility flag", + ) + self.assertFalse( + picking.package_incompatibility_warning, + "Compatible picking should have no warning", + ) + + def test_picking_mixed_compatible_and_incompatible(self): + """UC7: Picking with mix of compatible and incompatible lines.""" + light_package = self.env["stock.package.type"].create( + { + "name": "Light Package", + "max_weight": 5.0, + } + ) + + heavy_product = self.env["product.product"].create( + { + "name": "Heavy Product", + "weight": 10.0, + } + ) + light_product = self.env["product.product"].create( + { + "name": "Light Product", + "weight": 1.0, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.env.ref("stock.picking_type_out").id, + "partner_id": self.env["res.partner"] + .create({"name": "Test Customer"}) + .id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + for product in [heavy_product, light_product]: + self.env["stock.move"].create( + { + "picking_id": picking.id, + "product_id": product.id, + "product_uom_qty": 1.0, + "product_uom": self.uom_unit.id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + ) + + picking.action_confirm() + picking.action_assign() + + for line in picking.move_line_ids: + line.package_type_id = light_package + + self.assertTrue( + picking.has_package_incompatibility, + "Picking with at least one incompatible line should flag", + ) + self.assertIn( + "Heavy Product", + picking.package_incompatibility_warning, + ) + + def test_no_warning_when_volume_fits(self): + """UC7: No volume warning when product volume fits in package.""" + big_package = self.env["stock.package.type"].create( + { + "name": "Big Package", + "packaging_length": 10, + "width": 10, + "height": 10, + "max_weight": 0, + } + ) + + small_product = self.env["product.product"].create( + { + "name": "Small Product", + "volume": 5.0, + } + ) + + move_line = self.env["stock.move.line"].create( + { + "product_id": small_product.id, + "product_uom_id": self.uom_unit.id, + "quantity": 1.0, + "package_type_id": big_package.id, + "company_id": self.env.user.company_id.id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.assertFalse( + move_line.is_package_incompatible, + "Product volume fitting in package should be compatible", + ) + self.assertFalse( + move_line.package_compatibility_warning, + "No warning when volume fits", + ) + + +@tagged("-at_install", "post_install") +class TestPackageWarningUI(HttpCase): + """UC7: Browser tour tests for package compatibility warning UI display.""" + + def test_package_warning_ui_display(self): + """UC7: Package compatibility warnings are displayed in the UI.""" + light_package = self.env["stock.package.type"].create( + { + "name": "Light Package", + "max_weight": 5.0, + } + ) + + heavy_product = self.env["product.product"].create( + { + "name": "Heavy Product", + "weight": 10.0, + } + ) + + picking = self.env["stock.picking"].create( + { + "picking_type_id": self.env.ref("stock.picking_type_out").id, + "partner_id": self.env["res.partner"] + .create({"name": "Test Customer"}) + .id, + "location_id": 1, + "location_dest_id": 2, + } + ) + + self.env["stock.move"].create( + { + "picking_id": picking.id, + "product_id": heavy_product.id, + "product_uom_qty": 1.0, + "product_uom": self.env.ref("uom.product_uom_unit").id, + "location_id": picking.location_id.id, + "location_dest_id": picking.location_dest_id.id, + } + ) + + picking.action_confirm() + picking.action_assign() + + move_line = picking.move_line_ids[0] + move_line.package_type_id = light_package + + tour_url = f"/odoo/stock.picking/{picking.id}" + self.start_tour( + tour_url, "test_package_warning_ui_display", login="admin", timeout=15 + ) diff --git a/product_uom_packaging/views/product_product_views.xml b/product_uom_packaging/views/product_product_views.xml new file mode 100644 index 00000000000..4e23cc35eb1 --- /dev/null +++ b/product_uom_packaging/views/product_product_views.xml @@ -0,0 +1,99 @@ + + + + + product.product.form.packaging + product.product + + + + +
+ Packaging configurations are managed on the product template. +
+ + + + + + + + + + + +
+
+
+
+ + + + product.template.form.packaging + product.template + + + + + + + + + + + + + + + + + + + + +
diff --git a/product_uom_packaging/views/product_uom_packaging_views.xml b/product_uom_packaging/views/product_uom_packaging_views.xml new file mode 100644 index 00000000000..7274edd80ff --- /dev/null +++ b/product_uom_packaging/views/product_uom_packaging_views.xml @@ -0,0 +1,112 @@ + + + + + product.uom.packaging.list + product.uom.packaging + + + + + + + + + + + + + + + + + product.uom.packaging.form + product.uom.packaging + +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + + + product.uom.packaging.search + product.uom.packaging + + + + + + + + + + + + Product UoM Packaging + product.uom.packaging + list,form + +

+ Define packaging configurations for your products +

+

+ Link products to units of measure with package types + to specify physical dimensions for each packaging configuration. +

+
+
+ + + +
diff --git a/product_uom_packaging/views/stock_move_line_views.xml b/product_uom_packaging/views/stock_move_line_views.xml new file mode 100644 index 00000000000..a71553ae1a7 --- /dev/null +++ b/product_uom_packaging/views/stock_move_line_views.xml @@ -0,0 +1,84 @@ + + + + + stock.move.line.tree.inherit + stock.move.line + + + + is_package_incompatible + + + + + + + + + + stock.move.line.tree.detailed.inherit + stock.move.line + + + + is_package_incompatible + + + + + + + + + + stock.move.line.form.inherit + stock.move.line + + + + + + + + + + + + + stock.move.line.operations.tree.inherit + stock.move.line + + + + is_package_incompatible + + + + + + + + + + stock.move.line.mobile.form.inherit + stock.move.line + + + + + + + + + + diff --git a/product_uom_packaging/views/stock_move_views.xml b/product_uom_packaging/views/stock_move_views.xml new file mode 100644 index 00000000000..85a0a1cfaee --- /dev/null +++ b/product_uom_packaging/views/stock_move_views.xml @@ -0,0 +1,35 @@ + + + + stock.move.form.inherit + stock.move + + + + + + + + + + stock.move.operations.form.inherit + stock.move + + + + + + + + + + stock.move.tree.inherit + stock.move + + + + + + + + diff --git a/product_uom_packaging/views/stock_picking_views.xml b/product_uom_packaging/views/stock_picking_views.xml new file mode 100644 index 00000000000..fb5c7179c28 --- /dev/null +++ b/product_uom_packaging/views/stock_picking_views.xml @@ -0,0 +1,20 @@ + + + + stock.picking.form.inherit.package.warning + stock.picking + + + + + + + +