From 7cec5bea98cbacc473225a3c91e51135ec33c643 Mon Sep 17 00:00:00 2001 From: kanda999 Date: Wed, 29 Jul 2026 11:13:23 +0000 Subject: [PATCH 01/17] [7060][ADD] stock_acceptance_label Add an acceptance label printed from transfers (3 per A4 portrait sheet), so that the incoming goods can be tagged with their acceptance number and the result of the acceptance test can be marked by hand on the label. --- stock_acceptance_label/README.rst | 98 ++++ stock_acceptance_label/__init__.py | 1 + stock_acceptance_label/__manifest__.py | 24 + stock_acceptance_label/i18n/ja.po | 78 +++ stock_acceptance_label/models/__init__.py | 2 + stock_acceptance_label/models/stock_move.py | 18 + .../models/stock_picking.py | 28 ++ stock_acceptance_label/pyproject.toml | 3 + stock_acceptance_label/readme/CONTRIBUTORS.md | 2 + stock_acceptance_label/readme/DESCRIPTION.md | 13 + stock_acceptance_label/readme/USAGE.md | 9 + .../report/stock_acceptance_label_report.xml | 35 ++ .../stock_acceptance_label_templates.xml | 61 +++ .../static/description/index.html | 443 ++++++++++++++++++ .../src/scss/report_acceptance_label.scss | 37 ++ stock_acceptance_label/tests/__init__.py | 1 + .../tests/test_stock_acceptance_label.py | 114 +++++ .../views/stock_picking_views.xml | 16 + 18 files changed, 983 insertions(+) create mode 100644 stock_acceptance_label/README.rst create mode 100644 stock_acceptance_label/__init__.py create mode 100644 stock_acceptance_label/__manifest__.py create mode 100644 stock_acceptance_label/i18n/ja.po create mode 100644 stock_acceptance_label/models/__init__.py create mode 100644 stock_acceptance_label/models/stock_move.py create mode 100644 stock_acceptance_label/models/stock_picking.py create mode 100644 stock_acceptance_label/pyproject.toml create mode 100644 stock_acceptance_label/readme/CONTRIBUTORS.md create mode 100644 stock_acceptance_label/readme/DESCRIPTION.md create mode 100644 stock_acceptance_label/readme/USAGE.md create mode 100644 stock_acceptance_label/report/stock_acceptance_label_report.xml create mode 100644 stock_acceptance_label/report/stock_acceptance_label_templates.xml create mode 100644 stock_acceptance_label/static/description/index.html create mode 100644 stock_acceptance_label/static/src/scss/report_acceptance_label.scss create mode 100644 stock_acceptance_label/tests/__init__.py create mode 100644 stock_acceptance_label/tests/test_stock_acceptance_label.py create mode 100644 stock_acceptance_label/views/stock_picking_views.xml diff --git a/stock_acceptance_label/README.rst b/stock_acceptance_label/README.rst new file mode 100644 index 0000000..ff1fdb9 --- /dev/null +++ b/stock_acceptance_label/README.rst @@ -0,0 +1,98 @@ +====================== +Stock Acceptance Label +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:5e1e9b272b3a1e46e65bb4a2f72bb8d4f13d47fb84acd7c5313c7d10650c9301 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-qrtl%2Frbkk--custom-lightgray.png?logo=github + :target: https://github.com/qrtl/rbkk-custom/tree/18.0/stock_acceptance_label + :alt: qrtl/rbkk-custom + +|badge1| |badge2| |badge3| + +This module adds an acceptance label that is printed from transfers, +three labels per A4 portrait sheet (one per horizontal band of the +sheet). + +Each label shows the product name, the acceptance number, the model +number (product internal reference), the arrival date (the effective +date of the transfer), a status area with checkboxes to be ticked by +hand, and the product barcode (Code128). + +One label is printed per transfer line, and several transfers can be +selected at once so that all their lines are printed in a single PDF. + +It also adds an **Acceptance Number** field on the transfer lines, which +is where the printed acceptance number comes from. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Enter the **Acceptance Number** of each line in the *Operations* tab of +the transfer. It may be left empty, in which case the field is printed +blank. + +Then go to *Inventory > Transfers*, select one or more transfers, and +use *Print > Acceptance Label*. Three labels are printed per sheet, and +the labels of a transfer stay together in the order of the selected +transfers. + +Cancelled lines are not printed. The arrival date stays blank until the +transfer is done. + +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 + +Contributors +------------ + +- `Quartile `__: + + - Tatsuki Kanda + +Maintainers +----------- + +.. |maintainer-kanda999| image:: https://github.com/kanda999.png?size=40px + :target: https://github.com/kanda999 + :alt: kanda999 + +Current maintainer: + +|maintainer-kanda999| + +This module is part of the `qrtl/rbkk-custom `_ project on GitHub. + +You are welcome to contribute. diff --git a/stock_acceptance_label/__init__.py b/stock_acceptance_label/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/stock_acceptance_label/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/stock_acceptance_label/__manifest__.py b/stock_acceptance_label/__manifest__.py new file mode 100644 index 0000000..678e19c --- /dev/null +++ b/stock_acceptance_label/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Stock Acceptance Label", + "version": "18.0.1.0.0", + "category": "Inventory/Inventory", + "summary": "Print acceptance labels from transfers, 3 per A4 portrait sheet", + "author": "Quartile", + "website": "https://www.quartile.co", + "license": "AGPL-3", + "maintainers": ["kanda999"], + "depends": ["stock"], + "data": [ + "report/stock_acceptance_label_report.xml", + "report/stock_acceptance_label_templates.xml", + "views/stock_picking_views.xml", + ], + "assets": { + "web.report_assets_common": [ + "stock_acceptance_label/static/src/scss/report_acceptance_label.scss", + ], + }, + "installable": True, +} diff --git a/stock_acceptance_label/i18n/ja.po b/stock_acceptance_label/i18n/ja.po new file mode 100644 index 0000000..95ee94d --- /dev/null +++ b/stock_acceptance_label/i18n/ja.po @@ -0,0 +1,78 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_acceptance_label +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-07-29 10:34+0000\n" +"PO-Revision-Date: 2026-07-29 10:34+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: stock_acceptance_label +#: model:ir.actions.report,print_report_name:stock_acceptance_label.stock_acceptance_label_report_action +msgid "'Acceptance Labels'" +msgstr "'受入ラベル'" + +#. module: stock_acceptance_label +#: model:ir.actions.report,name:stock_acceptance_label.stock_acceptance_label_report_action +msgid "Acceptance Label" +msgstr "受入ラベル" + +#. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move__acceptance_number +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "Acceptance Number" +msgstr "受け入れ番号" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "Arrival Date" +msgstr "入荷日" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "Model Number" +msgstr "型番" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "Product Name" +msgstr "製品の名前" + +#. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move__smart_search +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_picking__smart_search +msgid "Smart Search" +msgstr "スマート検索" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "Status" +msgstr "ステータス" + +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_stock_move +msgid "Stock Move" +msgstr "在庫移動" + +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_stock_picking +msgid "Transfer" +msgstr "転送" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "□ Conforming or □ Rejected" +msgstr "□適合 or □不合格" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +msgid "□ Under Inspection" +msgstr "□検査中" diff --git a/stock_acceptance_label/models/__init__.py b/stock_acceptance_label/models/__init__.py new file mode 100644 index 0000000..a33bde1 --- /dev/null +++ b/stock_acceptance_label/models/__init__.py @@ -0,0 +1,2 @@ +from . import stock_move +from . import stock_picking diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py new file mode 100644 index 0000000..2b08040 --- /dev/null +++ b/stock_acceptance_label/models/stock_move.py @@ -0,0 +1,18 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + acceptance_number = fields.Char(copy=False) + + def get_acceptance_arrival_date(self): + """Return the effective date of the transfer, in the user time zone.""" + self.ensure_one() + date_done = self.picking_id.date_done + if not date_done: + return False + return fields.Datetime.context_timestamp(self, date_done).date() diff --git a/stock_acceptance_label/models/stock_picking.py b/stock_acceptance_label/models/stock_picking.py new file mode 100644 index 0000000..9562147 --- /dev/null +++ b/stock_acceptance_label/models/stock_picking.py @@ -0,0 +1,28 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + # An A4 portrait sheet is split into three horizontal bands. + LABELS_PER_PAGE = 3 + + def get_acceptance_label_pages(self): + """Return the moves to print a label for, grouped per sheet. + + The moves are kept in the order of the transfers they belong to, so that + the labels of a transfer stay together. + """ + moves = [ + move + for picking in self + for move in picking.move_ids + if move.state != "cancel" + ] + return [ + moves[index : index + self.LABELS_PER_PAGE] + for index in range(0, len(moves), self.LABELS_PER_PAGE) + ] diff --git a/stock_acceptance_label/pyproject.toml b/stock_acceptance_label/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/stock_acceptance_label/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/stock_acceptance_label/readme/CONTRIBUTORS.md b/stock_acceptance_label/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..097598f --- /dev/null +++ b/stock_acceptance_label/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Quartile](https://www.quartile.co): + - Tatsuki Kanda diff --git a/stock_acceptance_label/readme/DESCRIPTION.md b/stock_acceptance_label/readme/DESCRIPTION.md new file mode 100644 index 0000000..0d2a81c --- /dev/null +++ b/stock_acceptance_label/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +This module adds an acceptance label that is printed from transfers, three labels +per A4 portrait sheet (one per horizontal band of the sheet). + +Each label shows the product name, the acceptance number, the model number +(product internal reference), the arrival date (the effective date of the +transfer), a status area with checkboxes to be ticked by hand, and the product +barcode (Code128). + +One label is printed per transfer line, and several transfers can be selected at +once so that all their lines are printed in a single PDF. + +It also adds an **Acceptance Number** field on the transfer lines, which is where +the printed acceptance number comes from. diff --git a/stock_acceptance_label/readme/USAGE.md b/stock_acceptance_label/readme/USAGE.md new file mode 100644 index 0000000..4d3f7f1 --- /dev/null +++ b/stock_acceptance_label/readme/USAGE.md @@ -0,0 +1,9 @@ +Enter the **Acceptance Number** of each line in the *Operations* tab of the +transfer. It may be left empty, in which case the field is printed blank. + +Then go to *Inventory > Transfers*, select one or more transfers, and use *Print > +Acceptance Label*. Three labels are printed per sheet, and the labels of a +transfer stay together in the order of the selected transfers. + +Cancelled lines are not printed. The arrival date stays blank until the transfer +is done. diff --git a/stock_acceptance_label/report/stock_acceptance_label_report.xml b/stock_acceptance_label/report/stock_acceptance_label_report.xml new file mode 100644 index 0000000..5657616 --- /dev/null +++ b/stock_acceptance_label/report/stock_acceptance_label_report.xml @@ -0,0 +1,35 @@ + + + + A4 Portrait Acceptance Label Sheet + + A4 + 0 + 0 + Portrait + 0 + 0 + 0 + 0 + + 0 + + 96 + + + + Acceptance Label + stock.picking + qweb-pdf + stock_acceptance_label.report_stock_acceptance_label + stock_acceptance_label.report_stock_acceptance_label + + 'Acceptance Labels' + + report + + diff --git a/stock_acceptance_label/report/stock_acceptance_label_templates.xml b/stock_acceptance_label/report/stock_acceptance_label_templates.xml new file mode 100644 index 0000000..a48679f --- /dev/null +++ b/stock_acceptance_label/report/stock_acceptance_label_templates.xml @@ -0,0 +1,61 @@ + + + + + + diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html new file mode 100644 index 0000000..e8333a5 --- /dev/null +++ b/stock_acceptance_label/static/description/index.html @@ -0,0 +1,443 @@ + + + + + +Stock Acceptance Label + + + +
+

Stock Acceptance Label

+ + +

Beta License: AGPL-3 qrtl/rbkk-custom

+

This module adds an acceptance label that is printed from transfers, +three labels per A4 portrait sheet (one per horizontal band of the +sheet).

+

Each label shows the product name, the acceptance number, the model +number (product internal reference), the arrival date (the effective +date of the transfer), a status area with checkboxes to be ticked by +hand, and the product barcode (Code128).

+

One label is printed per transfer line, and several transfers can be +selected at once so that all their lines are printed in a single PDF.

+

It also adds an Acceptance Number field on the transfer lines, which +is where the printed acceptance number comes from.

+

Table of contents

+ +
+

Usage

+

Enter the Acceptance Number of each line in the Operations tab of +the transfer. It may be left empty, in which case the field is printed +blank.

+

Then go to Inventory > Transfers, select one or more transfers, and +use Print > Acceptance Label. Three labels are printed per sheet, and +the labels of a transfer stay together in the order of the selected +transfers.

+

Cancelled lines are not printed. The arrival date stays blank until the +transfer is done.

+
+
+

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
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

Current maintainer:

+

kanda999

+

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

+

You are welcome to contribute.

