From 9b0421bc31abed823a4cd91caba76e84c9a7f20f Mon Sep 17 00:00:00 2001 From: yostashiro Date: Thu, 30 Jul 2026 06:05:50 +0000 Subject: [PATCH] [ADD] product_rental_fee_link: link equipment to its rental fee product Rental fees are managed as products separate from the equipment itself (one rental fee product per equipment model), and nothing in the standard data model ties the two together. Add a dedicated Rental Fee Product many2one on the product, restricted to services. A dedicated field is used rather than optional_product_ids: the latter is a many2many for cross-sell suggestions in the quotation configurator, so it cannot express a single verifiable relationship, carries no type restriction, and its meaning collides with genuine cross-sell entries. is_storable is deliberately not part of the domain nor the constraint: it only means "track inventory", can be turned on for a service by a user default, and core clears it for non-goods on recompute - so the product type is the only reliable discriminator. --- product_rental_fee_link/README.rst | 100 ++++ product_rental_fee_link/__init__.py | 1 + product_rental_fee_link/__manifest__.py | 18 + product_rental_fee_link/i18n/ja.po | 66 +++ product_rental_fee_link/models/__init__.py | 1 + .../models/product_template.py | 40 ++ product_rental_fee_link/pyproject.toml | 3 + product_rental_fee_link/readme/DESCRIPTION.md | 14 + product_rental_fee_link/readme/USAGE.md | 17 + .../static/description/index.html | 441 ++++++++++++++++++ product_rental_fee_link/tests/__init__.py | 1 + .../tests/test_product_rental_fee_link.py | 72 +++ .../views/product_template_views.xml | 34 ++ 13 files changed, 808 insertions(+) create mode 100644 product_rental_fee_link/README.rst create mode 100644 product_rental_fee_link/__init__.py create mode 100644 product_rental_fee_link/__manifest__.py create mode 100644 product_rental_fee_link/i18n/ja.po create mode 100644 product_rental_fee_link/models/__init__.py create mode 100644 product_rental_fee_link/models/product_template.py create mode 100644 product_rental_fee_link/pyproject.toml create mode 100644 product_rental_fee_link/readme/DESCRIPTION.md create mode 100644 product_rental_fee_link/readme/USAGE.md create mode 100644 product_rental_fee_link/static/description/index.html create mode 100644 product_rental_fee_link/tests/__init__.py create mode 100644 product_rental_fee_link/tests/test_product_rental_fee_link.py create mode 100644 product_rental_fee_link/views/product_template_views.xml diff --git a/product_rental_fee_link/README.rst b/product_rental_fee_link/README.rst new file mode 100644 index 0000000..b47039f --- /dev/null +++ b/product_rental_fee_link/README.rst @@ -0,0 +1,100 @@ +======================= +Product Rental Fee Link +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:cd91e3917d3563e4af97a4bde7bef1cb579cce2450d30aa5d06150cd08052441 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-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-qrtl%2Focj--custom-lightgray.png?logo=github + :target: https://github.com/qrtl/ocj-custom/tree/19.0/product_rental_fee_link + :alt: qrtl/ocj-custom + +|badge1| |badge2| |badge3| + +This module lets you point an equipment product at the service product +used to bill its rental. + +When rental fees are managed as products separate from the equipment +itself (one *rental fee* product per equipment model), nothing in the +standard data model ties the two together. This module adds a dedicated +**Rental Fee Product** link on the product, restricted to non-storable +service products. + +Why a dedicated field rather than *Optional Products*: +``optional_product_ids`` is a many2many meant for cross-sell suggestions +in the quotation product configurator. It cannot express a single, +verifiable relationship, it carries no type restriction, and its meaning +collides with genuine cross-sell entries. A dedicated many2one can be +validated, searched, and used as the source for integrations. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +On an equipment product (Goods), set **Rental Fee Product** next to the +product category. Only non-storable service products can be selected, +and a product cannot point at itself. + +The field is hidden on service products, since a rental fee product is +not itself rented. + +To find equipment that still needs the link, use the **Rental Fee +Product Missing** filter in the product search view. + +Several equipment products may share the same rental fee product; this +is not restricted, because the same fee often applies to more than one +model. + +If the rental fee product carries variants (for example a *price +category* attribute for first unit / second unit / in-hospital use), the +variants of the linked product are the set of billable items for that +equipment - which is what an integration reads to publish the +corresponding product codes. + +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 +------- + +* Quartile + +Maintainers +----------- + +.. |maintainer-yostashiro| image:: https://github.com/yostashiro.png?size=40px + :target: https://github.com/yostashiro + :alt: yostashiro + +Current maintainer: + +|maintainer-yostashiro| + +This module is part of the `qrtl/ocj-custom `_ project on GitHub. + +You are welcome to contribute. diff --git a/product_rental_fee_link/__init__.py b/product_rental_fee_link/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/product_rental_fee_link/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_rental_fee_link/__manifest__.py b/product_rental_fee_link/__manifest__.py new file mode 100644 index 0000000..aa187c5 --- /dev/null +++ b/product_rental_fee_link/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). +{ + "name": "Product Rental Fee Link", + "version": "19.0.1.0.0", + "category": "Inventory/Inventory", + "website": "https://www.quartile.co", + "author": "Quartile", + "maintainers": ["yostashiro"], + "license": "LGPL-3", + "summary": "Link an equipment product to the service product used to bill " + "its rental", + "installable": True, + "depends": ["stock"], + "data": [ + "views/product_template_views.xml", + ], +} diff --git a/product_rental_fee_link/i18n/ja.po b/product_rental_fee_link/i18n/ja.po new file mode 100644 index 0000000..ca5dcaa --- /dev/null +++ b/product_rental_fee_link/i18n/ja.po @@ -0,0 +1,66 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_rental_fee_link +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-30 05:51+0000\n" +"PO-Revision-Date: 2026-07-30 05:51+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_rental_fee_link +#. odoo-python +#: code:addons/product_rental_fee_link/models/product_template.py:0 +msgid "A product cannot be its own rental fee product." +msgstr "プロダクト自身をレンタル費用品目に設定することはできません。" + +#. module: product_rental_fee_link +#: model:ir.model.fields,field_description:product_rental_fee_link.field_product_template__display_name +msgid "Display Name" +msgstr "表示名" + +#. module: product_rental_fee_link +#: model:ir.model.fields,field_description:product_rental_fee_link.field_product_template__id +msgid "ID" +msgstr "" + +#. module: product_rental_fee_link +#: model:ir.model,name:product_rental_fee_link.model_product_template +msgid "Product" +msgstr "プロダクト" + +#. module: product_rental_fee_link +#: model:ir.model.fields,field_description:product_rental_fee_link.field_product_product__rental_fee_product_tmpl_id +#: model:ir.model.fields,field_description:product_rental_fee_link.field_product_template__rental_fee_product_tmpl_id +msgid "Rental Fee Product" +msgstr "レンタル費用品目" + +#. module: product_rental_fee_link +#: model_terms:ir.ui.view,arch_db:product_rental_fee_link.product_template_view_search +msgid "Rental Fee Product Missing" +msgstr "レンタル費用品目 未設定" + +#. module: product_rental_fee_link +#: model:ir.model.fields,help:product_rental_fee_link.field_product_product__rental_fee_product_tmpl_id +#: model:ir.model.fields,help:product_rental_fee_link.field_product_template__rental_fee_product_tmpl_id +msgid "" +"Service product used to bill the rental of this equipment. The rental fee is" +" managed as a product separate from the equipment itself, so this link is " +"what ties the two together - e.g. to derive the product codes to send to an " +"external system from the variants of the rental fee product." +msgstr "" +"この機種の課金に使うレンタル費用品目。レンタル費用は機器そのものとは別の品目として管理するため、両者を結びつけるのはこの項目です" +"(例: 外部システムへ配信する商品CDを、レンタル費用品目のバリアントから導出する)。" + +#. module: product_rental_fee_link +#. odoo-python +#: code:addons/product_rental_fee_link/models/product_template.py:0 +msgid "The rental fee product must be a service, but %(product)s is not." +msgstr "レンタル費用品目はサービス品目でなければなりません(%(product)s はサービス品目ではありません)。" diff --git a/product_rental_fee_link/models/__init__.py b/product_rental_fee_link/models/__init__.py new file mode 100644 index 0000000..e8fa8f6 --- /dev/null +++ b/product_rental_fee_link/models/__init__.py @@ -0,0 +1 @@ +from . import product_template diff --git a/product_rental_fee_link/models/product_template.py b/product_rental_fee_link/models/product_template.py new file mode 100644 index 0000000..50b03eb --- /dev/null +++ b/product_rental_fee_link/models/product_template.py @@ -0,0 +1,40 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + rental_fee_product_tmpl_id = fields.Many2one( + comodel_name="product.template", + string="Rental Fee Product", + domain=[("type", "=", "service")], + help="Service product used to bill the rental of this equipment. The " + "rental fee is managed as a product separate from the equipment itself, " + "so this link is what ties the two together - e.g. to derive the product " + "codes to send to an external system from the variants of the rental fee " + "product.", + ) + + @api.constrains("rental_fee_product_tmpl_id", "type") + def _check_rental_fee_product_tmpl_id(self): + # The product type is the discriminator here; is_storable is deliberately + # not checked, as it only means "track inventory" and can be turned on by + # a user default even for services (core clears it on recompute anyway). + for template in self.filtered("rental_fee_product_tmpl_id"): + fee_product = template.rental_fee_product_tmpl_id + if fee_product == template: + raise ValidationError( + self.env._("A product cannot be its own rental fee product.") + ) + if fee_product.type != "service": + raise ValidationError( + self.env._( + "The rental fee product must be a service, but " + "%(product)s is not.", + product=fee_product.display_name, + ) + ) diff --git a/product_rental_fee_link/pyproject.toml b/product_rental_fee_link/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/product_rental_fee_link/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_rental_fee_link/readme/DESCRIPTION.md b/product_rental_fee_link/readme/DESCRIPTION.md new file mode 100644 index 0000000..4c8d70f --- /dev/null +++ b/product_rental_fee_link/readme/DESCRIPTION.md @@ -0,0 +1,14 @@ +This module lets you point an equipment product at the service product used to +bill its rental. + +When rental fees are managed as products separate from the equipment itself +(one *rental fee* product per equipment model), nothing in the standard data +model ties the two together. This module adds a dedicated **Rental Fee Product** +link on the product, restricted to non-storable service products. + +Why a dedicated field rather than *Optional Products*: `optional_product_ids` is +a many2many meant for cross-sell suggestions in the quotation product +configurator. It cannot express a single, verifiable relationship, it carries no +type restriction, and its meaning collides with genuine cross-sell entries. A +dedicated many2one can be validated, searched, and used as the source for +integrations. diff --git a/product_rental_fee_link/readme/USAGE.md b/product_rental_fee_link/readme/USAGE.md new file mode 100644 index 0000000..8d8023a --- /dev/null +++ b/product_rental_fee_link/readme/USAGE.md @@ -0,0 +1,17 @@ +On an equipment product (Goods), set **Rental Fee Product** next to the product +category. Only non-storable service products can be selected, and a product +cannot point at itself. + +The field is hidden on service products, since a rental fee product is not +itself rented. + +To find equipment that still needs the link, use the **Rental Fee Product +Missing** filter in the product search view. + +Several equipment products may share the same rental fee product; this is not +restricted, because the same fee often applies to more than one model. + +If the rental fee product carries variants (for example a *price category* +attribute for first unit / second unit / in-hospital use), the variants of the +linked product are the set of billable items for that equipment - which is what +an integration reads to publish the corresponding product codes. diff --git a/product_rental_fee_link/static/description/index.html b/product_rental_fee_link/static/description/index.html new file mode 100644 index 0000000..41aaebd --- /dev/null +++ b/product_rental_fee_link/static/description/index.html @@ -0,0 +1,441 @@ + + + + + +Product Rental Fee Link + + + + + + diff --git a/product_rental_fee_link/tests/__init__.py b/product_rental_fee_link/tests/__init__.py new file mode 100644 index 0000000..c6936ff --- /dev/null +++ b/product_rental_fee_link/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_rental_fee_link diff --git a/product_rental_fee_link/tests/test_product_rental_fee_link.py b/product_rental_fee_link/tests/test_product_rental_fee_link.py new file mode 100644 index 0000000..f9a7d44 --- /dev/null +++ b/product_rental_fee_link/tests/test_product_rental_fee_link.py @@ -0,0 +1,72 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl). + +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestProductRentalFeeLink(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + Template = cls.env["product.template"] + cls.equipment = Template.create( + {"name": "Oxygen Concentrator", "type": "consu", "is_storable": True} + ) + cls.rental_fee = Template.create( + {"name": "Oxygen Concentrator Rental Fee", "type": "service"} + ) + + def test_01_link_service_product(self): + self.equipment.rental_fee_product_tmpl_id = self.rental_fee + self.assertEqual(self.equipment.rental_fee_product_tmpl_id, self.rental_fee) + + def test_02_goods_not_allowed(self): + with self.assertRaises(ValidationError): + self.equipment.rental_fee_product_tmpl_id = self.equipment.copy() + + def test_03_self_reference_not_allowed(self): + with self.assertRaises(ValidationError): + self.equipment.rental_fee_product_tmpl_id = self.equipment + + def test_04_storable_service_is_allowed(self): + # "Track Inventory" can be switched on for a service by a user default, + # so it must not disqualify a service from being a rental fee product. + self.rental_fee.is_storable = True + self.equipment.rental_fee_product_tmpl_id = self.rental_fee + self.assertEqual(self.equipment.rental_fee_product_tmpl_id, self.rental_fee) + + def test_05_shared_between_equipments(self): + other_equipment = self.equipment.copy({"name": "Oxygen Concentrator 2"}) + self.equipment.rental_fee_product_tmpl_id = self.rental_fee + other_equipment.rental_fee_product_tmpl_id = self.rental_fee + self.assertEqual( + other_equipment.rental_fee_product_tmpl_id, + self.equipment.rental_fee_product_tmpl_id, + ) + + def test_06_variants_of_linked_product_are_billable_items(self): + attribute = self.env["product.attribute"].create( + { + "name": "Price Category", + "create_variant": "always", + "value_ids": [ + (0, 0, {"name": "First Unit"}), + (0, 0, {"name": "Second Unit"}), + ], + } + ) + self.rental_fee.attribute_line_ids = [ + ( + 0, + 0, + { + "attribute_id": attribute.id, + "value_ids": [(6, 0, attribute.value_ids.ids)], + }, + ) + ] + self.equipment.rental_fee_product_tmpl_id = self.rental_fee + self.assertEqual( + len(self.equipment.rental_fee_product_tmpl_id.product_variant_ids), 2 + ) diff --git a/product_rental_fee_link/views/product_template_views.xml b/product_rental_fee_link/views/product_template_views.xml new file mode 100644 index 0000000..5d85a26 --- /dev/null +++ b/product_rental_fee_link/views/product_template_views.xml @@ -0,0 +1,34 @@ + + + + product.template.form.rental.fee.link + product.template + + + + + + + + + + product.template.search.rental.fee.link + product.template + + + + + + + + + +