diff --git a/stock_acceptance_label/README.rst b/stock_acceptance_label/README.rst new file mode 100644 index 0000000..39db5d0 --- /dev/null +++ b/stock_acceptance_label/README.rst @@ -0,0 +1,177 @@ +====================== +Stock Acceptance Label +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:c56585b3580ff74bdfb779ee05d5035591cd3bd73d3645e92f6b4cb16f9f6d1e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 following rows, in the same layout: + +- the product name; +- the acceptance number; +- the model number (product internal reference); +- 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); +- 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 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 +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** + +.. contents:: + :local: + +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 +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. + +**Status Area** holds the content of the status row, and can be edited +freely. It is empty by default, in which case the label prints the +built-in area: + +:: + + □ Under Inspection + ↓ + □ Conforming or □ Rejected + +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 built-in area, which follows the language of the printing +user, while an area entered here is printed as such in every language. + +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. + +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. + +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 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. + +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..8503ebe --- /dev/null +++ b/stock_acceptance_label/__manifest__.py @@ -0,0 +1,22 @@ +# 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": ["product_expiry", "stock"], + "data": [ + "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_move_line_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 new file mode 100644 index 0000000..7c3505f --- /dev/null +++ b/stock_acceptance_label/i18n/ja.po @@ -0,0 +1,205 @@ +# 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-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" +"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 +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form +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 +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 "入荷日フィールド" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label +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" +msgstr "コンフィグ設定" + +#. module: stock_acceptance_label +#: model:ir.model.fields,help:stock_acceptance_label.field_stock_lot__acceptance_move_line_ids +msgid "" +"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 +msgid "Expiration Date" +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_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 " +"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 "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" +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.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" +msgstr "ステータス欄" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.res_config_settings_view_form +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 " +"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." +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" +msgstr "転送" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label_status +msgid "□ Conforming or □ Rejected" +msgstr "□適合 or □不合格" + +#. module: stock_acceptance_label +#: model_terms:ir.ui.view,arch_db:stock_acceptance_label.acceptance_label_status +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..cb3e694 --- /dev/null +++ b/stock_acceptance_label/models/__init__.py @@ -0,0 +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/res_company.py b/stock_acceptance_label/models/res_company.py new file mode 100644 index 0000000..7772777 --- /dev/null +++ b/stock_acceptance_label/models/res_company.py @@ -0,0 +1,49 @@ +# 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" + + 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", + domain=[ + ("model", "in", ["stock.move", "stock.picking"]), + ("ttype", "in", ["date", "datetime"]), + ], + 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 " + "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() + # sudo() so that users without access to ir.model.fields can print. + return ( + self.acceptance_label_arrival_date_field_id + or self._default_acceptance_label_arrival_date_field() + ).sudo() + + 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 new file mode 100644 index 0000000..3efca9a --- /dev/null +++ b/stock_acceptance_label/models/res_config_settings.py @@ -0,0 +1,17 @@ +# 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 ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + acceptance_label_arrival_date_field_id = fields.Many2one( + 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, + ) diff --git a/stock_acceptance_label/models/stock_lot.py b/stock_acceptance_label/models/stock_lot.py new file mode 100644 index 0000000..b0c8022 --- /dev/null +++ b/stock_acceptance_label/models/stock_lot.py @@ -0,0 +1,34 @@ +# 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", + 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( + 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): + 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 new file mode 100644 index 0000000..020fac9 --- /dev/null +++ b/stock_acceptance_label/models/stock_move.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 api, fields, models + + +class StockMove(models.Model): + _inherit = "stock.move" + + 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: + move.acceptance_number = ", ".join( + move.move_line_ids._get_acceptance_numbers() + ) + + def _inverse_acceptance_number(self): + for move in self: + if move.has_tracking != "none": + continue + move.move_line_ids.acceptance_number = move.acceptance_number + + def _get_acceptance_numbers(self): + 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): + 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 new file mode 100644 index 0000000..0912e20 --- /dev/null +++ b/stock_acceptance_label/models/stock_move_line.py @@ -0,0 +1,63 @@ +# 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 move over to its first operation. + 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): + numbers = [] + for line in self: + if line.acceptance_number and line.acceptance_number not in numbers: + numbers.append(line.acceptance_number) + return numbers + + def get_acceptance_arrival_date(self): + self.ensure_one() + field = self.company_id._get_acceptance_arrival_date_field() + if field.model == "stock.move": + record = self.move_id + 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 + if field.ttype == "datetime": + return fields.Datetime.context_timestamp(self, value).date() + return value + + def get_acceptance_expiration_date(self): + self.ensure_one() + if not self.lot_id.expiration_date: + return False + return fields.Datetime.context_timestamp( + self, self.lot_id.expiration_date + ).date() + + def get_acceptance_status_html(self): + 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 new file mode 100644 index 0000000..9e22374 --- /dev/null +++ b/stock_acceptance_label/models/stock_picking.py @@ -0,0 +1,33 @@ +# 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 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): + return [("move_ids.acceptance_number", operator, value)] + + def get_acceptance_label_pages(self): + lines = [ + line + for picking in self + for move in picking.move_ids + for line in move.move_line_ids + if line.state != "cancel" + ] + return [lines[index : index + 3] for index in range(0, len(lines), 3)] 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/CONFIGURE.md b/stock_acceptance_label/readme/CONFIGURE.md new file mode 100644 index 0000000..184f633 --- /dev/null +++ b/stock_acceptance_label/readme/CONFIGURE.md @@ -0,0 +1,29 @@ +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 +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. + +**Status Area** holds the content of the status row, and can be edited freely. +It is empty by default, in which case the label prints the built-in area: + +``` +□ Under Inspection +↓ +□ Conforming or □ Rejected +``` + +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 +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/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..704e41e --- /dev/null +++ b/stock_acceptance_label/readme/DESCRIPTION.md @@ -0,0 +1,35 @@ +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 following rows, in the same layout: + +- the product name; +- the acceptance number; +- the model number (product internal reference); +- 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); +- 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 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 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 new file mode 100644 index 0000000..d30bb88 --- /dev/null +++ b/stock_acceptance_label/readme/USAGE.md @@ -0,0 +1,27 @@ +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. + +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 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/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..e1b881d --- /dev/null +++ b/stock_acceptance_label/report/stock_acceptance_label_templates.xml @@ -0,0 +1,120 @@ + + + + + + + + diff --git a/stock_acceptance_label/static/description/index.html b/stock_acceptance_label/static/description/index.html new file mode 100644 index 0000000..a00ca5b --- /dev/null +++ b/stock_acceptance_label/static/description/index.html @@ -0,0 +1,511 @@ + + + + + +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 following rows, in the same layout:

+
    +
  • the product name;
  • +
  • the acceptance number;
  • +
  • the model number (product internal reference);
  • +
  • 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);
  • +
  • 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 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 +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

+ +
+

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

+

Status Area holds the content of the status row, and can be edited +freely. It is empty by default, in which case the label prints the +built-in area:

+
+□ Under Inspection
+↓
+□ Conforming or □ Rejected
+
+

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 built-in area, which follows the language of the printing +user, while an area entered here is printed as such in every language.

+
+
+

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.

+

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.

+

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 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.

+
+
+

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/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..ec0990b --- /dev/null +++ b/stock_acceptance_label/tests/test_stock_acceptance_label.py @@ -0,0 +1,391 @@ +# 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 +from odoo.tools import is_html_empty + + +@tagged("post_install", "-at_install") +class TestStockAcceptanceLabel(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = cls.env.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( + { + "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.product_lot = cls.env["product.product"].create( + { + "name": "Product Lot", + "is_storable": True, + "tracking": "lot", + "use_expiration_date": True, + } + ) + cls.env.user.tz = "Asia/Tokyo" + 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: + for check in picking.check_ids.filtered( + lambda check: check.quality_state == "none" + ): + check.quality_state = "pass" + picking._action_done() + + def _label_line(self, picking): + 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" + ]._get(model, name) + + 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) + unnumbered = picking.copy() + unnumbered.action_confirm() + self.assertEqual(len(unnumbered.move_ids), 1) + 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)) + 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]) + 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.action_confirm() + self.picking.move_ids[0]._action_cancel() + pages = self.picking.get_acceptance_label_pages() + self.assertEqual([line.product_id for line in pages[0]], [self.product_b]) + + def test_arrival_date(self): + 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() + 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(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") + line = self._label_line(self.picking) + expected = fields.Datetime.context_timestamp( + self.picking, self.picking.scheduled_date + ).date() + self.assertEqual(line.get_acceptance_arrival_date(), expected) + 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(line.get_acceptance_arrival_date(), expected) + + def test_arrival_date_setting(self): + settings = self.env["res.config.settings"].create({}) + self.assertEqual( + settings.acceptance_label_arrival_date_field_id, + self.env["ir.model.fields"]._get("stock.picking", "date_done"), + ) + settings.acceptance_label_arrival_date_field_id = self.env[ + "ir.model.fields" + ]._get("stock.picking", "scheduled_date") + settings.execute() + self.assertEqual( + self.company.acceptance_label_arrival_date_field_id.name, + "scheduled_date", + ) + + def _create_lot(self, name, expiration_date): + return self.env["stock.lot"].create( + { + "name": name, + "product_id": self.product_lot.id, + "expiration_date": expiration_date, + } + ) + + def test_lot_and_expiration_date(self): + picking = self._create_picking(self.product_lot) + picking.action_confirm() + line = self._label_line(picking) + self.assertFalse(line.lot_id) + self.assertFalse(line.get_acceptance_expiration_date()) + lot = self._create_lot("LOT-0001", "2027-03-30 15:30:00") + 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(line.lot_id.name, "LOT-0001") + self.assertFalse(line.get_acceptance_expiration_date()) + + 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() + move = picking.move_ids[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": lot_b.id, + } + ) + 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): + self.picking.action_confirm() + line = self._label_line(self.picking) + 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", line.get_acceptance_status_html()) + settings.acceptance_label_status_html = "