+
+
+
+ + diff --git a/stock_acceptance_label/static/src/scss/report_acceptance_label.scss b/stock_acceptance_label/static/src/scss/report_acceptance_label.scss new file mode 100644 index 0000000..704a488 --- /dev/null +++ b/stock_acceptance_label/static/src/scss/report_acceptance_label.scss @@ -0,0 +1,37 @@ +// Three labels per A4 portrait sheet (210mm x 297mm), one per horizontal band. +.o_pal_sheet { + width: 210mm; + height: 297mm; + overflow: hidden; + page-break-before: always; + div.o_pal_cell { + box-sizing: border-box; + width: 210mm; + height: 98mm; + padding: 6mm 10mm; + overflow: hidden; + } + table.o_pal_label { + table-layout: fixed; + border-collapse: collapse; + width: 100%; + td { + border: 1px solid black; + padding: 1.5mm 3mm; + font-size: 13pt; + line-height: 1.4; + overflow: hidden; + } + // Narrow key column, as the product name often is long. + td.o_pal_key { + width: 22%; + } + td.o_pal_value { + text-align: center; + } + } + .o_pal_barcode { + margin-top: 4mm; + text-align: center; + } +} diff --git a/stock_acceptance_label/tests/__init__.py b/stock_acceptance_label/tests/__init__.py new file mode 100644 index 0000000..d14113f --- /dev/null +++ b/stock_acceptance_label/tests/__init__.py @@ -0,0 +1 @@ +from . import test_stock_acceptance_label diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py new file mode 100644 index 0000000..3359f5c --- /dev/null +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -0,0 +1,114 @@ +# Copyright 2026 Quartile (https://www.quartile.co) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import Command, fields +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +# This module is loaded early in the module graph, so the tests have to run once +# every module is loaded: creating a product otherwise fails on the not-null +# columns of the modules that are not loaded yet. +@tagged("post_install", "-at_install") +class TestStockAcceptanceLabel(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.vendor = cls.env["res.partner"].create({"name": "Vendor"}) + cls.product_a = cls.env["product.product"].create( + { + "name": "Product A", + "is_storable": True, + "default_code": "SH30221.26", + "barcode": "1234567890128", + } + ) + cls.product_b = cls.env["product.product"].create( + {"name": "Product B", "is_storable": True} + ) + cls.picking_type = cls.env["stock.picking.type"].search( + [("code", "=", "incoming"), ("company_id", "=", cls.env.company.id)], + limit=1, + ) + cls.location = cls.env.ref("stock.stock_location_suppliers") + cls.location_dest = cls.picking_type.default_location_dest_id + cls.picking = cls._create_picking(cls.product_a, cls.product_b) + + @classmethod + def _create_picking(cls, *products): + return cls.env["stock.picking"].create( + { + "picking_type_id": cls.picking_type.id, + "partner_id": cls.vendor.id, + "location_id": cls.location.id, + "location_dest_id": cls.location_dest.id, + "move_ids": [ + Command.create( + { + "name": product.name, + "product_id": product.id, + "product_uom_qty": 1.0, + "location_id": cls.location.id, + "location_dest_id": cls.location_dest.id, + } + ) + for product in products + ], + } + ) + + def _validate(self, picking): + picking.action_confirm() + for move in picking.move_ids: + move.write({"quantity": move.product_uom_qty, "picked": True}) + if "check_ids" in picking._fields: + # Pass the quality checks created by the optional quality modules, + # as they would otherwise block the transfer. One by one, as the + # quality modules only handle a single check at a time. + for check in picking.check_ids.filtered( + lambda check: check.quality_state == "none" + ): + check.quality_state = "pass" + # _action_done() instead of button_validate(), which may return a wizard + # depending on the modules installed (quality checks, backorders, ...). + picking._action_done() + + def test_acceptance_number_is_not_copied(self): + self.picking.move_ids[0].acceptance_number = "R016-20251017-01" + self.assertFalse(self.picking.copy().move_ids[0].acceptance_number) + + def test_label_pages_layout(self): + other_picking = self._create_picking(*([self.product_a] * 5)) + pages = (self.picking | other_picking).get_acceptance_label_pages() + self.assertEqual([len(page) for page in pages], [3, 3, 1]) + # The labels of a transfer stay together, in the order of the transfers. + self.assertEqual(pages[0][0].picking_id, self.picking) + self.assertEqual(pages[0][2].picking_id, other_picking) + + def test_cancelled_move_is_skipped(self): + self.picking.move_ids[0]._action_cancel() + pages = self.picking.get_acceptance_label_pages() + self.assertEqual([move.product_id for move in pages[0]], [self.product_b]) + + def test_arrival_date(self): + move = self.picking.move_ids[0] + self.assertFalse(move.get_acceptance_arrival_date()) + self._validate(self.picking) + expected = fields.Datetime.context_timestamp( + self.picking, self.picking.date_done + ).date() + self.assertEqual(move.get_acceptance_arrival_date(), expected) + + def test_report_html(self): + self.picking.move_ids[0].acceptance_number = "R016-20251017-01" + html = ( + self.env["ir.actions.report"] + ._render_qweb_html( + "stock_acceptance_label.report_stock_acceptance_label", + self.picking.ids, + )[0] + .decode() + ) + self.assertIn("R016-20251017-01", html) + self.assertIn("SH30221.26", html) + self.assertEqual(html.count("o_pal_cell"), 2) diff --git a/stock_acceptance_label/views/stock_picking_views.xml b/stock_acceptance_label/views/stock_picking_views.xml new file mode 100644 index 0000000..123bbcd --- /dev/null +++ b/stock_acceptance_label/views/stock_picking_views.xml @@ -0,0 +1,16 @@ + + + + stock.picking.form.acceptance.label + stock.picking + + + + + + + + From c1851a1effe52ec0832150859ea1bffebbdaee4d Mon Sep 17 00:00:00 2001 From: kanda999 Date: Mon, 3 Aug 2026 01:38:10 +0000 Subject: [PATCH 02/17] [IMP] stock_acceptance_label: make the arrival date field configurable The date to be printed as the arrival date differs between operations, so the effective date of the transfer cannot always be used. Let the field be selected in the inventory settings, among the date and datetime fields of the transfer and of its lines, and keep the effective date of the transfer as the default. --- stock_acceptance_label/README.rst | 25 ++++++-- stock_acceptance_label/__manifest__.py | 1 + stock_acceptance_label/i18n/ja.po | 41 ++++++++++--- stock_acceptance_label/models/__init__.py | 1 + .../models/res_config_settings.py | 29 ++++++++++ stock_acceptance_label/models/stock_move.py | 40 +++++++++++-- stock_acceptance_label/readme/CONFIGURE.md | 9 +++ stock_acceptance_label/readme/DESCRIPTION.md | 4 +- stock_acceptance_label/readme/USAGE.md | 5 +- .../static/description/index.html | 58 ++++++++++++------- .../tests/test_stock_acceptance_label.py | 49 ++++++++++++++++ .../views/res_config_settings_views.xml | 30 ++++++++++ 12 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 stock_acceptance_label/models/res_config_settings.py create mode 100644 stock_acceptance_label/readme/CONFIGURE.md create mode 100644 stock_acceptance_label/views/res_config_settings_views.xml diff --git a/stock_acceptance_label/README.rst b/stock_acceptance_label/README.rst index ff1fdb9..08ad32c 100644 --- a/stock_acceptance_label/README.rst +++ b/stock_acceptance_label/README.rst @@ -7,7 +7,7 @@ Stock Acceptance Label !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:5e1e9b272b3a1e46e65bb4a2f72bb8d4f13d47fb84acd7c5313c7d10650c9301 + !! source digest: sha256:633d4780e8af93484a3a5d5d2d5018a439630c594effa5e749401e1e63aabfb6 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -28,8 +28,9 @@ sheet). Each label shows the product name, the acceptance number, the model number (product internal reference), the arrival date (the effective -date of the transfer), a status area with checkboxes to be ticked by -hand, and the product barcode (Code128). +date of the transfer, or any other date field selected in the settings), +a status area with checkboxes to be ticked by hand, and the product +barcode (Code128). One label is printed per transfer line, and several transfers can be selected at once so that all their lines are printed in a single PDF. @@ -42,6 +43,19 @@ is where the printed acceptance number comes from. .. contents:: :local: +Configuration +============= + +The date printed as the arrival date can be selected in *Inventory > +Configuration > Settings > Acceptance Label > Arrival Date*. Any date or +datetime field of the transfer or of its lines can be selected, +including the fields added by other modules, and datetime fields are +converted to the user time zone. + +The effective date of the transfer (*Date of Transfer*) is used by +default, and also when the selected field is emptied or points to a +field that does not exist anymore. + Usage ===== @@ -54,8 +68,9 @@ use *Print > Acceptance Label*. Three labels are printed per sheet, and the labels of a transfer stay together in the order of the selected transfers. -Cancelled lines are not printed. The arrival date stays blank until the -transfer is done. +Cancelled lines are not printed. The arrival date stays blank as long as +the configured date field is empty, which is the case until the transfer +is done with the default setting. Bug Tracker =========== diff --git a/stock_acceptance_label/__manifest__.py b/stock_acceptance_label/__manifest__.py index 678e19c..b3b841d 100644 --- a/stock_acceptance_label/__manifest__.py +++ b/stock_acceptance_label/__manifest__.py @@ -13,6 +13,7 @@ "data": [ "report/stock_acceptance_label_report.xml", "report/stock_acceptance_label_templates.xml", + "views/res_config_settings_views.xml", "views/stock_picking_views.xml", ], "assets": { diff --git a/stock_acceptance_label/i18n/ja.po b/stock_acceptance_label/i18n/ja.po index 95ee94d..a258f8d 100644 --- a/stock_acceptance_label/i18n/ja.po +++ b/stock_acceptance_label/i18n/ja.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-29 10:34+0000\n" -"PO-Revision-Date: 2026-07-29 10:34+0000\n" +"POT-Creation-Date: 2026-07-31 09:36+0000\n" +"PO-Revision-Date: 2026-07-31 09:36+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -22,6 +22,7 @@ msgstr "'受入ラベル'" #. module: stock_acceptance_label #: model:ir.actions.report,name:stock_acceptance_label.stock_acceptance_label_report_action +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form msgid "Acceptance Label" msgstr "受入ラベル" @@ -29,13 +30,41 @@ msgstr "受入ラベル" #: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move__acceptance_number #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Acceptance Number" -msgstr "受け入れ番号" +msgstr "受入番号" #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form msgid "Arrival Date" msgstr "入荷日" +#. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_res_config_settings__acceptance_label_arrival_date_field_id +msgid "Arrival Date Field" +msgstr "入荷日フィールド" + +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_res_config_settings +msgid "Config Settings" +msgstr "コンフィグ設定" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form +msgid "Field printed as the arrival date on the acceptance label" +msgstr "受入ラベルに入荷日として印字するフィールド" + +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_res_config_settings__acceptance_label_arrival_date_field_id +msgid "" +"Field printed as the arrival date on the acceptance label. Date fields of " +"the transfer and of its lines can be selected, and datetime fields are " +"converted to the user time zone. The effective date of the transfer is used " +"when this is left empty." +msgstr "" +"受入ラベルに入荷日として印字するフィールド。転送およびその明細の日付・日時" +"フィールドを選択でき、日時フィールドはユーザのタイムゾーンに変換されます。空欄" +"の場合は転送の実施日が使用されます。" + #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Model Number" @@ -46,12 +75,6 @@ msgstr "型番" msgid "Product Name" msgstr "製品の名前" -#. module: stock_acceptance_label -#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move__smart_search -#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_picking__smart_search -msgid "Smart Search" -msgstr "スマート検索" - #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Status" diff --git a/stock_acceptance_label/models/__init__.py b/stock_acceptance_label/models/__init__.py index a33bde1..6777458 100644 --- a/stock_acceptance_label/models/__init__.py +++ b/stock_acceptance_label/models/__init__.py @@ -1,2 +1,3 @@ +from . import res_config_settings from . import stock_move from . import stock_picking diff --git a/stock_acceptance_label/models/res_config_settings.py b/stock_acceptance_label/models/res_config_settings.py new file mode 100644 index 0000000..9284ac0 --- /dev/null +++ b/stock_acceptance_label/models/res_config_settings.py @@ -0,0 +1,29 @@ +# 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 + +from .stock_move import ARRIVAL_DATE_FIELD_PARAM + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + acceptance_label_arrival_date_field_id = fields.Many2one( + "ir.model.fields", + string="Arrival Date Field", + domain=[ + ("model", "in", ["stock.move", "stock.picking"]), + ("ttype", "in", ["date", "datetime"]), + ], + default=lambda self: self._default_acceptance_label_arrival_date_field_id(), + config_parameter=ARRIVAL_DATE_FIELD_PARAM, + help="Field printed as the arrival date on the acceptance label. Date " + "fields of the transfer and of its lines can be selected, and datetime " + "fields are converted to the user time zone. The effective date of the " + "transfer is used when this is left empty.", + ) + + @api.model + def _default_acceptance_label_arrival_date_field_id(self): + return self.env["stock.move"]._get_acceptance_arrival_date_field().id diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index 2b08040..916dd26 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -1,7 +1,9 @@ # 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 import api, fields, models + +ARRIVAL_DATE_FIELD_PARAM = "stock_acceptance_label.arrival_date_field_id" class StockMove(models.Model): @@ -9,10 +11,38 @@ class StockMove(models.Model): acceptance_number = fields.Char(copy=False) + @api.model + def _get_acceptance_arrival_date_field(self): + """Return the field to print as the arrival date of the label. + + The field is selected in the inventory settings. The effective date of + the transfer is used when the setting is empty or points to a field + that does not exist anymore. + """ + ir_model_fields = self.env["ir.model.fields"].sudo() + param = ( + self.env["ir.config_parameter"].sudo().get_param(ARRIVAL_DATE_FIELD_PARAM) + ) + field = ( + ir_model_fields.browse(int(param)) + if param and str(param).isdigit() + else ir_model_fields + ) + return field.exists() or ir_model_fields._get("stock.picking", "date_done") + def get_acceptance_arrival_date(self): - """Return the effective date of the transfer, in the user time zone.""" + """Return the configured arrival date, in the user time zone.""" self.ensure_one() - date_done = self.picking_id.date_done - if not date_done: + field = self._get_acceptance_arrival_date_field() + if field.model == "stock.move": + record = self + elif field.model == "stock.picking": + record = self.picking_id + else: + return False + value = record[field.name] if record else False + if not value: return False - return fields.Datetime.context_timestamp(self, date_done).date() + if field.ttype == "datetime": + return fields.Datetime.context_timestamp(self, value).date() + return value diff --git a/stock_acceptance_label/readme/CONFIGURE.md b/stock_acceptance_label/readme/CONFIGURE.md new file mode 100644 index 0000000..2a3eb73 --- /dev/null +++ b/stock_acceptance_label/readme/CONFIGURE.md @@ -0,0 +1,9 @@ +The date printed as the arrival date can be selected in *Inventory > +Configuration > Settings > Acceptance Label > Arrival Date*. Any date or +datetime field of the transfer or of its lines can be selected, including the +fields added by other modules, and datetime fields are converted to the user +time zone. + +The effective date of the transfer (*Date of Transfer*) is used by default, and +also when the selected field is emptied or points to a field that does not exist +anymore. diff --git a/stock_acceptance_label/readme/DESCRIPTION.md b/stock_acceptance_label/readme/DESCRIPTION.md index 0d2a81c..6981167 100644 --- a/stock_acceptance_label/readme/DESCRIPTION.md +++ b/stock_acceptance_label/readme/DESCRIPTION.md @@ -3,8 +3,8 @@ per A4 portrait sheet (one per horizontal band of the sheet). Each label shows the product name, the acceptance number, the model number (product internal reference), the arrival date (the effective date of the -transfer), a status area with checkboxes to be ticked by hand, and the product -barcode (Code128). +transfer, or any other date field selected in the settings), a status area with +checkboxes to be ticked by hand, and the product barcode (Code128). One label is printed per transfer line, and several transfers can be selected at once so that all their lines are printed in a single PDF. diff --git a/stock_acceptance_label/readme/USAGE.md b/stock_acceptance_label/readme/USAGE.md index 4d3f7f1..8d2eef8 100644 --- a/stock_acceptance_label/readme/USAGE.md +++ b/stock_acceptance_label/readme/USAGE.md @@ -5,5 +5,6 @@ Then go to *Inventory > Transfers*, select one or more transfers, and use *Print Acceptance Label*. Three labels are printed per sheet, and the labels of a transfer stay together in the order of the selected transfers. -Cancelled lines are not printed. The arrival date stays blank until the transfer -is done. +Cancelled lines are not printed. The arrival date stays blank as long as the +configured date field is empty, which is the case until the transfer is done +with the default setting. diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html index e8333a5..10b72e3 100644 --- a/stock_acceptance_label/static/description/index.html +++ b/stock_acceptance_label/static/description/index.html @@ -2,18 +2,18 @@ - + Stock Acceptance Label
diff --git a/stock_acceptance_label/static/src/scss/report_acceptance_label.scss b/stock_acceptance_label/static/src/scss/report_acceptance_label.scss deleted file mode 100644 index 67a42a8..0000000 --- a/stock_acceptance_label/static/src/scss/report_acceptance_label.scss +++ /dev/null @@ -1,38 +0,0 @@ -// Three labels per A4 portrait sheet (210mm x 297mm), one per horizontal band. -.o_pal_sheet { - width: 210mm; - height: 297mm; - overflow: hidden; - page-break-before: always; - div.o_pal_cell { - box-sizing: border-box; - width: 210mm; - height: 98mm; - padding: 6mm 10mm; - overflow: hidden; - } - table.o_pal_label { - table-layout: fixed; - border-collapse: collapse; - width: 100%; - td { - border: 1px solid black; - // Tight padding, as the label has eight rows to fit in its band. - padding: 1mm 3mm; - font-size: 13pt; - line-height: 1.3; - overflow: hidden; - } - // Narrow key column, as the product name often is long. - td.o_pal_key { - width: 22%; - } - td.o_pal_value { - text-align: center; - } - td.o_pal_barcode img { - display: block; - margin: 0 auto; - } - } -} diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py index 34aeb20..458019d 100644 --- a/stock_acceptance_label/tests/test_stock_acceptance_label.py +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -259,7 +259,9 @@ def test_report_html(self): self.assertIn("R016-20251017-01", html) self.assertIn("SH30221.26", html) self.assertIn("Under Inspection", html) - self.assertEqual(html.count("o_pal_cell"), 2) + # Counted on the class attributes, as the class names also appear in the + # selectors of the stylesheet of the report. + self.assertEqual(html.count('class="o_pal_cell"'), 2) # The barcode is a row of the label, printed for the product that has one. - self.assertEqual(html.count("o_pal_barcode"), 2) + self.assertEqual(html.count('class="o_pal_value o_pal_barcode"'), 2) self.assertEqual(html.count(" Date: Mon, 14 Sep 2026 06:28:25 +0000 Subject: [PATCH 05/17] [FIX] stock_acceptance_label: use company settings and align lot dates --- stock_acceptance_label/i18n/ja.po | 9 + stock_acceptance_label/models/__init__.py | 1 + stock_acceptance_label/models/res_company.py | 47 ++++ .../models/res_config_settings.py | 51 +--- stock_acceptance_label/models/stock_move.py | 68 ++--- .../models/stock_picking.py | 8 +- stock_acceptance_label/readme/CONFIGURE.md | 4 + stock_acceptance_label/readme/USAGE.md | 2 + .../tests/test_stock_acceptance_label.py | 241 ++++++++++++++---- .../views/res_config_settings_views.xml | 4 +- .../views/stock_picking_views.xml | 2 +- 11 files changed, 289 insertions(+), 148 deletions(-) create mode 100644 stock_acceptance_label/models/res_company.py diff --git a/stock_acceptance_label/i18n/ja.po b/stock_acceptance_label/i18n/ja.po index 5014398..888adfc 100644 --- a/stock_acceptance_label/i18n/ja.po +++ b/stock_acceptance_label/i18n/ja.po @@ -39,6 +39,7 @@ msgid "Arrival Date" msgstr "入荷日" #. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_res_company__acceptance_label_arrival_date_field_id #: model:ir.model.fields,field_description:stock_acceptance_label.field_res_config_settings__acceptance_label_arrival_date_field_id msgid "Arrival Date Field" msgstr "入荷日フィールド" @@ -48,6 +49,11 @@ msgstr "入荷日フィールド" msgid "Barcode" msgstr "バーコード" +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_res_company +msgid "Companies" +msgstr "会社" + #. module: stock_acceptance_label #: model:ir.model,name:stock_acceptance_label.model_res_config_settings msgid "Config Settings" @@ -64,6 +70,7 @@ msgid "Field printed as the arrival date on the acceptance label" msgstr "受入ラベルに入荷日として印字するフィールド" #. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_res_company__acceptance_label_arrival_date_field_id #: model:ir.model.fields,help:stock_acceptance_label.field_res_config_settings__acceptance_label_arrival_date_field_id msgid "" "Field printed as the arrival date on the acceptance label. Date fields of " @@ -96,6 +103,7 @@ msgid "Status" msgstr "ステータス" #. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_res_company__acceptance_label_status_html #: model:ir.model.fields,field_description:stock_acceptance_label.field_res_config_settings__acceptance_label_status_html #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form msgid "Status Area" @@ -107,6 +115,7 @@ msgid "Status area printed on the acceptance label, to be filled in by hand" msgstr "受入ラベルに印字する、手書きで記入するためのステータス欄" #. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_res_company__acceptance_label_status_html #: model:ir.model.fields,help:stock_acceptance_label.field_res_config_settings__acceptance_label_status_html msgid "" "Status area printed on the acceptance label. It is meant to be filled in by " diff --git a/stock_acceptance_label/models/__init__.py b/stock_acceptance_label/models/__init__.py index 6777458..4fe92cb 100644 --- a/stock_acceptance_label/models/__init__.py +++ b/stock_acceptance_label/models/__init__.py @@ -1,3 +1,4 @@ +from . import res_company from . import res_config_settings from . import stock_move from . import stock_picking diff --git a/stock_acceptance_label/models/res_company.py b/stock_acceptance_label/models/res_company.py new file mode 100644 index 0000000..648810c --- /dev/null +++ b/stock_acceptance_label/models/res_company.py @@ -0,0 +1,47 @@ +# 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.tools import html_sanitize, is_html_empty + + +class ResCompany(models.Model): + _inherit = "res.company" + + acceptance_label_arrival_date_field_id = fields.Many2one( + "ir.model.fields", + string="Arrival Date Field", + domain=[ + ("model", "in", ["stock.move", "stock.picking"]), + ("ttype", "in", ["date", "datetime"]), + ], + default=lambda self: self.env["ir.model.fields"]._get( + "stock.picking", "date_done" + ), + ondelete="set null", + help="Field printed as the arrival date on the acceptance label. Date " + "fields of the transfer and of its lines can be selected, and datetime " + "fields are converted to the user time zone. The effective date of the " + "transfer is used when this is left empty.", + ) + acceptance_label_status_html = fields.Html( + string="Status Area", + help="Status area printed on the acceptance label. It is meant to be " + "filled in by hand, and can be edited freely as long as it stays within " + "a few lines: the label has a fixed height, and anything that does not " + "fit is cut off. Empty it to restore the built-in status area.", + ) + + def _get_acceptance_arrival_date_field(self): + self.ensure_one() + return self.acceptance_label_arrival_date_field_id or self.env[ + "ir.model.fields" + ]._get("stock.picking", "date_done") + + def _get_acceptance_status_html(self): + self.ensure_one() + if is_html_empty(self.acceptance_label_status_html): + return self.env["ir.qweb"]._render( + "stock_acceptance_label.acceptance_label_status" + ) + return html_sanitize(self.acceptance_label_status_html) diff --git a/stock_acceptance_label/models/res_config_settings.py b/stock_acceptance_label/models/res_config_settings.py index 0268225..11e3b8c 100644 --- a/stock_acceptance_label/models/res_config_settings.py +++ b/stock_acceptance_label/models/res_config_settings.py @@ -4,54 +4,23 @@ from odoo import api, fields, models from odoo.tools import is_html_empty -from .stock_move import ARRIVAL_DATE_FIELD_PARAM, STATUS_HTML_PARAM - class ResConfigSettings(models.TransientModel): _inherit = "res.config.settings" acceptance_label_arrival_date_field_id = fields.Many2one( - "ir.model.fields", - string="Arrival Date Field", - domain=[ - ("model", "in", ["stock.move", "stock.picking"]), - ("ttype", "in", ["date", "datetime"]), - ], - default=lambda self: self._default_acceptance_label_arrival_date_field_id(), - config_parameter=ARRIVAL_DATE_FIELD_PARAM, - help="Field printed as the arrival date on the acceptance label. Date " - "fields of the transfer and of its lines can be selected, and datetime " - "fields are converted to the user time zone. The effective date of the " - "transfer is used when this is left empty.", + related="company_id.acceptance_label_arrival_date_field_id", + readonly=False, ) - # Read and written by hand rather than through config_parameter, which only - # supports the simple field types. acceptance_label_status_html = fields.Html( - string="Status Area", - help="Status area printed on the acceptance label. It is meant to be " - "filled in by hand, and can be edited freely as long as it stays within " - "a few lines: the label has a fixed height, and anything that does not " - "fit is cut off. Empty it to restore the built-in status area.", + related="company_id.acceptance_label_status_html", + readonly=False, ) - @api.model - def _default_acceptance_label_arrival_date_field_id(self): - return self.env["stock.move"]._get_acceptance_arrival_date_field().id - - def get_values(self): - res = super().get_values() - # Show the status area that is actually printed, so that the built-in one - # only has to be adjusted. - res["acceptance_label_status_html"] = self.env[ - "stock.move" - ].get_acceptance_status_html() - return res - - def set_values(self): - res = super().set_values() - status_html = self.acceptance_label_status_html - self.env["ir.config_parameter"].sudo().set_param( - STATUS_HTML_PARAM, False if is_html_empty(status_html) else status_html - ) - return res + @api.onchange("company_id") + def _onchange_company_id_acceptance_label(self): + if self.company_id and is_html_empty(self.acceptance_label_status_html): + self.acceptance_label_status_html = ( + self.company_id._get_acceptance_status_html() + ) diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index c096ba6..60a0591 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -1,11 +1,9 @@ # 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 -from odoo.tools import format_date, html_sanitize +from odoo import fields, models +from odoo.tools import format_date -ARRIVAL_DATE_FIELD_PARAM = "stock_acceptance_label.arrival_date_field_id" -STATUS_HTML_PARAM = "stock_acceptance_label.status_html" # The label is a form to be filled in by hand on the shop floor, so its dates # are printed in a fixed format instead of the format of the language. LABEL_DATE_FORMAT = "yyyy/MM/dd" @@ -16,29 +14,10 @@ class StockMove(models.Model): acceptance_number = fields.Char(copy=False) - @api.model - def _get_acceptance_arrival_date_field(self): - """Return the field to print as the arrival date of the label. - - The field is selected in the inventory settings. The effective date of - the transfer is used when the setting is empty or points to a field - that does not exist anymore. - """ - ir_model_fields = self.env["ir.model.fields"].sudo() - param = ( - self.env["ir.config_parameter"].sudo().get_param(ARRIVAL_DATE_FIELD_PARAM) - ) - field = ( - ir_model_fields.browse(int(param)) - if param and str(param).isdigit() - else ir_model_fields - ) - return field.exists() or ir_model_fields._get("stock.picking", "date_done") - def get_acceptance_arrival_date(self): """Return the configured arrival date, in the user time zone.""" self.ensure_one() - field = self._get_acceptance_arrival_date_field() + field = self.company_id._get_acceptance_arrival_date_field() if field.model == "stock.move": record = self elif field.model == "stock.picking": @@ -65,36 +44,21 @@ def get_acceptance_expiration_dates(self): """Return the expiration dates of the lots, as a single string. The dates come from the lots, so that nothing is printed as long as the - lot of the line is unknown. + lot of the line is unknown. Keep one entry per lot, in the same order as + the lot numbers, including duplicate dates and empty entries. """ - dates = { - fields.Datetime.context_timestamp(self, expiration_date).date() - for expiration_date in self._get_acceptance_lots().mapped("expiration_date") - if expiration_date - } return ", ".join( - format_date(self.env, date, date_format=LABEL_DATE_FORMAT) - for date in sorted(dates) + format_date( + self.env, + fields.Datetime.context_timestamp(self, lot.expiration_date).date(), + date_format=LABEL_DATE_FORMAT, + ) + if lot.expiration_date + else "" + for lot in self._get_acceptance_lots() ) - @api.model - def _get_default_acceptance_status_html(self): - """Return the built-in status area, in the language of the user.""" - return self.env["ir.qweb"]._render( - "stock_acceptance_label.acceptance_label_status" - ) - - @api.model def get_acceptance_status_html(self): - """Return the status area of the label, as set in the inventory settings. - - The value is sanitized on printing as well as on saving the settings, so - that the report cannot be made to run scripts through the underlying - system parameter. - """ - status_html = ( - self.env["ir.config_parameter"].sudo().get_param(STATUS_HTML_PARAM) - ) - if not status_html: - return self._get_default_acceptance_status_html() - return html_sanitize(status_html) + """Return the status area configured for the transfer's company.""" + self.ensure_one() + return self.company_id._get_acceptance_status_html() diff --git a/stock_acceptance_label/models/stock_picking.py b/stock_acceptance_label/models/stock_picking.py index 9562147..c9b6bb0 100644 --- a/stock_acceptance_label/models/stock_picking.py +++ b/stock_acceptance_label/models/stock_picking.py @@ -7,15 +7,13 @@ class StockPicking(models.Model): _inherit = "stock.picking" - # An A4 portrait sheet is split into three horizontal bands. - LABELS_PER_PAGE = 3 - def get_acceptance_label_pages(self): """Return the moves to print a label for, grouped per sheet. The moves are kept in the order of the transfers they belong to, so that the labels of a transfer stay together. """ + labels_per_page = 3 moves = [ move for picking in self @@ -23,6 +21,6 @@ def get_acceptance_label_pages(self): if move.state != "cancel" ] return [ - moves[index : index + self.LABELS_PER_PAGE] - for index in range(0, len(moves), self.LABELS_PER_PAGE) + moves[index : index + labels_per_page] + for index in range(0, len(moves), labels_per_page) ] diff --git a/stock_acceptance_label/readme/CONFIGURE.md b/stock_acceptance_label/readme/CONFIGURE.md index 3ee80ed..32a9165 100644 --- a/stock_acceptance_label/readme/CONFIGURE.md +++ b/stock_acceptance_label/readme/CONFIGURE.md @@ -1,6 +1,10 @@ The settings of the label are in *Inventory > Configuration > Settings > Acceptance Label*. +Both settings are specific to the company selected in the settings. Each label +uses the settings of its transfer's company, including when transfers from +several companies are printed together. + **Arrival Date** selects the date printed as the arrival date. Any date or datetime field of the transfer or of its lines can be selected, including the fields added by other modules, and datetime fields are converted to the user diff --git a/stock_acceptance_label/readme/USAGE.md b/stock_acceptance_label/readme/USAGE.md index 2cce2aa..8414927 100644 --- a/stock_acceptance_label/readme/USAGE.md +++ b/stock_acceptance_label/readme/USAGE.md @@ -12,3 +12,5 @@ with the default setting. The lot number and the expiration date are taken from the lots of the line, so they stay blank until the lots are assigned in the *Detailed Operations* of the transfer. A line received in several lots prints them all, separated by commas. +The expiration dates follow the same order as the lot numbers, including repeated +dates. A lot without an expiration date keeps an empty entry in the date list. diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py index 458019d..4cb359f 100644 --- a/stock_acceptance_label/tests/test_stock_acceptance_label.py +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -1,14 +1,12 @@ # Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from lxml import html + from odoo import Command, fields -from odoo.tests import tagged +from odoo.tests import Form, tagged from odoo.tests.common import TransactionCase - -from odoo.addons.stock_acceptance_label.models.stock_move import ( - ARRIVAL_DATE_FIELD_PARAM, - STATUS_HTML_PARAM, -) +from odoo.tools import is_html_empty # This module is loaded early in the module graph, so the tests have to run once @@ -19,6 +17,8 @@ class TestStockAcceptanceLabel(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() + cls.company = cls.env.company + cls.other_company = cls.env["res.company"].create({"name": "Other Company"}) cls.vendor = cls.env["res.partner"].create({"name": "Vendor"}) cls.product_a = cls.env["product.product"].create( { @@ -50,13 +50,15 @@ def setUpClass(cls): cls.picking = cls._create_picking(cls.product_a, cls.product_b) @classmethod - def _create_picking(cls, *products): + def _create_picking(cls, *products, picking_type=None): + picking_type = picking_type or cls.picking_type + location_dest = picking_type.default_location_dest_id return cls.env["stock.picking"].create( { - "picking_type_id": cls.picking_type.id, + "picking_type_id": picking_type.id, "partner_id": cls.vendor.id, "location_id": cls.location.id, - "location_dest_id": cls.location_dest.id, + "location_dest_id": location_dest.id, "move_ids": [ Command.create( { @@ -64,7 +66,7 @@ def _create_picking(cls, *products): "product_id": product.id, "product_uom_qty": 1.0, "location_id": cls.location.id, - "location_dest_id": cls.location_dest.id, + "location_dest_id": location_dest.id, } ) for product in products @@ -77,9 +79,14 @@ def setUp(self): # Drop the settings stored in the database, so that the tests describe # the behaviour of the module and not how the label happens to be # configured in the database they run on. - config_parameter = self.env["ir.config_parameter"].sudo() - config_parameter.set_param(ARRIVAL_DATE_FIELD_PARAM, False) - config_parameter.set_param(STATUS_HTML_PARAM, False) + (self.company | self.other_company).write( + { + "acceptance_label_arrival_date_field_id": self.env["ir.model.fields"] + ._get("stock.picking", "date_done") + .id, + "acceptance_label_status_html": False, + } + ) def _validate(self, picking): picking.action_confirm() @@ -98,10 +105,9 @@ def _validate(self, picking): picking._action_done() def _set_arrival_date_field(self, model, name): - self.env["ir.config_parameter"].sudo().set_param( - ARRIVAL_DATE_FIELD_PARAM, - self.env["ir.model.fields"]._get(model, name).id, - ) + self.company.acceptance_label_arrival_date_field_id = self.env[ + "ir.model.fields" + ]._get(model, name) def test_acceptance_number_is_not_copied(self): self.picking.move_ids[0].acceptance_number = "R016-20251017-01" @@ -142,8 +148,8 @@ def test_arrival_date_from_configured_field(self): expected = fields.Datetime.context_timestamp(move, move.date_deadline).date() self.assertEqual(move.get_acceptance_arrival_date(), expected) - def test_arrival_date_falls_back_on_invalid_setting(self): - self.env["ir.config_parameter"].sudo().set_param(ARRIVAL_DATE_FIELD_PARAM, "0") + def test_arrival_date_falls_back_on_empty_setting(self): + self.company.acceptance_label_arrival_date_field_id = False self._validate(self.picking) expected = fields.Datetime.context_timestamp( self.picking, self.picking.date_done @@ -164,7 +170,7 @@ def test_arrival_date_setting(self): ]._get("stock.picking", "scheduled_date") settings.execute() self.assertEqual( - self.env["stock.move"]._get_acceptance_arrival_date_field().name, + self.company._get_acceptance_arrival_date_field().name, "scheduled_date", ) @@ -198,54 +204,193 @@ def test_lot_without_expiration_date(self): self.assertFalse(move.get_acceptance_expiration_dates()) def test_several_lots_on_one_line(self): + move = self._create_move_with_lots( + ("LOT-0003", "2027-03-30 15:30:00"), + ("LOT-0004", "2027-04-01 15:30:00"), + ) + self.assertEqual(move.get_acceptance_lot_names(), "LOT-0003, LOT-0004") + self.assertEqual( + move.get_acceptance_expiration_dates(), "2027/03/31, 2027/04/02" + ) + + def _create_move_with_lots(self, *lot_values): picking = self._create_picking(self.product_lot) - picking.move_ids[0].product_uom_qty = 2.0 + picking.move_ids[0].product_uom_qty = len(lot_values) picking.action_confirm() move = picking.move_ids[0] move.move_line_ids.write({"quantity": 1.0}) - move.move_line_ids[0].lot_id = self._create_lot( - "LOT-0003", "2027-03-30 15:30:00" - ) + move.move_line_ids[0].lot_id = self._create_lot(*lot_values[0]) move.move_line_ids.create( - { - "move_id": move.id, - "product_id": self.product_lot.id, - "quantity": 1.0, - "location_id": move.location_id.id, - "location_dest_id": move.location_dest_id.id, - "lot_id": self._create_lot("LOT-0004", "2027-04-01 15:30:00").id, - } + [ + { + "move_id": move.id, + "product_id": self.product_lot.id, + "quantity": 1.0, + "location_id": move.location_id.id, + "location_dest_id": move.location_dest_id.id, + "lot_id": self._create_lot(*values).id, + } + for values in lot_values[1:] + ] ) - self.assertEqual(move.get_acceptance_lot_names(), "LOT-0003, LOT-0004") + return move + + def _assert_printed_lot_dates(self, move, expected): + # Check the actual report rows as well as the formatting helpers. self.assertEqual( - move.get_acceptance_expiration_dates(), "2027/03/31, 2027/04/02" + list( + zip( + move.get_acceptance_lot_names().split(", "), + move.get_acceptance_expiration_dates().split(", "), + strict=True, + ) + ), + expected, + ) + document = html.fromstring( + self.env["ir.actions.report"]._render_qweb_html( + "stock_acceptance_label.report_stock_acceptance_label", + move.picking_id.ids, + )[0] ) + lot_names = document.xpath("//tr[td='Lot No.']/td[2]")[0].text_content().strip() + dates = ( + document.xpath("//tr[td='Expiration Date']/td[2]")[0].text_content().strip() + ) + self.assertEqual(lot_names, ", ".join(name for name, date in expected)) + self.assertEqual(dates, ", ".join(date for name, date in expected).strip()) + + def test_expiration_dates_follow_lot_order(self): + move = self._create_move_with_lots( + ("LOT-A", "2027-12-30 15:30:00"), + ("LOT-B", "2027-03-30 15:30:00"), + ) + self._assert_printed_lot_dates( + move, [("LOT-A", "2027/12/31"), ("LOT-B", "2027/03/31")] + ) + + def test_expiration_dates_keep_duplicates(self): + move = self._create_move_with_lots( + ("LOT-A", "2027-03-30 15:30:00"), + ("LOT-B", "2027-03-30 15:30:00"), + ) + self._assert_printed_lot_dates( + move, [("LOT-A", "2027/03/31"), ("LOT-B", "2027/03/31")] + ) + + def test_expiration_dates_keep_empty_positions(self): + move = self._create_move_with_lots( + ("LOT-A", "2027-03-30 15:30:00"), + ("LOT-B", "2027-04-01 15:30:00"), + ("LOT-C", "2027-12-30 15:30:00"), + ) + lots = move._get_acceptance_lots() + for index in range(len(lots)): + with self.subTest(missing_expiration_index=index): + original_date = lots[index].expiration_date + lots[index].expiration_date = False + expected = [ + ("LOT-A", "2027/03/31"), + ("LOT-B", "2027/04/02"), + ("LOT-C", "2027/12/31"), + ] + expected[index] = (expected[index][0], "") + self._assert_printed_lot_dates(move, expected) + lots[index].expiration_date = original_date def test_status_area_setting(self): - settings = self.env["res.config.settings"].create({}) - # The setting comes filled in with the default status area. - self.assertIn("Under Inspection", settings.acceptance_label_status_html) - settings.acceptance_label_status_html = "
Accepted
" + with Form(self.env["res.config.settings"]) as settings_form: + # The setting comes filled in with the default status area. + self.assertIn( + "Under Inspection", settings_form.acceptance_label_status_html + ) + settings_form.acceptance_label_status_html = "
Accepted
" + settings = settings_form.record settings.execute() - self.assertIn("Accepted", self.env["stock.move"].get_acceptance_status_html()) + move = self.picking.move_ids[0] + self.assertIn("Accepted", move.get_acceptance_status_html()) # Emptying the setting restores the default status area. settings.acceptance_label_status_html = "


