diff --git a/product_weighing/README.rst b/product_weighing/README.rst new file mode 100644 index 00000000..040997d7 --- /dev/null +++ b/product_weighing/README.rst @@ -0,0 +1,95 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================ +Product Weighing +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:21fbf5e2cf7c9407d15e8a541c5b18e4abfda1788eb91e37fcc51ebd66e5d553 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-qrtl%2Frmm--custom-lightgray.png?logo=github + :target: https://github.com/qrtl/rmm-custom/tree/15.0/product_weighing + :alt: qrtl/rmm-custom + +|badge1| |badge2| |badge3| + +This module adds an **append-only** weighing line model +(``product.weighing.line``) that stores weighing results (gross weight, +dust deduction and net weight) sent from an external system against a +product. + +To support the "no tampering of weighing data" claim, weighing lines are +create-only: creation is allowed only for the dedicated integration +group, and the model overrides ``write`` and ``unlink`` to always raise, +so a line can never be modified or deleted after creation through any +path - UI, RPC, ``sudo`` or the developer-mode superuser included +(direct SQL is out of scope by design). The parent product cannot be +deleted either while lines exist (``ondelete="restrict"``), so the trail +is preserved. + +The product form gets a read-only **Weighing** tab showing three +aggregate fields (computed from the lines) and the list of weighing +lines. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Assign the API integration user to the **Weighing Integration** group. +Only members of this group can createweighing lines; no group can edit +or delete them. + +The three aggregate fields on the product (Total Net Weight, Weighing +Count, Weighing Confirmed At) are computed from the weighing lines, so +the integration only needs to create ``product.weighing.line`` records; +it does not write to ``product.template``. + +Usage +===== + +Open a product and go to the **Weighing** tab. The tab shows the +aggregate values and the weighing lines. Lines cannot be added, edited +or removed from the interface; click a line to open its read-only detail +(including the image hash). + +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 +----------- + +This module is part of the `qrtl/rmm-custom `_ project on GitHub. + +You are welcome to contribute. diff --git a/product_weighing/__init__.py b/product_weighing/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/product_weighing/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_weighing/__manifest__.py b/product_weighing/__manifest__.py new file mode 100644 index 00000000..8c1a8e0f --- /dev/null +++ b/product_weighing/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Product Weighing", + "summary": "Append-only weighing lines linked to products for tamper-proof records", + "category": "Product", + "version": "15.0.1.0.0", + "author": "Quartile", + "website": "https://www.quartile.co", + "license": "AGPL-3", + "depends": ["product"], + "data": [ + "security/product_weighing_security.xml", + "security/ir.model.access.csv", + "views/product_template_views.xml", + ], + "installable": True, +} diff --git a/product_weighing/i18n/ja.po b/product_weighing/i18n/ja.po new file mode 100644 index 00000000..41027c6a --- /dev/null +++ b/product_weighing/i18n/ja.po @@ -0,0 +1,203 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_weighing +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-08-05 05:05+0000\n" +"PO-Revision-Date: 2026-08-05 05:05+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_weighing +#: model:ir.model.constraint,message:product_weighing.constraint_product_weighing_line_weighing_id_uniq +msgid "A weighing line with this Weighing ID already exists." +msgstr "この計量IDの計量明細は既に存在します。" + +#. module: product_weighing +#: model:ir.model.fields,help:product_weighing.field_product_weighing_line__dust_detail +msgid "Breakdown of deducted items (e.g. 'cage cart x2, cart x1')." +msgstr "控除品目の内訳(例:「かご台車×2, 台車×1」)。" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__company_id +msgid "Company" +msgstr "会社" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__create_uid +msgid "Created by" +msgstr "作成者" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__create_date +msgid "Created on" +msgstr "作成日" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__display_name +msgid "Display Name" +msgstr "表示名" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__dust_detail +msgid "Dust Deduction Detail" +msgstr "ダスト控除内訳" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__dust_weight +msgid "Dust Deduction Weight (kg)" +msgstr "ダスト控除重量(kg)" + +#. module: product_weighing +#: model:ir.model.fields,help:product_weighing.field_product_weighing_line__weighing_id +msgid "External weighing identifier (e.g. WEIGH-KNG-20260513-0001)." +msgstr "外部計量ID(例: WEIGH-KNG-20260513-0001)。" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__gross_weight +msgid "Gross Weight (kg)" +msgstr "総重量(kg)" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__id +msgid "ID" +msgstr "ID" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__image_hash +msgid "Image Hash" +msgstr "画像ハッシュ" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line____last_update +msgid "Last Modified on" +msgstr "最終更新日" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__write_uid +msgid "Last Updated by" +msgstr "最終更新者" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__write_date +msgid "Last Updated on" +msgstr "最終更新日" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__measured_at +msgid "Measured At" +msgstr "計測日時" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__measurer_name +msgid "Measurer" +msgstr "計測者" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__net_weight +msgid "Net Weight (kg)" +msgstr "正味重量(kg)" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__product_tmpl_id +msgid "Product" +msgstr "プロダクト" + +#. module: product_weighing +#: model:ir.model,name:product_weighing.model_product_template +msgid "Product Template" +msgstr "プロダクトテンプレート" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__receipt_no +msgid "Receipt No." +msgstr "レシート番号" + +#. module: product_weighing +#: model:ir.model.fields,help:product_weighing.field_product_weighing_line__image_hash +msgid "" +"SHA-256 of the evidence image(s), comma-separated, for tamper verification." +msgstr "証跡画像のSHA-256(複数の場合はカンマ区切り)。改ざん検証用。" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_product__total_net_weight +#: model:ir.model.fields,field_description:product_weighing.field_product_template__total_net_weight +msgid "Total Net Weight" +msgstr "合計正味重量" + +#. module: product_weighing +#: model:res.groups,comment:product_weighing.group_weighing_integration +msgid "" +"Users allowed to create weighing lines via the\n" +" external integration (odoo-upload-system)." +msgstr "外部連携(odoo-upload-system)経由で計量明細を作成できるユーザ。" + +#. module: product_weighing +#: model_terms:ir.ui.view,arch_db:product_weighing.product_template_view_form +msgid "Weighing" +msgstr "計量" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_product__weighing_confirmed_at +#: model:ir.model.fields,field_description:product_weighing.field_product_template__weighing_confirmed_at +msgid "Weighing Confirmed At" +msgstr "計量確定日時" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_product__weighing_count +#: model:ir.model.fields,field_description:product_weighing.field_product_template__weighing_count +msgid "Weighing Count" +msgstr "計量数" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_weighing_line__weighing_id +msgid "Weighing ID" +msgstr "計量ID" + +#. module: product_weighing +#: model:res.groups,name:product_weighing.group_weighing_integration +msgid "Weighing Integration" +msgstr "計量連携" + +#. module: product_weighing +#: model:ir.model,name:product_weighing.model_product_weighing_line +msgid "Weighing Line" +msgstr "計量明細" + +#. module: product_weighing +#: model:ir.model.fields,field_description:product_weighing.field_product_product__weighing_line_ids +#: model:ir.model.fields,field_description:product_weighing.field_product_template__weighing_line_ids +msgid "Weighing Lines" +msgstr "計量明細" + +#. module: product_weighing +#: code:addons/product_weighing/models/product_weighing_line.py:0 +#, python-format +msgid "Weighing lines are append-only and cannot be deleted." +msgstr "計量明細は追記専用のため削除できません。" + +#. module: product_weighing +#: code:addons/product_weighing/models/product_weighing_line.py:0 +#, python-format +msgid "Weighing lines are append-only and cannot be modified." +msgstr "計量明細は追記専用のため変更できません。" + +#. module: product_weighing +#: model:ir.model.fields,help:product_weighing.field_product_product__weighing_confirmed_at +#: model:ir.model.fields,help:product_weighing.field_product_template__weighing_confirmed_at +msgid "" +"When the most recent weighing line was recorded in Odoo, i.e. the latest " +"creation timestamp among the lines. This is the time the external system " +"pushed the result, not the time the weighing itself took place - for that, " +"see Measured At on the individual lines. The two differ whenever results are" +" backfilled after an outage." +msgstr "" +"最新の計量明細がOdooに記録された日時、すなわち明細の作成日時のうち最も新しいものです。これは外部システムが結果を送信した日時であり、計量そのものが行われた" +"日時ではありません。後者については各明細の計測日時を参照してください。障害発生後に結果を遡って登録した場合、両者は異なります。" diff --git a/product_weighing/models/__init__.py b/product_weighing/models/__init__.py new file mode 100644 index 00000000..b772458e --- /dev/null +++ b/product_weighing/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_weighing_line +from . import product_template diff --git a/product_weighing/models/product_template.py b/product_weighing/models/product_template.py new file mode 100644 index 00000000..934b6400 --- /dev/null +++ b/product_weighing/models/product_template.py @@ -0,0 +1,44 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + weighing_line_ids = fields.One2many( + "product.weighing.line", + "product_tmpl_id", + string="Weighing Lines", + ) + weighing_count = fields.Integer( + compute="_compute_weighing_aggregates", + store=True, + ) + total_net_weight = fields.Float( + digits=(10, 2), + compute="_compute_weighing_aggregates", + store=True, + ) + weighing_confirmed_at = fields.Datetime( + compute="_compute_weighing_aggregates", + store=True, + help="When the most recent weighing line was recorded in Odoo, i.e. the " + "latest creation timestamp among the lines. This is the time the " + "external system pushed the result, not the time the weighing itself " + "took place - for that, see Measured At on the individual lines. The " + "two differ whenever results are backfilled after an outage.", + ) + + @api.depends( + "weighing_line_ids", + "weighing_line_ids.net_weight", + "weighing_line_ids.create_date", + ) + def _compute_weighing_aggregates(self): + for tmpl in self: + lines = tmpl.weighing_line_ids + tmpl.weighing_count = len(lines) + tmpl.total_net_weight = sum(lines.mapped("net_weight")) + tmpl.weighing_confirmed_at = max(lines.mapped("create_date"), default=False) diff --git a/product_weighing/models/product_weighing_line.py b/product_weighing/models/product_weighing_line.py new file mode 100644 index 00000000..894fb6ad --- /dev/null +++ b/product_weighing/models/product_weighing_line.py @@ -0,0 +1,72 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import AccessError + + +class ProductWeighingLine(models.Model): + _name = "product.weighing.line" + _description = "Weighing Line" + _order = "measured_at desc" + + product_tmpl_id = fields.Many2one( + "product.template", + string="Product", + required=True, + ondelete="restrict", + index=True, + ) + company_id = fields.Many2one( + "res.company", + related="product_tmpl_id.company_id", + store=True, + index=True, + ) + weighing_id = fields.Char( + string="Weighing ID", + required=True, + index=True, + help="External weighing identifier (e.g. WEIGH-KNG-20260513-0001).", + ) + measured_at = fields.Datetime(required=True) + measurer_name = fields.Char(string="Measurer") + receipt_no = fields.Char(string="Receipt No.") + gross_weight = fields.Float( + string="Gross Weight (kg)", digits=(10, 2), required=True + ) + dust_detail = fields.Char( + string="Dust Deduction Detail", + help="Breakdown of deducted items (e.g. 'cage cart x2, cart x1').", + ) + dust_weight = fields.Float( + string="Dust Deduction Weight (kg)", + digits=(10, 2), + required=True, + ) + net_weight = fields.Float(string="Net Weight (kg)", digits=(10, 2), required=True) + image_hash = fields.Text( + help="SHA-256 of the evidence image(s), comma-separated, for tamper " + "verification.", + ) + + _sql_constraints = [ + ( + "weighing_id_uniq", + "unique(weighing_id)", + "A weighing line with this Weighing ID already exists.", + ), + ] + + def write(self, vals): # pylint: disable=method-required-super + # Append-only: a weighing line can never be modified after creation, + # through any path (UI, RPC, sudo or the developer-mode superuser). + # This guarantee is the whole point of the model, so there is no + # context flag to bypass it. Direct SQL is out of scope by design. + raise AccessError(_("Weighing lines are append-only and cannot be modified.")) + + def unlink(self): # pylint: disable=method-required-super + # Append-only: deletion is denied for everyone, through any path. + # The parent product cannot be deleted either while lines exist + # (product_tmpl_id ondelete="restrict"), so the trail is preserved. + raise AccessError(_("Weighing lines are append-only and cannot be deleted.")) diff --git a/product_weighing/readme/CONFIGURE.md b/product_weighing/readme/CONFIGURE.md new file mode 100644 index 00000000..d83262fd --- /dev/null +++ b/product_weighing/readme/CONFIGURE.md @@ -0,0 +1,8 @@ +Assign the API integration user to the **Weighing Integration** group. +Only members of this group can createweighing lines; no group can edit +or delete them. + +The three aggregate fields on the product (Total Net Weight, Weighing +Count, Weighing Confirmed At) are computed from the weighing lines, so +the integration only needs to create `product.weighing.line` records; it +does not write to `product.template`. diff --git a/product_weighing/readme/DESCRIPTION.md b/product_weighing/readme/DESCRIPTION.md new file mode 100644 index 00000000..c5657320 --- /dev/null +++ b/product_weighing/readme/DESCRIPTION.md @@ -0,0 +1,15 @@ +This module adds an **append-only** weighing line model +(`product.weighing.line`) that stores weighing results (gross weight, +dust deduction and net weight) sent from an external system against a product. + +To support the "no tampering of weighing data" claim, weighing lines are +create-only: creation is allowed only for the dedicated integration +group, and the model overrides `write` and `unlink` to always raise, so a +line can never be modified or deleted after creation through any path - +UI, RPC, `sudo` or the developer-mode superuser included (direct SQL is +out of scope by design). The parent product cannot be deleted either +while lines exist (`ondelete="restrict"`), so the trail is preserved. + +The product form gets a read-only **Weighing** tab showing three +aggregate fields (computed from the lines) and the list of weighing +lines. diff --git a/product_weighing/readme/USAGE.md b/product_weighing/readme/USAGE.md new file mode 100644 index 00000000..7ef51f14 --- /dev/null +++ b/product_weighing/readme/USAGE.md @@ -0,0 +1,4 @@ +Open a product and go to the **Weighing** tab. The tab shows the +aggregate values and the weighing lines. Lines cannot be added, edited +or removed from the interface; click a line to open its read-only detail +(including the image hash). diff --git a/product_weighing/security/ir.model.access.csv b/product_weighing/security/ir.model.access.csv new file mode 100644 index 00000000..1b53503a --- /dev/null +++ b/product_weighing/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_weighing_line_user,product.weighing.line user,model_product_weighing_line,base.group_user,1,0,0,0 +access_product_weighing_line_integration,product.weighing.line integration,model_product_weighing_line,product_weighing.group_weighing_integration,1,0,1,0 diff --git a/product_weighing/security/product_weighing_security.xml b/product_weighing/security/product_weighing_security.xml new file mode 100644 index 00000000..63d66c4b --- /dev/null +++ b/product_weighing/security/product_weighing_security.xml @@ -0,0 +1,17 @@ + + + + Weighing Integration + + Users allowed to create weighing lines via the + external integration. + + + product.weighing.line multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/product_weighing/static/description/index.html b/product_weighing/static/description/index.html new file mode 100644 index 00000000..65ea62e0 --- /dev/null +++ b/product_weighing/static/description/index.html @@ -0,0 +1,448 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product Weighing