" + settings.execute() + self.assertTrue(is_html_empty(self.company.acceptance_label_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
" + ) + 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) + + def test_settings_are_company_specific(self): + other_company = self.env["res.company"].create({"name": "Other Company"}) + settings = self.env["res.config.settings"].create( + { + "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
", + } + ) + settings.execute() + self.assertEqual( + other_company.acceptance_label_arrival_date_field_id.name, "scheduled_date" + ) + self.assertIn("Other company", other_company.acceptance_label_status_html) + 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( + "stock_acceptance_label.report_stock_acceptance_label", + self.picking.ids, + )[0] + .decode() + ) + self.assertIn("R016-20251017-01", html) + self.assertIn("SH30221.26", html) + self.assertIn("Under Inspection", html) + self.assertEqual(html.count('class="o_pal_cell"'), 2) + self.assertEqual(html.count('class="o_pal_value o_pal_barcode"'), 2) + self.assertEqual(html.count(" + + + res.config.settings.view.form.inherit.stock + res.config.settings + + + + + + + + +
+ +
+
+
+
+
+
+
diff --git a/stock_acceptance_label/views/stock_lot_views.xml b/stock_acceptance_label/views/stock_lot_views.xml new file mode 100644 index 0000000..f2f1f14 --- /dev/null +++ b/stock_acceptance_label/views/stock_lot_views.xml @@ -0,0 +1,24 @@ + + + + stock.lot.form + stock.lot + + + + + + + + + + stock.lot.list + stock.lot + + + + + + + + diff --git a/stock_acceptance_label/views/stock_move_line_views.xml b/stock_acceptance_label/views/stock_move_line_views.xml new file mode 100644 index 0000000..b3a580d --- /dev/null +++ b/stock_acceptance_label/views/stock_move_line_views.xml @@ -0,0 +1,35 @@ + + + + stock.move.line.operations.list + stock.move.line + + + + + + + + + + stock.move.line.operations.list + stock.move.line + + + + + + + + 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..3fcc8f3 --- /dev/null +++ b/stock_acceptance_label/views/stock_picking_views.xml @@ -0,0 +1,42 @@ + + + + stock.picking.form + stock.picking + + + + + + + + + + stock.picking.list + stock.picking + + + + + + + + + + stock.picking.internal.search + stock.picking + + + + + + + +