" settings.execute() - self.assertFalse( - self.env["ir.config_parameter"].sudo().get_param(STATUS_HTML_PARAM) - ) - self.assertIn( - "Under Inspection", self.env["stock.move"].get_acceptance_status_html() - ) + self.assertTrue(is_html_empty(self.company.acceptance_label_status_html)) + self.assertIn("Under Inspection", move.get_acceptance_status_html()) def test_status_area_is_sanitized(self): - self.env["ir.config_parameter"].sudo().set_param( - STATUS_HTML_PARAM, "
Accepted
" + self.company.acceptance_label_status_html = ( + "
Accepted
" ) - status_html = self.env["stock.move"].get_acceptance_status_html() + status_html = self.picking.move_ids[0].get_acceptance_status_html() self.assertIn("Accepted", status_html) self.assertNotIn("script", status_html) + def test_settings_are_company_specific(self): + scheduled_date = self.env["ir.model.fields"]._get( + "stock.picking", "scheduled_date" + ) + self.company.acceptance_label_status_html = "
First company
" + with Form(self.env["res.config.settings"]) as settings_form: + self.assertIn("First company", settings_form.acceptance_label_status_html) + settings_form.company_id = self.other_company + self.assertIn( + "Under Inspection", settings_form.acceptance_label_status_html + ) + settings_form.acceptance_label_arrival_date_field_id = scheduled_date + settings_form.acceptance_label_status_html = "
Second company
" + settings_form.record.execute() + self.assertEqual( + self.company.acceptance_label_arrival_date_field_id.name, "date_done" + ) + self.assertIn("First company", self.company.acceptance_label_status_html) + self.assertEqual( + self.other_company.acceptance_label_arrival_date_field_id, scheduled_date + ) + self.assertIn("Second company", self.other_company.acceptance_label_status_html) + + def test_report_uses_each_transfer_company(self): + picking_type = self.env["stock.picking.type"].search( + [("code", "=", "incoming"), ("company_id", "=", self.other_company.id)], + limit=1, + ) + other_picking = self._create_picking(self.product_b, picking_type=picking_type) + self._set_arrival_date_field("stock.picking", "scheduled_date") + self.company.acceptance_label_status_html = "
First company
" + self.other_company.acceptance_label_arrival_date_field_id = self.env[ + "ir.model.fields" + ]._get("stock.move", "date_deadline") + self.other_company.acceptance_label_status_html = "
Second company
" + self.picking.scheduled_date = "2026-08-01 15:30:00" + other_picking.move_ids.date_deadline = "2026-08-02 15:30:00" + user = self.env["res.users"].create( + { + "name": "Label User", + "login": "acceptance_label_user", + "company_id": self.company.id, + "company_ids": [Command.set((self.company | self.other_company).ids)], + "groups_id": [Command.set([self.env.ref("stock.group_stock_user").id])], + "tz": "Asia/Tokyo", + } + ) + report = ( + self.env["ir.actions.report"] + .with_user(user) + .with_context(allowed_company_ids=(self.company | self.other_company).ids) + ) + document = html.fromstring( + report._render_qweb_html( + "stock_acceptance_label.report_stock_acceptance_label", + (self.picking | other_picking).ids, + )[0] + ) + labels = document.xpath('//table[@class="o_pal_label"]') + self.assertEqual(len(labels), 3) + for label in labels[:2]: + self.assertIn("2026/08/02", label.text_content()) + self.assertIn("First company", label.text_content()) + self.assertNotIn("Second company", label.text_content()) + self.assertIn("2026/08/03", labels[2].text_content()) + self.assertIn("Second company", labels[2].text_content()) + self.assertNotIn("First company", labels[2].text_content()) + def test_report_html(self): self.picking.move_ids[0].acceptance_number = "R016-20251017-01" html = ( diff --git a/stock_acceptance_label/views/res_config_settings_views.xml b/stock_acceptance_label/views/res_config_settings_views.xml index c6db325..50ea95f 100644 --- a/stock_acceptance_label/views/res_config_settings_views.xml +++ b/stock_acceptance_label/views/res_config_settings_views.xml @@ -1,7 +1,7 @@ - res.config.settings.form.acceptance.label + res.config.settings.view.form.inherit.stock res.config.settings @@ -15,6 +15,7 @@ > @@ -25,6 +26,7 @@ diff --git a/stock_acceptance_label/views/stock_picking_views.xml b/stock_acceptance_label/views/stock_picking_views.xml index 123bbcd..538e3c3 100644 --- a/stock_acceptance_label/views/stock_picking_views.xml +++ b/stock_acceptance_label/views/stock_picking_views.xml @@ -1,7 +1,7 @@ - stock.picking.form.acceptance.label + stock.picking.form stock.picking From 32929d2696f0a38d2dd116e680a544a761fc7d98 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 14 Sep 2026 06:43:05 +0000 Subject: [PATCH 06/17] [REF] stock_acceptance_label: simplify tests and report helpers --- stock_acceptance_label/models/res_company.py | 7 - stock_acceptance_label/models/stock_move.py | 17 +- .../models/stock_picking.py | 6 +- .../tests/test_stock_acceptance_label.py | 255 ++++-------------- 4 files changed, 62 insertions(+), 223 deletions(-) diff --git a/stock_acceptance_label/models/res_company.py b/stock_acceptance_label/models/res_company.py index 648810c..4d164de 100644 --- a/stock_acceptance_label/models/res_company.py +++ b/stock_acceptance_label/models/res_company.py @@ -18,7 +18,6 @@ class ResCompany(models.Model): default=lambda self: self.env["ir.model.fields"]._get( "stock.picking", "date_done" ), - ondelete="set null", help="Field printed as the arrival date on the acceptance label. Date " "fields of the transfer and of its lines can be selected, and datetime " "fields are converted to the user time zone. The effective date of the " @@ -32,12 +31,6 @@ class ResCompany(models.Model): "fit is cut off. Empty it to restore the built-in status area.", ) - def _get_acceptance_arrival_date_field(self): - self.ensure_one() - return self.acceptance_label_arrival_date_field_id or self.env[ - "ir.model.fields" - ]._get("stock.picking", "date_done") - def _get_acceptance_status_html(self): self.ensure_one() if is_html_empty(self.acceptance_label_status_html): diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index 60a0591..3984df8 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -4,10 +4,6 @@ from odoo import fields, models from odoo.tools import format_date -# The label is a form to be filled in by hand on the shop floor, so its dates -# are printed in a fixed format instead of the format of the language. -LABEL_DATE_FORMAT = "yyyy/MM/dd" - class StockMove(models.Model): _inherit = "stock.move" @@ -17,7 +13,9 @@ class StockMove(models.Model): def get_acceptance_arrival_date(self): """Return the configured arrival date, in the user time zone.""" self.ensure_one() - field = self.company_id._get_acceptance_arrival_date_field() + field = self.company_id.acceptance_label_arrival_date_field_id or self.env[ + "ir.model.fields" + ]._get("stock.picking", "date_done") if field.model == "stock.move": record = self elif field.model == "stock.picking": @@ -41,17 +39,12 @@ def get_acceptance_lot_names(self): return ", ".join(self._get_acceptance_lots().mapped("name")) def get_acceptance_expiration_dates(self): - """Return the expiration dates of the lots, as a single string. - - The dates come from the lots, so that nothing is printed as long as the - lot of the line is unknown. Keep one entry per lot, in the same order as - the lot numbers, including duplicate dates and empty entries. - """ + """Keep one date per lot, in lot order, including duplicates and blanks.""" return ", ".join( format_date( self.env, fields.Datetime.context_timestamp(self, lot.expiration_date).date(), - date_format=LABEL_DATE_FORMAT, + date_format="yyyy/MM/dd", ) if lot.expiration_date else "" diff --git a/stock_acceptance_label/models/stock_picking.py b/stock_acceptance_label/models/stock_picking.py index c9b6bb0..a2330de 100644 --- a/stock_acceptance_label/models/stock_picking.py +++ b/stock_acceptance_label/models/stock_picking.py @@ -13,14 +13,10 @@ def get_acceptance_label_pages(self): The moves are kept in the order of the transfers they belong to, so that the labels of a transfer stay together. """ - labels_per_page = 3 moves = [ move for picking in self for move in picking.move_ids if move.state != "cancel" ] - return [ - moves[index : index + labels_per_page] - for index in range(0, len(moves), labels_per_page) - ] + return [moves[index : index + 3] for index in range(0, len(moves), 3)] diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py index 4cb359f..deab9b8 100644 --- a/stock_acceptance_label/tests/test_stock_acceptance_label.py +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -1,24 +1,23 @@ # Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from lxml import html - from odoo import Command, fields from odoo.tests import Form, tagged from odoo.tests.common import TransactionCase from odoo.tools import is_html_empty -# This module is loaded early in the module graph, so the tests have to run once -# every module is loaded: creating a product otherwise fails on the not-null -# columns of the modules that are not loaded yet. +# Run after all modules have initialized their required product fields. @tagged("post_install", "-at_install") class TestStockAcceptanceLabel(TransactionCase): @classmethod def setUpClass(cls): super().setUpClass() cls.company = cls.env.company - cls.other_company = cls.env["res.company"].create({"name": "Other Company"}) + cls.company.acceptance_label_arrival_date_field_id = cls.env[ + "ir.model.fields" + ]._get("stock.picking", "date_done") + cls.company.acceptance_label_status_html = False cls.vendor = cls.env["res.partner"].create({"name": "Vendor"}) cls.product_a = cls.env["product.product"].create( { @@ -50,15 +49,13 @@ def setUpClass(cls): cls.picking = cls._create_picking(cls.product_a, cls.product_b) @classmethod - def _create_picking(cls, *products, picking_type=None): - picking_type = picking_type or cls.picking_type - location_dest = picking_type.default_location_dest_id + def _create_picking(cls, *products): return cls.env["stock.picking"].create( { - "picking_type_id": picking_type.id, + "picking_type_id": cls.picking_type.id, "partner_id": cls.vendor.id, "location_id": cls.location.id, - "location_dest_id": location_dest.id, + "location_dest_id": cls.location_dest.id, "move_ids": [ Command.create( { @@ -66,7 +63,7 @@ def _create_picking(cls, *products, picking_type=None): "product_id": product.id, "product_uom_qty": 1.0, "location_id": cls.location.id, - "location_dest_id": location_dest.id, + "location_dest_id": cls.location_dest.id, } ) for product in products @@ -74,34 +71,17 @@ def _create_picking(cls, *products, picking_type=None): } ) - def setUp(self): - super().setUp() - # Drop the settings stored in the database, so that the tests describe - # the behaviour of the module and not how the label happens to be - # configured in the database they run on. - (self.company | self.other_company).write( - { - "acceptance_label_arrival_date_field_id": self.env["ir.model.fields"] - ._get("stock.picking", "date_done") - .id, - "acceptance_label_status_html": False, - } - ) - def _validate(self, picking): picking.action_confirm() for move in picking.move_ids: move.write({"quantity": move.product_uom_qty, "picked": True}) if "check_ids" in picking._fields: - # Pass the quality checks created by the optional quality modules, - # as they would otherwise block the transfer. One by one, as the - # quality modules only handle a single check at a time. + # Optional quality modules require checks to pass before validation. for check in picking.check_ids.filtered( lambda check: check.quality_state == "none" ): check.quality_state = "pass" - # _action_done() instead of button_validate(), which may return a wizard - # depending on the modules installed (quality checks, backorders, ...). + # Finish directly to avoid optional validation wizards. picking._action_done() def _set_arrival_date_field(self, model, name): @@ -134,6 +114,8 @@ def test_arrival_date(self): self.picking, self.picking.date_done ).date() self.assertEqual(move.get_acceptance_arrival_date(), expected) + self.company.acceptance_label_arrival_date_field_id = False + self.assertEqual(move.get_acceptance_arrival_date(), expected) def test_arrival_date_from_configured_field(self): self._set_arrival_date_field("stock.picking", "scheduled_date") @@ -148,16 +130,6 @@ def test_arrival_date_from_configured_field(self): expected = fields.Datetime.context_timestamp(move, move.date_deadline).date() self.assertEqual(move.get_acceptance_arrival_date(), expected) - def test_arrival_date_falls_back_on_empty_setting(self): - self.company.acceptance_label_arrival_date_field_id = False - self._validate(self.picking) - expected = fields.Datetime.context_timestamp( - self.picking, self.picking.date_done - ).date() - self.assertEqual( - self.picking.move_ids[0].get_acceptance_arrival_date(), expected - ) - def test_arrival_date_setting(self): settings = self.env["res.config.settings"].create({}) # The setting reflects the effective date of the transfer by default. @@ -170,7 +142,7 @@ def test_arrival_date_setting(self): ]._get("stock.picking", "scheduled_date") settings.execute() self.assertEqual( - self.company._get_acceptance_arrival_date_field().name, + self.company.acceptance_label_arrival_date_field_id.name, "scheduled_date", ) @@ -191,112 +163,44 @@ def test_lot_and_expiration_date(self): self.assertFalse(move.get_acceptance_lot_names()) self.assertFalse(move.get_acceptance_expiration_dates()) # 2027-03-31 00:30 in Asia/Tokyo, to cover the time zone conversion. - move.move_line_ids.lot_id = self._create_lot("LOT-0001", "2027-03-30 15:30:00") + lot = self._create_lot("LOT-0001", "2027-03-30 15:30:00") + move.move_line_ids.lot_id = lot self.assertEqual(move.get_acceptance_lot_names(), "LOT-0001") self.assertEqual(move.get_acceptance_expiration_dates(), "2027/03/31") - def test_lot_without_expiration_date(self): - picking = self._create_picking(self.product_lot) - picking.action_confirm() - move = picking.move_ids[0] - move.move_line_ids.lot_id = self._create_lot("LOT-0002", False) - self.assertEqual(move.get_acceptance_lot_names(), "LOT-0002") + lot.expiration_date = False + self.assertEqual(move.get_acceptance_lot_names(), "LOT-0001") self.assertFalse(move.get_acceptance_expiration_dates()) def test_several_lots_on_one_line(self): - move = self._create_move_with_lots( - ("LOT-0003", "2027-03-30 15:30:00"), - ("LOT-0004", "2027-04-01 15:30:00"), - ) - self.assertEqual(move.get_acceptance_lot_names(), "LOT-0003, LOT-0004") - self.assertEqual( - move.get_acceptance_expiration_dates(), "2027/03/31, 2027/04/02" - ) - - def _create_move_with_lots(self, *lot_values): picking = self._create_picking(self.product_lot) - picking.move_ids[0].product_uom_qty = len(lot_values) + picking.move_ids.product_uom_qty = 2.0 picking.action_confirm() move = picking.move_ids[0] - move.move_line_ids.write({"quantity": 1.0}) - move.move_line_ids[0].lot_id = self._create_lot(*lot_values[0]) + move.move_line_ids.quantity = 1.0 + lot_a = self._create_lot("LOT-A", "2027-12-30 15:30:00") + lot_b = self._create_lot("LOT-B", False) + move.move_line_ids.lot_id = lot_a move.move_line_ids.create( - [ - { - "move_id": move.id, - "product_id": self.product_lot.id, - "quantity": 1.0, - "location_id": move.location_id.id, - "location_dest_id": move.location_dest_id.id, - "lot_id": self._create_lot(*values).id, - } - for values in lot_values[1:] - ] - ) - return move - - def _assert_printed_lot_dates(self, move, expected): - # Check the actual report rows as well as the formatting helpers. - self.assertEqual( - list( - zip( - move.get_acceptance_lot_names().split(", "), - move.get_acceptance_expiration_dates().split(", "), - strict=True, - ) - ), - expected, - ) - document = html.fromstring( - self.env["ir.actions.report"]._render_qweb_html( - "stock_acceptance_label.report_stock_acceptance_label", - move.picking_id.ids, - )[0] - ) - lot_names = document.xpath("//tr[td='Lot No.']/td[2]")[0].text_content().strip() - dates = ( - document.xpath("//tr[td='Expiration Date']/td[2]")[0].text_content().strip() - ) - self.assertEqual(lot_names, ", ".join(name for name, date in expected)) - self.assertEqual(dates, ", ".join(date for name, date in expected).strip()) - - def test_expiration_dates_follow_lot_order(self): - move = self._create_move_with_lots( - ("LOT-A", "2027-12-30 15:30:00"), - ("LOT-B", "2027-03-30 15:30:00"), - ) - self._assert_printed_lot_dates( - move, [("LOT-A", "2027/12/31"), ("LOT-B", "2027/03/31")] - ) - - def test_expiration_dates_keep_duplicates(self): - move = self._create_move_with_lots( - ("LOT-A", "2027-03-30 15:30:00"), - ("LOT-B", "2027-03-30 15:30:00"), - ) - self._assert_printed_lot_dates( - move, [("LOT-A", "2027/03/31"), ("LOT-B", "2027/03/31")] - ) - - def test_expiration_dates_keep_empty_positions(self): - move = self._create_move_with_lots( - ("LOT-A", "2027-03-30 15:30:00"), - ("LOT-B", "2027-04-01 15:30:00"), - ("LOT-C", "2027-12-30 15:30:00"), + { + "move_id": move.id, + "product_id": self.product_lot.id, + "quantity": 1.0, + "location_id": move.location_id.id, + "location_dest_id": move.location_dest_id.id, + "lot_id": lot_b.id, + } ) - lots = move._get_acceptance_lots() - for index in range(len(lots)): - with self.subTest(missing_expiration_index=index): - original_date = lots[index].expiration_date - lots[index].expiration_date = False - expected = [ - ("LOT-A", "2027/03/31"), - ("LOT-B", "2027/04/02"), - ("LOT-C", "2027/12/31"), - ] - expected[index] = (expected[index][0], "") - self._assert_printed_lot_dates(move, expected) - lots[index].expiration_date = original_date + # A later first date catches sorting; equal and empty dates catch filtering. + for expiration_date, expected in [ + ("2027-03-30 15:30:00", "2027/12/31, 2027/03/31"), + ("2027-12-30 15:30:00", "2027/12/31, 2027/12/31"), + (False, "2027/12/31, "), + ]: + with self.subTest(expiration_date=expiration_date): + lot_b.expiration_date = expiration_date + self.assertEqual(move.get_acceptance_lot_names(), "LOT-A, LOT-B") + self.assertEqual(move.get_acceptance_expiration_dates(), expected) def test_status_area_setting(self): with Form(self.env["res.config.settings"]) as settings_form: @@ -324,72 +228,25 @@ def test_status_area_is_sanitized(self): self.assertNotIn("script", status_html) def test_settings_are_company_specific(self): - scheduled_date = self.env["ir.model.fields"]._get( - "stock.picking", "scheduled_date" - ) - self.company.acceptance_label_status_html = "
First company
" - with Form(self.env["res.config.settings"]) as settings_form: - self.assertIn("First company", settings_form.acceptance_label_status_html) - settings_form.company_id = self.other_company - self.assertIn( - "Under Inspection", settings_form.acceptance_label_status_html - ) - settings_form.acceptance_label_arrival_date_field_id = scheduled_date - settings_form.acceptance_label_status_html = "
Second company
" - settings_form.record.execute() - self.assertEqual( - self.company.acceptance_label_arrival_date_field_id.name, "date_done" - ) - self.assertIn("First company", self.company.acceptance_label_status_html) - self.assertEqual( - self.other_company.acceptance_label_arrival_date_field_id, scheduled_date - ) - self.assertIn("Second company", self.other_company.acceptance_label_status_html) - - def test_report_uses_each_transfer_company(self): - picking_type = self.env["stock.picking.type"].search( - [("code", "=", "incoming"), ("company_id", "=", self.other_company.id)], - limit=1, - ) - other_picking = self._create_picking(self.product_b, picking_type=picking_type) - self._set_arrival_date_field("stock.picking", "scheduled_date") - self.company.acceptance_label_status_html = "
First company
" - self.other_company.acceptance_label_arrival_date_field_id = self.env[ - "ir.model.fields" - ]._get("stock.move", "date_deadline") - self.other_company.acceptance_label_status_html = "
Second company
" - self.picking.scheduled_date = "2026-08-01 15:30:00" - other_picking.move_ids.date_deadline = "2026-08-02 15:30:00" - user = self.env["res.users"].create( + other_company = self.env["res.company"].create({"name": "Other Company"}) + settings = self.env["res.config.settings"].create( { - "name": "Label User", - "login": "acceptance_label_user", - "company_id": self.company.id, - "company_ids": [Command.set((self.company | self.other_company).ids)], - "groups_id": [Command.set([self.env.ref("stock.group_stock_user").id])], - "tz": "Asia/Tokyo", + "company_id": other_company.id, + "acceptance_label_arrival_date_field_id": self.env["ir.model.fields"] + ._get("stock.picking", "scheduled_date") + .id, + "acceptance_label_status_html": "
Other company
", } ) - report = ( - self.env["ir.actions.report"] - .with_user(user) - .with_context(allowed_company_ids=(self.company | self.other_company).ids) - ) - document = html.fromstring( - report._render_qweb_html( - "stock_acceptance_label.report_stock_acceptance_label", - (self.picking | other_picking).ids, - )[0] + settings.execute() + self.assertEqual( + other_company.acceptance_label_arrival_date_field_id.name, "scheduled_date" ) - labels = document.xpath('//table[@class="o_pal_label"]') - self.assertEqual(len(labels), 3) - for label in labels[:2]: - self.assertIn("2026/08/02", label.text_content()) - self.assertIn("First company", label.text_content()) - self.assertNotIn("Second company", label.text_content()) - self.assertIn("2026/08/03", labels[2].text_content()) - self.assertIn("Second company", labels[2].text_content()) - self.assertNotIn("First company", labels[2].text_content()) + self.assertIn("Other company", other_company.acceptance_label_status_html) + # Printing follows the move's company even when another company is active. + move = self.picking.move_ids[0].with_company(other_company) + self.assertFalse(move.get_acceptance_arrival_date()) + self.assertIn("Under Inspection", move.get_acceptance_status_html()) def test_report_html(self): self.picking.move_ids[0].acceptance_number = "R016-20251017-01" From 64ee06cf39c538968d3d390b395f431fea516f10 Mon Sep 17 00:00:00 2001 From: "Toshikimi Shigenobu (Quartile)" Date: Mon, 14 Sep 2026 17:51:55 +0900 Subject: [PATCH 07/17] Update res_config_settings.py --- stock_acceptance_label/models/res_config_settings.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/stock_acceptance_label/models/res_config_settings.py b/stock_acceptance_label/models/res_config_settings.py index 11e3b8c..1f15035 100644 --- a/stock_acceptance_label/models/res_config_settings.py +++ b/stock_acceptance_label/models/res_config_settings.py @@ -17,10 +17,3 @@ class ResConfigSettings(models.TransientModel): related="company_id.acceptance_label_status_html", readonly=False, ) - - @api.onchange("company_id") - def _onchange_company_id_acceptance_label(self): - if self.company_id and is_html_empty(self.acceptance_label_status_html): - self.acceptance_label_status_html = ( - self.company_id._get_acceptance_status_html() - ) From d412cd8e67f69c769174c2d49926f1c12c25db72 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 15 Sep 2026 08:42:52 +0000 Subject: [PATCH 08/17] fixup! --- stock_acceptance_label/README.rst | 14 ++++++--- stock_acceptance_label/models/res_company.py | 15 ++++++++-- .../models/res_config_settings.py | 3 +- stock_acceptance_label/models/stock_move.py | 10 ++++--- stock_acceptance_label/readme/CONFIGURE.md | 4 +-- .../static/description/index.html | 13 +++++--- .../tests/test_stock_acceptance_label.py | 30 ++++++++++++------- 7 files changed, 60 insertions(+), 29 deletions(-) diff --git a/stock_acceptance_label/README.rst b/stock_acceptance_label/README.rst index 100f204..2bada08 100644 --- a/stock_acceptance_label/README.rst +++ b/stock_acceptance_label/README.rst @@ -56,6 +56,10 @@ Configuration The settings of the label are in *Inventory > Configuration > Settings > Acceptance Label*. +Both settings are specific to the company selected in the settings. Each +label uses the settings of its transfer's company, including when +transfers from several companies are printed together. + **Arrival Date** selects the date printed as the arrival date. Any date or datetime field of the transfer or of its lines can be selected, including the fields added by other modules, and datetime fields are @@ -66,8 +70,8 @@ default, and also when the selected field is emptied or points to a field that does not exist anymore. **Status Area** holds the content of the status row, and can be edited -freely. It comes filled in with the default area, so that it only has to -be adjusted: +freely. It is empty by default, in which case the label prints the +built-in area: :: @@ -77,7 +81,7 @@ be adjusted: Keep the area within a few lines: the label has a fixed height, and anything that does not fit in its band is cut off. Emptying the setting -restores the default area, which follows the language of the printing +restores the built-in area, which follows the language of the printing user, while an area entered here is printed as such in every language. Usage @@ -99,7 +103,9 @@ is done with the default setting. The lot number and the expiration date are taken from the lots of the line, so they stay blank until the lots are assigned in the *Detailed Operations* of the transfer. A line received in several lots prints them -all, separated by commas. +all, separated by commas. The expiration dates follow the same order as +the lot numbers, including repeated dates. A lot without an expiration +date keeps an empty entry in the date list. Bug Tracker =========== diff --git a/stock_acceptance_label/models/res_company.py b/stock_acceptance_label/models/res_company.py index 4d164de..d3fa38b 100644 --- a/stock_acceptance_label/models/res_company.py +++ b/stock_acceptance_label/models/res_company.py @@ -8,6 +8,9 @@ class ResCompany(models.Model): _inherit = "res.company" + def _default_acceptance_label_arrival_date_field(self): + return self.env["ir.model.fields"]._get("stock.picking", "date_done") + acceptance_label_arrival_date_field_id = fields.Many2one( "ir.model.fields", string="Arrival Date Field", @@ -15,9 +18,7 @@ class ResCompany(models.Model): ("model", "in", ["stock.move", "stock.picking"]), ("ttype", "in", ["date", "datetime"]), ], - default=lambda self: self.env["ir.model.fields"]._get( - "stock.picking", "date_done" - ), + default=lambda self: self._default_acceptance_label_arrival_date_field(), help="Field printed as the arrival date on the acceptance label. Date " "fields of the transfer and of its lines can be selected, and datetime " "fields are converted to the user time zone. The effective date of the " @@ -31,6 +32,14 @@ class ResCompany(models.Model): "fit is cut off. Empty it to restore the built-in status area.", ) + def _get_acceptance_arrival_date_field(self): + """Return the field to read the arrival date from.""" + self.ensure_one() + return ( + self.acceptance_label_arrival_date_field_id + or self._default_acceptance_label_arrival_date_field() + ) + def _get_acceptance_status_html(self): self.ensure_one() if is_html_empty(self.acceptance_label_status_html): diff --git a/stock_acceptance_label/models/res_config_settings.py b/stock_acceptance_label/models/res_config_settings.py index 1f15035..9e8e23e 100644 --- a/stock_acceptance_label/models/res_config_settings.py +++ b/stock_acceptance_label/models/res_config_settings.py @@ -1,8 +1,7 @@ # 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 -from odoo.tools import is_html_empty +from odoo import fields, models class ResConfigSettings(models.TransientModel): diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index 3984df8..74562c3 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -10,12 +10,14 @@ class StockMove(models.Model): acceptance_number = fields.Char(copy=False) + def _prepare_merge_moves_distinct_fields(self): + # Keep one label, and one acceptance number, per numbered line. + return super()._prepare_merge_moves_distinct_fields() + ["acceptance_number"] + def get_acceptance_arrival_date(self): """Return the configured arrival date, in the user time zone.""" self.ensure_one() - field = self.company_id.acceptance_label_arrival_date_field_id or self.env[ - "ir.model.fields" - ]._get("stock.picking", "date_done") + field = self.company_id._get_acceptance_arrival_date_field() if field.model == "stock.move": record = self elif field.model == "stock.picking": @@ -32,7 +34,7 @@ def get_acceptance_arrival_date(self): def _get_acceptance_lots(self): """Return the lots the transfer line is received in, if any.""" self.ensure_one() - return self.move_line_ids.lot_id + return self.lot_ids def get_acceptance_lot_names(self): """Return the lot numbers of the transfer line, as a single string.""" diff --git a/stock_acceptance_label/readme/CONFIGURE.md b/stock_acceptance_label/readme/CONFIGURE.md index 32a9165..184f633 100644 --- a/stock_acceptance_label/readme/CONFIGURE.md +++ b/stock_acceptance_label/readme/CONFIGURE.md @@ -15,7 +15,7 @@ also when the selected field is emptied or points to a field that does not exist anymore. **Status Area** holds the content of the status row, and can be edited freely. -It comes filled in with the default area, so that it only has to be adjusted: +It is empty by default, in which case the label prints the built-in area: ``` □ Under Inspection @@ -25,5 +25,5 @@ It comes filled in with the default area, so that it only has to be adjusted: Keep the area within a few lines: the label has a fixed height, and anything that does not fit in its band is cut off. Emptying the setting restores the -default area, which follows the language of the printing user, while an area +built-in area, which follows the language of the printing user, while an area entered here is printed as such in every language. diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html index 424d988..82b33fe 100644 --- a/stock_acceptance_label/static/description/index.html +++ b/stock_acceptance_label/static/description/index.html @@ -407,6 +407,9 @@

Stock Acceptance Label

Configuration

The settings of the label are in Inventory > Configuration > Settings > Acceptance Label.

+

Both settings are specific to the company selected in the settings. Each +label uses the settings of its transfer’s company, including when +transfers from several companies are printed together.

Arrival Date selects the date printed as the arrival date. Any date or datetime field of the transfer or of its lines can be selected, including the fields added by other modules, and datetime fields are @@ -415,8 +418,8 @@

Configuration

default, and also when the selected field is emptied or points to a field that does not exist anymore.

Status Area holds the content of the status row, and can be edited -freely. It comes filled in with the default area, so that it only has to -be adjusted:

+freely. It is empty by default, in which case the label prints the +built-in area:

 □ Under Inspection
 ↓
@@ -424,7 +427,7 @@ 

Configuration

Keep the area within a few lines: the label has a fixed height, and anything that does not fit in its band is cut off. Emptying the setting -restores the default area, which follows the language of the printing +restores the built-in area, which follows the language of the printing user, while an area entered here is printed as such in every language.

@@ -442,7 +445,9 @@

Usage

The lot number and the expiration date are taken from the lots of the line, so they stay blank until the lots are assigned in the Detailed Operations of the transfer. A line received in several lots prints them -all, separated by commas.

+all, separated by commas. The expiration dates follow the same order as +the lot numbers, including repeated dates. A lot without an expiration +date keeps an empty entry in the date list.

Bug Tracker

diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py index deab9b8..5295a3d 100644 --- a/stock_acceptance_label/tests/test_stock_acceptance_label.py +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -2,7 +2,7 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from odoo import Command, fields -from odoo.tests import Form, tagged +from odoo.tests import tagged from odoo.tests.common import TransactionCase from odoo.tools import is_html_empty @@ -93,6 +93,18 @@ def test_acceptance_number_is_not_copied(self): self.picking.move_ids[0].acceptance_number = "R016-20251017-01" self.assertFalse(self.picking.copy().move_ids[0].acceptance_number) + def test_numbered_lines_are_not_merged(self): + picking = self._create_picking(self.product_a, self.product_a) + # Identical lines are merged on confirmation, so one label is printed. + unnumbered = picking.copy() + unnumbered.action_confirm() + self.assertEqual(len(unnumbered.move_ids), 1) + # Lines that carry their own acceptance number keep their own label. + picking.move_ids[0].acceptance_number = "R016-20251017-01" + picking.move_ids[1].acceptance_number = "R016-20251017-02" + picking.action_confirm() + self.assertEqual(len(picking.move_ids), 2) + def test_label_pages_layout(self): other_picking = self._create_picking(*([self.product_a] * 5)) pages = (self.picking | other_picking).get_acceptance_label_pages() @@ -203,17 +215,15 @@ def test_several_lots_on_one_line(self): self.assertEqual(move.get_acceptance_expiration_dates(), expected) def test_status_area_setting(self): - with Form(self.env["res.config.settings"]) as settings_form: - # The setting comes filled in with the default status area. - self.assertIn( - "Under Inspection", settings_form.acceptance_label_status_html - ) - settings_form.acceptance_label_status_html = "
Accepted
" - settings = settings_form.record - settings.execute() move = self.picking.move_ids[0] + # The built-in status area is printed as long as the setting is empty. + self.assertIn("Under Inspection", move.get_acceptance_status_html()) + settings = self.env["res.config.settings"].create( + {"acceptance_label_status_html": "
Accepted
"} + ) + settings.execute() self.assertIn("Accepted", move.get_acceptance_status_html()) - # Emptying the setting restores the default status area. + # Emptying the setting restores the built-in status area. settings.acceptance_label_status_html = "


" settings.execute() self.assertTrue(is_html_empty(self.company.acceptance_label_status_html)) From edc532ab664c1336649f98fa608046916d807a2d Mon Sep 17 00:00:00 2001 From: "Toshikimi Shigenobu (Quartile)" Date: Tue, 15 Sep 2026 18:00:37 +0900 Subject: [PATCH 09/17] Update stock_acceptance_label/models/res_config_settings.py Co-authored-by: Aung Ko Ko Lin (Quartile) <45355704+AungKoKoLin1997@users.noreply.github.com> --- stock_acceptance_label/models/res_config_settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/stock_acceptance_label/models/res_config_settings.py b/stock_acceptance_label/models/res_config_settings.py index 9e8e23e..3efca9a 100644 --- a/stock_acceptance_label/models/res_config_settings.py +++ b/stock_acceptance_label/models/res_config_settings.py @@ -11,7 +11,6 @@ class ResConfigSettings(models.TransientModel): related="company_id.acceptance_label_arrival_date_field_id", readonly=False, ) - acceptance_label_status_html = fields.Html( related="company_id.acceptance_label_status_html", readonly=False, From 7268f37fd7c2ef7e4410ac570b3a35f858575984 Mon Sep 17 00:00:00 2001 From: t Date: Fri, 18 Sep 2026 05:13:43 +0000 Subject: [PATCH 10/17] [IMP] stock_acceptance_label: hold the acceptance number per lot The acceptance number was a plain field of the transfer line, so a line received in several lots could only carry one number, and the lots kept none of their own. The detailed operations now hold the number, the transfer line summarizes the numbers of its operations, and each lot keeps the numbers it was received under. A line of a product without tracking is still numbered on the line itself, which is carried over to its first detailed operation; for a tracked product the field is read-only on the line and is entered per lot instead. The numbers of the lot are computed from its operations rather than appended to on receipt, so that a number corrected, or a receipt cancelled after the fact, drops out instead of staying on the lot for good. Transfers can also be searched by acceptance number. --- stock_acceptance_label/README.rst | 48 +++++--- stock_acceptance_label/__manifest__.py | 1 + stock_acceptance_label/i18n/ja.po | 73 ++++++++++-- stock_acceptance_label/models/__init__.py | 2 + stock_acceptance_label/models/stock_lot.py | 36 ++++++ stock_acceptance_label/models/stock_move.py | 49 +++++++- .../models/stock_move_line.py | 44 ++++++++ .../models/stock_picking.py | 19 +++- stock_acceptance_label/readme/DESCRIPTION.md | 12 +- stock_acceptance_label/readme/USAGE.md | 8 ++ .../static/description/index.html | 63 +++++++---- .../tests/test_stock_acceptance_label.py | 106 ++++++++++++++++++ .../views/stock_lot_views.xml | 24 ++++ .../views/stock_picking_views.xml | 40 ++++++- 14 files changed, 474 insertions(+), 51 deletions(-) create mode 100644 stock_acceptance_label/models/stock_lot.py create mode 100644 stock_acceptance_label/models/stock_move_line.py create mode 100644 stock_acceptance_label/views/stock_lot_views.xml diff --git a/stock_acceptance_label/README.rst b/stock_acceptance_label/README.rst index 2bada08..6812a20 100644 --- a/stock_acceptance_label/README.rst +++ b/stock_acceptance_label/README.rst @@ -28,22 +28,32 @@ sheet). Each label shows the following rows, in the same layout: -- the product name; -- the acceptance number; -- the model number (product internal reference); -- the lot number of the line, blank as long as no lot is assigned; -- the expiration date of that lot, blank when it has none; -- the arrival date (the effective date of the transfer, or any other - date field selected in the settings); -- a status area with checkboxes to be ticked by hand, which can be - edited in the settings; -- the product barcode (Code128). +- the product name; +- the acceptance number; +- the model number (product internal reference); +- the lot number of the line, blank as long as no lot is assigned; +- the expiration date of that lot, blank when it has none; +- the arrival date (the effective date of the transfer, or any other + date field selected in the settings); +- a status area with checkboxes to be ticked by hand, which can be + edited in the settings; +- the product barcode (Code128). One label is printed per transfer line, and several transfers can be selected at once so that all their lines are printed in a single PDF. -It also adds an **Acceptance Number** field on the transfer lines, which -is where the printed acceptance number comes from. +It also adds an **Acceptance Number** field, which is where the printed +acceptance number comes from. The number is held by the detailed +operations of the transfer, so that a line received in several lots can +be numbered per lot, and the transfer line shows the summary of the +numbers of its operations. + +Each lot keeps the acceptance numbers it was received under, and a lot +received several times shows them all. They follow the receipts: a +number corrected, or a receipt cancelled after the fact, drops out of +the lot. + +Transfers can be searched by acceptance number. **Table of contents** @@ -91,6 +101,12 @@ Enter the **Acceptance Number** of each line in the *Operations* tab of the transfer. It may be left empty, in which case the field is printed blank. +For a product with tracking the field is read-only there, and is entered +per lot in the *Detailed Operations* of the transfer instead; the line +then shows the summary of the numbers of its lots. For a product without +tracking, a number entered on the line is applied to its detailed +operations. + Then go to *Inventory > Transfers*, select one or more transfers, and use *Print > Acceptance Label*. Three labels are printed per sheet, and the labels of a transfer stay together in the order of the selected @@ -107,6 +123,10 @@ all, separated by commas. The expiration dates follow the same order as the lot numbers, including repeated dates. A lot without an expiration date keeps an empty entry in the date list. +Transfers can be searched by acceptance number from the search bar of +the transfer list, and the numbers received under a lot are shown on the +lot itself. + Bug Tracker =========== @@ -128,9 +148,9 @@ Authors Contributors ------------ -- `Quartile `__: +- `Quartile `__: - - Tatsuki Kanda + - Tatsuki Kanda Maintainers ----------- diff --git a/stock_acceptance_label/__manifest__.py b/stock_acceptance_label/__manifest__.py index dc4120b..3b9c350 100644 --- a/stock_acceptance_label/__manifest__.py +++ b/stock_acceptance_label/__manifest__.py @@ -14,6 +14,7 @@ "report/stock_acceptance_label_report.xml", "report/stock_acceptance_label_templates.xml", "views/res_config_settings_views.xml", + "views/stock_lot_views.xml", "views/stock_picking_views.xml", ], "installable": True, diff --git a/stock_acceptance_label/i18n/ja.po b/stock_acceptance_label/i18n/ja.po index 888adfc..1bd4264 100644 --- a/stock_acceptance_label/i18n/ja.po +++ b/stock_acceptance_label/i18n/ja.po @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 18.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-08-04 09:31+0000\n" -"PO-Revision-Date: 2026-08-04 09:31+0000\n" +"POT-Creation-Date: 2026-09-18 05:16+0000\n" +"PO-Revision-Date: 2026-09-18 05:16+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -27,11 +27,37 @@ msgid "Acceptance Label" msgstr "受入ラベル" #. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_lot__acceptance_number #: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move__acceptance_number +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_move_line__acceptance_number +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_picking__acceptance_number #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Acceptance Number" msgstr "受入番号" +#. module: stock_acceptance_label +#: model:ir.model.fields,field_description:stock_acceptance_label.field_stock_lot__acceptance_move_line_ids +msgid "Acceptance Operations" +msgstr "受入オペレーション" + +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_move_line__acceptance_number +msgid "" +"Acceptance number of this detailed operation. It is the number printed on " +"the label, and the one kept on the lot it is received in." +msgstr "" +"この詳細オペレーションの受入番号。ラベルに印字される番号であり、入荷先のロッ" +"トにも保持されます。" + +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_lot__acceptance_number +msgid "" +"Acceptance numbers the lot was received under. A lot received several times " +"carries them all, separated by commas." +msgstr "" +"このロットが入荷した際の受入番号。複数回入荷したロットはそのすべてをカンマ区" +"切りで保持します。" + #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form @@ -59,6 +85,15 @@ msgstr "会社" msgid "Config Settings" msgstr "コンフィグ設定" +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_lot__acceptance_move_line_ids +msgid "" +"Detailed operations the lot appears in, which the acceptance numbers of the " +"lot are read from." +msgstr "" +"このロットが含まれる詳細オペレーション。ロットの受入番号はここから取得されま" +"す。" + #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Expiration Date" @@ -78,20 +113,28 @@ msgid "" "converted to the user time zone. The effective date of the transfer is used " "when this is left empty." msgstr "" -"受入ラベルに入荷日として印字するフィールド。転送およびその明細の日付・日時" -"フィールドを選択でき、日時フィールドはユーザのタイムゾーンに変換されます。空欄" -"の場合は転送の実施日が使用されます。" +"受入ラベルに入荷日として印字するフィールド。転送およびその明細の日付・日時フィールドを選択でき、日時フィールドはユーザのタイムゾーンに変換されます。空欄の場合は転送の実施日が使用されます。" #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Lot No." msgstr "ロットNo." +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_stock_lot +msgid "Lot/Serial" +msgstr "ロット/シリアル" + #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Model Number" msgstr "型番" +#. module: stock_acceptance_label +#: model:ir.model,name:stock_acceptance_label.model_stock_move_line +msgid "Product Moves (Stock Move Line)" +msgstr "プロダクトの移動(在庫移動ライン)" + #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label msgid "Product Name" @@ -123,15 +166,29 @@ msgid "" "label has a fixed height, and anything that does not fit is cut off. Empty " "it to restore the built-in status area." msgstr "" -"受入ラベルに印字するステータス欄。手書きで記入するための欄で、数行に収まる範囲" -"であれば自由に編集できます。ラベルの高さは固定されているため、収まらない部分は" -"切り捨てられます。空欄にすると既定のステータス欄に戻ります。" +"受入ラベルに印字するステータス欄。手書きで記入するための欄で、数行に収まる範囲であれば自由に編集できます。ラベルの高さは固定されているため、収まらない部分は切り捨てられます。空欄にすると既定のステータス欄に戻ります。" #. module: stock_acceptance_label #: model:ir.model,name:stock_acceptance_label.model_stock_move msgid "Stock Move" msgstr "在庫移動" +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_move__acceptance_number +msgid "" +"Summary of the acceptance numbers of the detailed operations of the line. A " +"line of a product without tracking can be numbered here directly; a tracked " +"one is numbered per lot in its detailed operations." +msgstr "" +"この明細の詳細オペレーションの受入番号のまとめ。追跡のないプロダクトの明細は" +"ここで直接入力でき、追跡のあるプロダクトは詳細オペレーションでロットごとに入" +"力します。" + +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_picking__acceptance_number +msgid "Summary of the acceptance numbers of the lines of the transfer." +msgstr "転送の明細の受入番号のまとめ。" + #. module: stock_acceptance_label #: model:ir.model,name:stock_acceptance_label.model_stock_picking msgid "Transfer" diff --git a/stock_acceptance_label/models/__init__.py b/stock_acceptance_label/models/__init__.py index 4fe92cb..cb3e694 100644 --- a/stock_acceptance_label/models/__init__.py +++ b/stock_acceptance_label/models/__init__.py @@ -1,4 +1,6 @@ from . import res_company from . import res_config_settings +from . import stock_lot from . import stock_move +from . import stock_move_line from . import stock_picking diff --git a/stock_acceptance_label/models/stock_lot.py b/stock_acceptance_label/models/stock_lot.py new file mode 100644 index 0000000..4e7a4db --- /dev/null +++ b/stock_acceptance_label/models/stock_lot.py @@ -0,0 +1,36 @@ +# 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 StockLot(models.Model): + _inherit = "stock.lot" + + acceptance_move_line_ids = fields.One2many( + "stock.move.line", + "lot_id", + string="Acceptance Operations", + help="Detailed operations the lot appears in, which the acceptance " + "numbers of the lot are read from.", + ) + acceptance_number = fields.Char( + compute="_compute_acceptance_number", + store=True, + help="Acceptance numbers the lot was received under. A lot received " + "several times carries them all, separated by commas.", + ) + + @api.depends( + "acceptance_move_line_ids.acceptance_number", + "acceptance_move_line_ids.state", + ) + def _compute_acceptance_number(self): + # Kept computed rather than appended to on receipt, so that a number + # corrected or a receipt cancelled after the fact drops out instead of + # staying on the lot for good. + for lot in self: + lines = lot.acceptance_move_line_ids.filtered( + lambda line: line.state != "cancel" + ) + lot.acceptance_number = ", ".join(lines._get_acceptance_numbers()) diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index 74562c3..1e9f5b9 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -1,14 +1,59 @@ # 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 import api, fields, models from odoo.tools import format_date class StockMove(models.Model): _inherit = "stock.move" - acceptance_number = fields.Char(copy=False) + acceptance_number = fields.Char( + compute="_compute_acceptance_number", + inverse="_inverse_acceptance_number", + store=True, + readonly=False, + copy=False, + help="Summary of the acceptance numbers of the detailed operations of " + "the line. A line of a product without tracking can be numbered here " + "directly; a tracked one is numbered per lot in its detailed " + "operations.", + ) + + @api.depends("move_line_ids.acceptance_number") + def _compute_acceptance_number(self): + for move in self: + # Only the detailed operations are summarized, so the number + # entered before they exist survives: it is carried over to the + # first one created, which gives back the same summary. + move.acceptance_number = ", ".join( + move.move_line_ids._get_acceptance_numbers() + ) + + def _inverse_acceptance_number(self): + for move in self: + # A number entered on the line applies to its detailed operations, + # which is where it is read back from. A tracked product is + # numbered per lot, so its operations are left alone. + if move.has_tracking != "none": + continue + move.move_line_ids.acceptance_number = move.acceptance_number + + def _get_acceptance_numbers(self): + """Return the acceptance numbers of the lines, as a summary. + + The numbers of the detailed operations are used, falling back on the + number of the line itself as long as it has no operation to read. + """ + numbers = [] + for move in self: + own = move.move_line_ids._get_acceptance_numbers() or [ + move.acceptance_number + ] + for number in own: + if number and number not in numbers: + numbers.append(number) + return numbers def _prepare_merge_moves_distinct_fields(self): # Keep one label, and one acceptance number, per numbered line. diff --git a/stock_acceptance_label/models/stock_move_line.py b/stock_acceptance_label/models/stock_move_line.py new file mode 100644 index 0000000..12b654f --- /dev/null +++ b/stock_acceptance_label/models/stock_move_line.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 StockMoveLine(models.Model): + _inherit = "stock.move.line" + + acceptance_number = fields.Char( + copy=False, + help="Acceptance number of this detailed operation. It is the number " + "printed on the label, and the one kept on the lot it is received in.", + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get("acceptance_number") or not vals.get("move_id"): + continue + move = self.env["stock.move"].browse(vals["move_id"]) + # Carry the number entered on the transfer line over to its first + # detailed operation: the operations are where the number is read + # back from, and they are only created once the line is reserved. + # Once one of them carries a number, the number of the transfer + # line is their summary and must not be pushed back down. + if ( + move.acceptance_number + and not move.move_line_ids._get_acceptance_numbers() + ): + vals["acceptance_number"] = move.acceptance_number + return super().create(vals_list) + + def _get_acceptance_numbers(self): + """Return the acceptance numbers of the operations. + + Blanks are dropped and repeats are kept once, in the order of the + operations, so that the result reads as a summary. + """ + numbers = [] + for line in self: + if line.acceptance_number and line.acceptance_number not in numbers: + numbers.append(line.acceptance_number) + return numbers diff --git a/stock_acceptance_label/models/stock_picking.py b/stock_acceptance_label/models/stock_picking.py index a2330de..c3d0a31 100644 --- a/stock_acceptance_label/models/stock_picking.py +++ b/stock_acceptance_label/models/stock_picking.py @@ -1,12 +1,29 @@ # Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import models +from odoo import api, fields, models class StockPicking(models.Model): _inherit = "stock.picking" + acceptance_number = fields.Char( + compute="_compute_acceptance_number", + search="_search_acceptance_number", + help="Summary of the acceptance numbers of the lines of the transfer.", + ) + + @api.depends("move_ids.acceptance_number", "move_ids.state") + def _compute_acceptance_number(self): + for picking in self: + moves = picking.move_ids.filtered(lambda move: move.state != "cancel") + picking.acceptance_number = ", ".join(moves._get_acceptance_numbers()) + + def _search_acceptance_number(self, operator, value): + # Searched on the lines, whose own number is stored and indexed, rather + # than on the summary, which is not. + return [("move_ids.acceptance_number", operator, value)] + def get_acceptance_label_pages(self): """Return the moves to print a label for, grouped per sheet. diff --git a/stock_acceptance_label/readme/DESCRIPTION.md b/stock_acceptance_label/readme/DESCRIPTION.md index 089c6e6..988a665 100644 --- a/stock_acceptance_label/readme/DESCRIPTION.md +++ b/stock_acceptance_label/readme/DESCRIPTION.md @@ -17,5 +17,13 @@ Each label shows the following rows, in the same layout: One label is printed per transfer line, and several transfers can be selected at once so that all their lines are printed in a single PDF. -It also adds an **Acceptance Number** field on the transfer lines, which is where -the printed acceptance number comes from. +It also adds an **Acceptance Number** field, which is where the printed +acceptance number comes from. The number is held by the detailed operations of +the transfer, so that a line received in several lots can be numbered per lot, +and the transfer line shows the summary of the numbers of its operations. + +Each lot keeps the acceptance numbers it was received under, and a lot received +several times shows them all. They follow the receipts: a number corrected, or a +receipt cancelled after the fact, drops out of the lot. + +Transfers can be searched by acceptance number. diff --git a/stock_acceptance_label/readme/USAGE.md b/stock_acceptance_label/readme/USAGE.md index 8414927..6aac30a 100644 --- a/stock_acceptance_label/readme/USAGE.md +++ b/stock_acceptance_label/readme/USAGE.md @@ -1,6 +1,11 @@ Enter the **Acceptance Number** of each line in the *Operations* tab of the transfer. It may be left empty, in which case the field is printed blank. +For a product with tracking the field is read-only there, and is entered per lot +in the *Detailed Operations* of the transfer instead; the line then shows the +summary of the numbers of its lots. For a product without tracking, a number +entered on the line is applied to its detailed operations. + Then go to *Inventory > Transfers*, select one or more transfers, and use *Print > Acceptance Label*. Three labels are printed per sheet, and the labels of a transfer stay together in the order of the selected transfers. @@ -14,3 +19,6 @@ they stay blank until the lots are assigned in the *Detailed Operations* of the transfer. A line received in several lots prints them all, separated by commas. The expiration dates follow the same order as the lot numbers, including repeated dates. A lot without an expiration date keeps an empty entry in the date list. + +Transfers can be searched by acceptance number from the search bar of the +transfer list, and the numbers received under a lot are shown on the lot itself. diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html index 82b33fe..39e1393 100644 --- a/stock_acceptance_label/static/description/index.html +++ b/stock_acceptance_label/static/description/index.html @@ -2,18 +2,19 @@ - + Stock Acceptance Label - +
-
+
diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html index 39e1393..a00ca5b 100644 --- a/stock_acceptance_label/static/description/index.html +++ b/stock_acceptance_label/static/description/index.html @@ -378,7 +378,8 @@