+ +

Beta License: AGPL-3 qrtl/rmm-custom

+

This module adds an append-only weighing line model +(product.weighing.line) that stores weighing results (gross weight, +dust deduction and net weight) sent from an external system against a +product.

+

To support the “no tampering of weighing data” claim, weighing lines are +create-only: creation is allowed only for the dedicated integration +group, and the model overrides write and unlink to always raise, +so a line can never be modified or deleted after creation through any +path - UI, RPC, sudo or the developer-mode superuser included +(direct SQL is out of scope by design). The parent product cannot be +deleted either while lines exist (ondelete="restrict"), so the trail +is preserved.

+

The product form gets a read-only Weighing tab showing three +aggregate fields (computed from the lines) and the list of weighing +lines.

+

Table of contents

+ +
+

Configuration

+

Assign the API integration user to the Weighing Integration group. +Only members of this group can createweighing lines; no group can edit +or delete them.

+

The three aggregate fields on the product (Total Net Weight, Weighing +Count, Weighing Confirmed At) are computed from the weighing lines, so +the integration only needs to create product.weighing.line records; +it does not write to product.template.

+
+
+

Usage

+

Open a product and go to the Weighing tab. The tab shows the +aggregate values and the weighing lines. Lines cannot be added, edited +or removed from the interface; click a line to open its read-only detail +(including the image hash).