Stock Acceptance Label

  • the product name;
  • the acceptance number;
  • the model number (product internal reference);
  • -
  • the lot number of the line, blank as long as no lot is assigned;
  • +
  • the lot number the goods are received in, blank as long as no lot is +assigned;
  • the expiration date of that lot, blank when it has none;
  • the arrival date (the effective date of the transfer, or any other date field selected in the settings);
  • @@ -386,8 +387,12 @@

    Stock Acceptance Label

    edited in the settings;
  • the product barcode (Code128).
  • -

    One label is printed per transfer line, and several transfers can be -selected at once so that all their lines are printed in a single PDF.

    +

    One label is printed per detailed operation of the transfer, so a line +received in several lots prints a label per lot, each with its own +acceptance number and lot number. Several transfers can be selected at +once so that all their labels are printed in a single PDF.

    +

    The detailed operations are created when the transfer is confirmed, so a +transfer still in draft has nothing to print.

    It also adds an Acceptance Number field, which is where the printed acceptance number comes from. The number is held by the detailed operations of the transfer, so that a line received in several lots can @@ -453,15 +458,17 @@

    Usage

    use Print > Acceptance Label. Three labels are printed per sheet, and the labels of a transfer stay together in the order of the selected transfers.

    +

    One label is printed per detailed operation, so confirm the transfer +first: the operations, and with them the labels, only exist from then +on.

    Cancelled lines are not printed. The arrival date stays blank as long as the configured date field is empty, which is the case until the transfer is done with the default setting.

    -

    The lot number and the expiration date are taken from the lots of the -line, so they stay blank until the lots are assigned in the Detailed -Operations of the transfer. A line received in several lots prints them -all, separated by commas. The expiration dates follow the same order as -the lot numbers, including repeated dates. A lot without an expiration -date keeps an empty entry in the date list.

    +

    The lot number and the expiration date are taken from the lot of the +operation, so they stay blank until the lot is assigned in the Detailed +Operations of the transfer. A line received in several lots prints one +label per lot, each with that lot’s own number, acceptance number and +expiration date. A lot without an expiration date prints the date blank.

    Transfers can be searched by acceptance number from the search bar of the transfer list, and the numbers received under a lot are shown on the lot itself.

    diff --git a/stock_acceptance_label/tests/test_stock_acceptance_label.py b/stock_acceptance_label/tests/test_stock_acceptance_label.py index a80af93..c54c656 100644 --- a/stock_acceptance_label/tests/test_stock_acceptance_label.py +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -1,6 +1,8 @@ # Copyright 2026 Quartile (https://www.quartile.co) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import date + from odoo import Command, fields from odoo.tests import tagged from odoo.tests.common import TransactionCase @@ -84,6 +86,10 @@ def _validate(self, picking): # Finish directly to avoid optional validation wizards. picking._action_done() + def _label_line(self, picking): + """Return the operation the first label of the transfer comes from.""" + return picking.get_acceptance_label_pages()[0][0] + def _set_arrival_date_field(self, model, name): self.company.acceptance_label_arrival_date_field_id = self.env[ "ir.model.fields" @@ -107,6 +113,12 @@ def test_numbered_lines_are_not_merged(self): def test_label_pages_layout(self): other_picking = self._create_picking(*([self.product_a] * 5)) + # Identical lines would be merged on confirmation, and numbering them + # keeps the five apart. The operations a label is printed from only + # exist once the transfer is confirmed. + for index, move in enumerate(other_picking.move_ids): + move.acceptance_number = f"R016-20251017-{index:02d}" + (self.picking | other_picking).action_confirm() pages = (self.picking | other_picking).get_acceptance_label_pages() self.assertEqual([len(page) for page in pages], [3, 3, 1]) # The labels of a transfer stay together, in the order of the transfers. @@ -114,33 +126,38 @@ def test_label_pages_layout(self): self.assertEqual(pages[0][2].picking_id, other_picking) def test_cancelled_move_is_skipped(self): + self.picking.action_confirm() self.picking.move_ids[0]._action_cancel() pages = self.picking.get_acceptance_label_pages() - self.assertEqual([move.product_id for move in pages[0]], [self.product_b]) + self.assertEqual([line.product_id for line in pages[0]], [self.product_b]) def test_arrival_date(self): - move = self.picking.move_ids[0] - self.assertFalse(move.get_acceptance_arrival_date()) + self.picking.action_confirm() + self.assertFalse(self._label_line(self.picking).get_acceptance_arrival_date()) self._validate(self.picking) expected = fields.Datetime.context_timestamp( self.picking, self.picking.date_done ).date() - self.assertEqual(move.get_acceptance_arrival_date(), expected) + line = self._label_line(self.picking) + self.assertEqual(line.get_acceptance_arrival_date(), expected) self.company.acceptance_label_arrival_date_field_id = False - self.assertEqual(move.get_acceptance_arrival_date(), expected) + self.assertEqual(line.get_acceptance_arrival_date(), expected) def test_arrival_date_from_configured_field(self): + self.picking.action_confirm() self._set_arrival_date_field("stock.picking", "scheduled_date") - move = self.picking.move_ids[0] + line = self._label_line(self.picking) expected = fields.Datetime.context_timestamp( self.picking, self.picking.scheduled_date ).date() - self.assertEqual(move.get_acceptance_arrival_date(), expected) - # A field of the line itself can be selected as well. + self.assertEqual(line.get_acceptance_arrival_date(), expected) + # A field of the transfer line can be selected as well, and is read + # through the operation the label is printed from. self._set_arrival_date_field("stock.move", "date_deadline") + move = line.move_id move.date_deadline = "2026-08-03 00:30:00" expected = fields.Datetime.context_timestamp(move, move.date_deadline).date() - self.assertEqual(move.get_acceptance_arrival_date(), expected) + self.assertEqual(line.get_acceptance_arrival_date(), expected) def test_arrival_date_setting(self): settings = self.env["res.config.settings"].create({}) @@ -170,21 +187,21 @@ def _create_lot(self, name, expiration_date): def test_lot_and_expiration_date(self): picking = self._create_picking(self.product_lot) picking.action_confirm() - move = picking.move_ids[0] - # Nothing is printed as long as the lot of the line is unknown. - self.assertFalse(move.get_acceptance_lot_names()) - self.assertFalse(move.get_acceptance_expiration_dates()) + line = self._label_line(picking) + # Nothing is printed as long as the lot of the operation is unknown. + self.assertFalse(line.lot_id) + self.assertFalse(line.get_acceptance_expiration_date()) # 2027-03-31 00:30 in Asia/Tokyo, to cover the time zone conversion. lot = self._create_lot("LOT-0001", "2027-03-30 15:30:00") - move.move_line_ids.lot_id = lot - self.assertEqual(move.get_acceptance_lot_names(), "LOT-0001") - self.assertEqual(move.get_acceptance_expiration_dates(), "2027/03/31") + line.lot_id = lot + self.assertEqual(line.lot_id.name, "LOT-0001") + self.assertEqual(line.get_acceptance_expiration_date(), date(2027, 3, 31)) lot.expiration_date = False - self.assertEqual(move.get_acceptance_lot_names(), "LOT-0001") - self.assertFalse(move.get_acceptance_expiration_dates()) + self.assertEqual(line.lot_id.name, "LOT-0001") + self.assertFalse(line.get_acceptance_expiration_date()) - def test_several_lots_on_one_line(self): + def test_several_lots_print_one_label_each(self): picking = self._create_picking(self.product_lot) picking.move_ids.product_uom_qty = 2.0 picking.action_confirm() @@ -203,37 +220,35 @@ def test_several_lots_on_one_line(self): "lot_id": lot_b.id, } ) - # A later first date catches sorting; equal and empty dates catch filtering. - for expiration_date, expected in [ - ("2027-03-30 15:30:00", "2027/12/31, 2027/03/31"), - ("2027-12-30 15:30:00", "2027/12/31, 2027/12/31"), - (False, "2027/12/31, "), - ]: - with self.subTest(expiration_date=expiration_date): - lot_b.expiration_date = expiration_date - self.assertEqual(move.get_acceptance_lot_names(), "LOT-A, LOT-B") - self.assertEqual(move.get_acceptance_expiration_dates(), expected) + # One transfer line received in two lots prints a label per lot, each + # carrying its own lot number and expiration date. + page = picking.get_acceptance_label_pages()[0] + self.assertEqual([label.lot_id.name for label in page], ["LOT-A", "LOT-B"]) + self.assertEqual(page[0].get_acceptance_expiration_date(), date(2027, 12, 31)) + self.assertFalse(page[1].get_acceptance_expiration_date()) def test_status_area_setting(self): - move = self.picking.move_ids[0] + self.picking.action_confirm() + line = self._label_line(self.picking) # The built-in status area is printed as long as the setting is empty. - self.assertIn("Under Inspection", move.get_acceptance_status_html()) + self.assertIn("Under Inspection", line.get_acceptance_status_html()) settings = self.env["res.config.settings"].create( {"acceptance_label_status_html": "
    Accepted
    "} ) settings.execute() - self.assertIn("Accepted", move.get_acceptance_status_html()) + self.assertIn("Accepted", line.get_acceptance_status_html()) # Emptying the setting restores the built-in status area. settings.acceptance_label_status_html = "


    " settings.execute() self.assertTrue(is_html_empty(self.company.acceptance_label_status_html)) - self.assertIn("Under Inspection", move.get_acceptance_status_html()) + self.assertIn("Under Inspection", line.get_acceptance_status_html()) def test_status_area_is_sanitized(self): self.company.acceptance_label_status_html = ( "
    Accepted
    " ) - status_html = self.picking.move_ids[0].get_acceptance_status_html() + self.picking.action_confirm() + status_html = self._label_line(self.picking).get_acceptance_status_html() self.assertIn("Accepted", status_html) self.assertNotIn("script", status_html) @@ -253,13 +268,15 @@ def test_settings_are_company_specific(self): other_company.acceptance_label_arrival_date_field_id.name, "scheduled_date" ) self.assertIn("Other company", other_company.acceptance_label_status_html) - # Printing follows the move's company even when another company is active. - move = self.picking.move_ids[0].with_company(other_company) - self.assertFalse(move.get_acceptance_arrival_date()) - self.assertIn("Under Inspection", move.get_acceptance_status_html()) + # Printing follows the transfer's company even when another is active. + self.picking.action_confirm() + line = self._label_line(self.picking).with_company(other_company) + self.assertFalse(line.get_acceptance_arrival_date()) + self.assertIn("Under Inspection", line.get_acceptance_status_html()) def test_report_html(self): self.picking.move_ids[0].acceptance_number = "R016-20251017-01" + self.picking.action_confirm() html = ( self.env["ir.actions.report"] ._render_qweb_html( From ab7e886f026e278709f64d9ac6fc6fda89ce6a7a Mon Sep 17 00:00:00 2001 From: nobuQuartile Date: Tue, 29 Sep 2026 07:53:50 +0000 Subject: [PATCH 14/17] [IMP] stock_acceptance_label: read the lot numbers from receipts only Limit the operations the acceptance numbers of a lot are read from to those of receipts. The operation type is taken from the transfer line, whose type is stored, rather than from the operation, whose type is only known once it is attached to a transfer. --- stock_acceptance_label/i18n/ja.po | 6 +++--- stock_acceptance_label/models/stock_lot.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/stock_acceptance_label/i18n/ja.po b/stock_acceptance_label/i18n/ja.po index 1bd4264..7c3505f 100644 --- a/stock_acceptance_label/i18n/ja.po +++ b/stock_acceptance_label/i18n/ja.po @@ -88,11 +88,11 @@ msgstr "コンフィグ設定" #. module: stock_acceptance_label #: model:ir.model.fields,help:stock_acceptance_label.field_stock_lot__acceptance_move_line_ids msgid "" -"Detailed operations the lot appears in, which the acceptance numbers of the " +"Receipt operations the lot appears in, which the acceptance numbers of the " "lot are read from." msgstr "" -"このロットが含まれる詳細オペレーション。ロットの受入番号はここから取得されま" -"す。" +"このロットが含まれる入荷の詳細オペレーション。ロットの受入番号はここから取得" +"されます。" #. module: stock_acceptance_label #: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label diff --git a/stock_acceptance_label/models/stock_lot.py b/stock_acceptance_label/models/stock_lot.py index 4e7a4db..6285840 100644 --- a/stock_acceptance_label/models/stock_lot.py +++ b/stock_acceptance_label/models/stock_lot.py @@ -11,7 +11,8 @@ class StockLot(models.Model): "stock.move.line", "lot_id", string="Acceptance Operations", - help="Detailed operations the lot appears in, which the acceptance " + domain=[("move_id.picking_type_id.code", "=", "incoming")], + help="Receipt operations the lot appears in, which the acceptance " "numbers of the lot are read from.", ) acceptance_number = fields.Char( From 3458cef1b87d763355c2525921c81bbe0b909e61 Mon Sep 17 00:00:00 2001 From: nobuQuartile Date: Tue, 29 Sep 2026 07:58:30 +0000 Subject: [PATCH 15/17] [IMP] stock_acceptance_label: keep comments to a minimum --- stock_acceptance_label/models/res_company.py | 1 - stock_acceptance_label/models/stock_lot.py | 3 -- stock_acceptance_label/models/stock_move.py | 12 ------- .../models/stock_move_line.py | 14 +-------- .../models/stock_picking.py | 9 ------ .../stock_acceptance_label_templates.xml | 11 ------- .../tests/test_stock_acceptance_label.py | 31 ------------------- .../views/stock_move_line_views.xml | 6 ---- .../views/stock_picking_views.xml | 3 -- 9 files changed, 1 insertion(+), 89 deletions(-) diff --git a/stock_acceptance_label/models/res_company.py b/stock_acceptance_label/models/res_company.py index d3fa38b..9cc0711 100644 --- a/stock_acceptance_label/models/res_company.py +++ b/stock_acceptance_label/models/res_company.py @@ -33,7 +33,6 @@ def _default_acceptance_label_arrival_date_field(self): ) def _get_acceptance_arrival_date_field(self): - """Return the field to read the arrival date from.""" self.ensure_one() return ( self.acceptance_label_arrival_date_field_id diff --git a/stock_acceptance_label/models/stock_lot.py b/stock_acceptance_label/models/stock_lot.py index 6285840..b0c8022 100644 --- a/stock_acceptance_label/models/stock_lot.py +++ b/stock_acceptance_label/models/stock_lot.py @@ -27,9 +27,6 @@ class StockLot(models.Model): "acceptance_move_line_ids.state", ) def _compute_acceptance_number(self): - # Kept computed rather than appended to on receipt, so that a number - # corrected or a receipt cancelled after the fact drops out instead of - # staying on the lot for good. for lot in self: lines = lot.acceptance_move_line_ids.filtered( lambda line: line.state != "cancel" diff --git a/stock_acceptance_label/models/stock_move.py b/stock_acceptance_label/models/stock_move.py index edf9d20..020fac9 100644 --- a/stock_acceptance_label/models/stock_move.py +++ b/stock_acceptance_label/models/stock_move.py @@ -22,28 +22,17 @@ class StockMove(models.Model): @api.depends("move_line_ids.acceptance_number") def _compute_acceptance_number(self): for move in self: - # Only the detailed operations are summarized, so the number - # entered before they exist survives: it is carried over to the - # first one created, which gives back the same summary. move.acceptance_number = ", ".join( move.move_line_ids._get_acceptance_numbers() ) def _inverse_acceptance_number(self): for move in self: - # A number entered on the line applies to its detailed operations, - # which is where it is read back from. A tracked product is - # numbered per lot, so its operations are left alone. if move.has_tracking != "none": continue move.move_line_ids.acceptance_number = move.acceptance_number def _get_acceptance_numbers(self): - """Return the acceptance numbers of the lines, as a summary. - - The numbers of the detailed operations are used, falling back on the - number of the line itself as long as it has no operation to read. - """ numbers = [] for move in self: own = move.move_line_ids._get_acceptance_numbers() or [ @@ -55,5 +44,4 @@ def _get_acceptance_numbers(self): return numbers def _prepare_merge_moves_distinct_fields(self): - # Keep one label, and one acceptance number, per numbered line. return super()._prepare_merge_moves_distinct_fields() + ["acceptance_number"] diff --git a/stock_acceptance_label/models/stock_move_line.py b/stock_acceptance_label/models/stock_move_line.py index 98221b0..0912e20 100644 --- a/stock_acceptance_label/models/stock_move_line.py +++ b/stock_acceptance_label/models/stock_move_line.py @@ -19,11 +19,7 @@ def create(self, vals_list): if vals.get("acceptance_number") or not vals.get("move_id"): continue move = self.env["stock.move"].browse(vals["move_id"]) - # Carry the number entered on the transfer line over to its first - # detailed operation: the operations are where the number is read - # back from, and they are only created once the line is reserved. - # Once one of them carries a number, the number of the transfer - # line is their summary and must not be pushed back down. + # Carry the number entered on the move over to its first operation. if ( move.acceptance_number and not move.move_line_ids._get_acceptance_numbers() @@ -32,11 +28,6 @@ def create(self, vals_list): return super().create(vals_list) def _get_acceptance_numbers(self): - """Return the acceptance numbers of the operations. - - Blanks are dropped and repeats are kept once, in the order of the - operations, so that the result reads as a summary. - """ numbers = [] for line in self: if line.acceptance_number and line.acceptance_number not in numbers: @@ -44,7 +35,6 @@ def _get_acceptance_numbers(self): return numbers def get_acceptance_arrival_date(self): - """Return the configured arrival date, in the user time zone.""" self.ensure_one() field = self.company_id._get_acceptance_arrival_date_field() if field.model == "stock.move": @@ -61,7 +51,6 @@ def get_acceptance_arrival_date(self): return value def get_acceptance_expiration_date(self): - """Return the expiration date of the lot, in the user time zone.""" self.ensure_one() if not self.lot_id.expiration_date: return False @@ -70,6 +59,5 @@ def get_acceptance_expiration_date(self): ).date() def get_acceptance_status_html(self): - """Return the status area configured for the transfer's company.""" self.ensure_one() return self.company_id._get_acceptance_status_html() diff --git a/stock_acceptance_label/models/stock_picking.py b/stock_acceptance_label/models/stock_picking.py index a5aae79..9e22374 100644 --- a/stock_acceptance_label/models/stock_picking.py +++ b/stock_acceptance_label/models/stock_picking.py @@ -20,18 +20,9 @@ def _compute_acceptance_number(self): picking.acceptance_number = ", ".join(moves._get_acceptance_numbers()) def _search_acceptance_number(self, operator, value): - # Searched on the lines, whose own number is stored and indexed, rather - # than on the summary, which is not. return [("move_ids.acceptance_number", operator, value)] def get_acceptance_label_pages(self): - """Return the detailed operations to print a label for, per sheet. - - One label is printed per detailed operation, as that is what carries - the acceptance number and the lot the goods are received in. The - operations are kept in the order of the transfers they belong to, so - that the labels of a transfer stay together. - """ lines = [ line for picking in self diff --git a/stock_acceptance_label/report/stock_acceptance_label_templates.xml b/stock_acceptance_label/report/stock_acceptance_label_templates.xml index d7dc577..e1b881d 100644 --- a/stock_acceptance_label/report/stock_acceptance_label_templates.xml +++ b/stock_acceptance_label/report/stock_acceptance_label_templates.xml @@ -1,7 +1,5 @@ -