+
+
+

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

+

This module is part of the qrtl/rmm-custom project on GitHub.

+

You are welcome to contribute.

+
+
+
+
+ + diff --git a/product_weighing/tests/__init__.py b/product_weighing/tests/__init__.py new file mode 100644 index 00000000..1d1f420b --- /dev/null +++ b/product_weighing/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_weighing diff --git a/product_weighing/tests/test_product_weighing.py b/product_weighing/tests/test_product_weighing.py new file mode 100644 index 00000000..8dc58f43 --- /dev/null +++ b/product_weighing/tests/test_product_weighing.py @@ -0,0 +1,206 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from psycopg2 import IntegrityError + +from odoo import Command, fields +from odoo.exceptions import AccessError +from odoo.tests import common +from odoo.tools import mute_logger + + +class TestProductWeighing(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.WeighingLine = cls.env["product.weighing.line"] + cls.product = cls.env["product.template"].create({"name": "Test Product"}) + group_user = cls.env.ref("base.group_user") + group_integration = cls.env.ref("product_weighing.group_weighing_integration") + group_system = cls.env.ref("base.group_system") + cls.integration_user = cls.env["res.users"].create( + { + "name": "Weighing API", + "login": "weighing_api", + "groups_id": [Command.set([group_user.id, group_integration.id])], + } + ) + cls.regular_user = cls.env["res.users"].create( + { + "name": "Regular", + "login": "weighing_regular", + "groups_id": [Command.set([group_user.id])], + } + ) + cls.admin_user = cls.env["res.users"].create( + { + "name": "Admin", + "login": "weighing_admin", + "groups_id": [Command.set([group_user.id, group_system.id])], + } + ) + + def _line_vals( + self, weighing_id, net_weight=20.70, measured_at="2026-07-13 09:41:18" + ): + return { + "product_tmpl_id": self.product.id, + "weighing_id": weighing_id, + "measured_at": measured_at, + "measurer_name": "Yamada", + "receipt_no": "R-20260713-118", + "gross_weight": 45.20, + "dust_detail": "cage cart x2, cart x1", + "dust_weight": 24.50, + "net_weight": net_weight, + "image_hash": "a3f1c9e0,91d44b0a", + } + + def _force_create_date(self, line, value): + # create_date is assigned by the ORM from cr.now(), which is cached for + # the whole transaction and never reset by a savepoint rollback, so + # every record a test creates shares one timestamp. Forcing the column + # is the only way to give lines distinct creation times and actually + # exercise the max() in the weighing_confirmed_at compute. + self.env.cr.execute( + "UPDATE product_weighing_line SET create_date = %s WHERE id = %s", + (value, line.id), + ) + line.invalidate_cache(["create_date"], line.ids) + line.modified(["create_date"]) + + def test_access_control(self): + # Create is restricted to the integration group. + line = self.WeighingLine.with_user(self.integration_user).create( + self._line_vals("WEIGH-KNG-20260713-0004") + ) + self.assertTrue(line.exists()) + for user in (self.regular_user, self.admin_user): + with self.assertRaises(AccessError): + self.WeighingLine.with_user(user).create( + self._line_vals("WEIGH-KNG-20260713-9%s" % user.id) + ) + # Lines are append-only: write and unlink are blocked for every user, + # including integration and admin. The model overrides write/unlink to + # always raise AccessError, so the block also holds for sudo and the + # developer-mode superuser (no ACL bypass and no context flag to escape + # it), consistent with the access denial the ACL already returns. + for user in (self.integration_user, self.regular_user, self.admin_user): + with self.assertRaises(AccessError): + line.with_user(user).write({"measurer_name": "Tampered"}) + with self.assertRaises(AccessError): + line.with_user(user).unlink() + with self.assertRaises(AccessError): + line.sudo().write({"measurer_name": "Tampered"}) + with self.assertRaises(AccessError): + line.sudo().unlink() + # The parent product cannot be deleted while a line references it + # (product_tmpl_id ondelete="restrict"), so the trail is preserved + # instead of being erased by cascade. + with self.assertRaises(IntegrityError), mute_logger( + "odoo.sql_db" + ), self.cr.savepoint(): + self.product.with_user(self.admin_user).unlink() + self.assertTrue(self.product.exists()) + + def test_weighing_id_uniqueness(self): + self.WeighingLine.create(self._line_vals("WEIGH-KNG-20260713-0007")) + with self.assertRaises(IntegrityError), mute_logger( + "odoo.sql_db" + ), self.cr.savepoint(): + self.WeighingLine.create(self._line_vals("WEIGH-KNG-20260713-0007")) + # The constraint is global, not per product: the same external weighing + # ID cannot be recorded against a second product either. + other_product = self.env["product.template"].create({"name": "Other Product"}) + with self.assertRaises(IntegrityError), mute_logger( + "odoo.sql_db" + ), self.cr.savepoint(): + self.WeighingLine.create( + dict( + self._line_vals("WEIGH-KNG-20260713-0007"), + product_tmpl_id=other_product.id, + ) + ) + self.assertEqual( + self.WeighingLine.search_count( + [("weighing_id", "=", "WEIGH-KNG-20260713-0007")] + ), + 1, + ) + + def test_product_aggregates(self): + self.assertEqual(self.product.weighing_count, 0) + self.assertFalse(self.product.total_net_weight) + self.assertFalse(self.product.weighing_confirmed_at) + line_1 = self.WeighingLine.create( + self._line_vals( + "WEIGH-KNG-20260713-0010", + net_weight=20.70, + measured_at="2026-07-13 09:41:18", + ) + ) + line_2 = self.WeighingLine.create( + self._line_vals( + "WEIGH-KNG-20260713-0011", + net_weight=18.80, + measured_at="2026-07-13 14:05:02", + ) + ) + line_3 = self.WeighingLine.create( + self._line_vals( + "WEIGH-KNG-20260714-0021", + net_weight=14.80, + measured_at="2026-07-14 08:12:44", + ) + ) + # Deliberately out of both creation order and measured_at order: line_2 + # is recorded last, while line_3 was created last and has the latest + # measured_at. A compute using min(), the first or last line of the + # one2many, or max(measured_at) would each yield a different value than + # the one asserted below. + self._force_create_date(line_1, "2026-07-20 01:00:00") + self._force_create_date(line_2, "2026-07-22 02:00:00") + self._force_create_date(line_3, "2026-07-21 03:00:00") + self.assertEqual(self.product.weighing_count, 3) + self.assertAlmostEqual(self.product.total_net_weight, 54.30, places=2) + self.assertEqual( + self.product.weighing_confirmed_at, + fields.Datetime.to_datetime("2026-07-22 02:00:00"), + ) + self.assertEqual(self.product.weighing_confirmed_at, line_2.create_date) + + def test_company_isolation(self): + company_b = self.env["res.company"].create({"name": "Weighing Company B"}) + product_b = self.env["product.template"].create( + {"name": "Company B Product", "company_id": company_b.id} + ) + line_b = self.WeighingLine.create( + dict( + self._line_vals("WEIGH-KNG-20260713-0030"), + product_tmpl_id=product_b.id, + ) + ) + self.assertEqual(line_b.company_id, company_b) + # The integration user belongs to the default company only, so the + # multi-company rule hides Company B's line on read... + self.assertNotIn( + line_b, self.WeighingLine.with_user(self.integration_user).search([]) + ) + # ...and blocks creating a line against a product of a company it has + # no access to, which would otherwise mutate that product's stored + # aggregates through the recompute. + with self.assertRaises(AccessError): + self.WeighingLine.with_user(self.integration_user).create( + dict( + self._line_vals("WEIGH-KNG-20260713-0031"), + product_tmpl_id=product_b.id, + ) + ) + # A line on a company-shared product stays readable by every internal + # user, which is what the base.group_user read grant is there for. + shared_line = self.WeighingLine.create( + self._line_vals("WEIGH-KNG-20260713-0032") + ) + self.assertFalse(shared_line.company_id) + for user in (self.integration_user, self.regular_user): + self.assertIn(shared_line, self.WeighingLine.with_user(user).search([])) diff --git a/product_weighing/views/product_template_views.xml b/product_weighing/views/product_template_views.xml new file mode 100644 index 00000000..4fba7b9c --- /dev/null +++ b/product_weighing/views/product_template_views.xml @@ -0,0 +1,52 @@ + + + + product.template.weighing.form + product.template + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
diff --git a/setup/product_weighing/odoo/addons/product_weighing b/setup/product_weighing/odoo/addons/product_weighing new file mode 120000 index 00000000..63e1ba4f --- /dev/null +++ b/setup/product_weighing/odoo/addons/product_weighing @@ -0,0 +1 @@ +../../../../product_weighing \ No newline at end of file diff --git a/setup/product_weighing/setup.py b/setup/product_weighing/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/product_weighing/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)