diff --git a/product_category_hs_mapping/README.rst b/product_category_hs_mapping/README.rst new file mode 100644 index 00000000000..8ff84b00402 --- /dev/null +++ b/product_category_hs_mapping/README.rst @@ -0,0 +1,302 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================== +Product Category — HS Code Mapping +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:37e22232ce3d83cca7d6f1d0fb8e4d8b26bd5f05dd3ced94b3a0d2fc2f6fe1ee + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github + :target: https://github.com/OCA/product-attribute/tree/19.0/product_category_hs_mapping + :alt: OCA/product-attribute +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/product-attribute-19-0/product-attribute-19-0-product_category_hs_mapping + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/product-attribute&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Maps customs HS (Harmonized System) tariff codes to Odoo +``product.category`` records, so EDI / supplier integrations can +auto-categorise newly-created products without hard-coding mapping logic +per integration. + +The mapping table supports two pattern shapes: + +- **Exact code** — e.g. ``8421230090`` matches only that exact code. +- **Prefix wildcard** — e.g. ``8421*`` matches any code starting with + ``8421``. + +At resolution time, the longest literal prefix wins. So a rule for +``8421230090`` (specificity 10) beats a rule for ``8421*`` (specificity +4) which itself beats ``84*`` (specificity 2). + +Multi-company aware: rules can be scoped to a specific company or left +global (apply to all companies). + +Ships with a sensible default mapping table covering common spare-parts +HS chapters relevant to material-handling and industrial-equipment +maintenance. Customers will typically redirect the targets to their own +product-category tree. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Why this module exists +====================== + +The problem +----------- + +When products land in Odoo from a supplier integration (cXML / OCI +punchout cart, REST-API enrichment, EDI feed, IDoc import, …) they +typically arrive with: + +- a **customs HS code** (``commodityCode`` in the supplier's payload, + populated on ``product.template.hs_code`` via OCA + ``account_intrastat`` or equivalent), +- but **no product category** — or a placeholder category the + integration uses as a landing zone (``All / Imported`` or the + supplier-specific bucket the punchout backend defaults to). + +The category drives downstream behaviour: which warehouse location the +product lands in, which procurement rules fire, which analytic account a +reorder hits, which sales tax applies, which sequence the auto-generated +internal reference uses. Leaving thousands of imported SKUs in a single +placeholder category breaks every one of those flows. + +What this module does +--------------------- + +A small mapping table: HS-code patterns → ``product.category``. Designed +to plug into any flow that sets ``hs_code`` on a product: + +.. code:: python + + mapped = self.env["product.category.hs.mapping"]._get_category_for_hs_code( + "84314100", # Forks for fork-lift trucks + company=self.company_id, + ) + if mapped: + template.categ_id = mapped + +Patterns can be: + +- **Full HS heading or subheading**: ``8431`` (parts of fork-lifts), + ``843141`` (specific fork-lift parts), ``84314100`` (forks). Longer + literals win — a rule with ``84314100`` beats a rule with ``8431`` for + input ``8431410080``. +- **Wildcard**: ``8431*`` is identical in behaviour to ``8431`` (the + trailing ``*`` is purely visual). The bare ``*`` pattern is a + deliberate catch-all (specificity 0), matched last when nothing else + fits. + +Per-company scope: rules with ``company_id`` set apply only to that +company; rules with no ``company_id`` apply to all companies. +Multi-company customers can ship different mapping tables per legal +entity. + +Why it's distinct from existing OCA work +---------------------------------------- + +OCA already provides: + +- ``account_intrastat`` — the source of HS codes themselves + (``account.intrastat.code`` records mapped to + ``product.template.hs_code``). +- ``account_intrastat_oss`` and friends — declaration-level consumers of + those codes. + +Neither maps HS codes to **product categories**. The closest adjacent +module, ``product_category_active``, manages category lifecycle but +doesn't classify products by HS. This module fills the gap between "we +have a customs code on the product" and "we want the product in the +right Odoo category". + +Use cases that drive this design +-------------------------------- + +- **Punchout cart imports**: every line in the cart enters with + ``hs_code`` from the supplier's cXML / OCI payload. The + punchout-purchase glue calls ``_get_category_for_hs_code(hs_code)`` on + the freshly created ``product.template`` and writes the result to + ``categ_id``. +- **REST-API product enrichment** (OEM parts catalogues): when Odoo + enriches a stub product with ``GET /items``-style data from the + supplier, the response carries ``commodityCode``; same lookup + populates the category at the same time. +- **EDI imports** (Pricat, custom CSV, …): an import wizard sets + ``hs_code`` first and the category after, using the matcher. +- **Manual buyer override**: the bundled + ``Apply HS Code → Category Mapping`` server action lets a buyer re-run + the resolver against any selected product (Action menu, form + list + view) — useful for retroactively classifying a pre-existing catalogue, + or for debugging a rule that doesn't match what the buyer expected. + +Bootstrap data is *not* shipped here +------------------------------------ + +The module deliberately ships **no rules**. Each customer's mapping +table reflects their own catalogue scope (different HS chapters, +different category trees, different language preferences). A +customer-side bootstrap module sits on top: + +.. code:: python + + "depends": [ + "product", + "product_category_hs_mapping", + ], + "data": [ + "data/product.category.csv", + "data/product_category_hs_mapping.xml", + ], + +That bootstrap module carries the customer's ``noupdate="1"`` seed +records, marked so buyer edits survive future upgrades. Customer +adoption pattern: install this matcher module from OCA, then ship a tiny +private module with your own category tree + HS rules. + +Known issues / Roadmap +====================== + +- **Auto-populate stub rules for unknown HS codes + assignee activity.** + When the matcher receives an HS code it can't resolve, optionally + create a draft rule (empty ``category_id``, ``sequence=999``) and + spawn a ``mail.activity`` on a configurable responsible group / user — + surfacing the gap in someone's "to-categorise" inbox instead of + leaving it in the matcher's INFO log only. + + Off by default. Toggle via ``res.config.settings`` Boolean (system + parameter ``product_category_hs_mapping.auto_create_unknown``) so the + feature is opt-in per-database. Auto-creation is idempotent on + (``hs_code_pattern``, ``company_id``, ``category_id IS NULL``): one + stub per unknown code, no duplicates on subsequent imports. + + **Multi-company nuance**: in a multi-company environment different + people / groups may be responsible for the category mapping per + company (a product manager for the industrial entity, a procurement + lead for the agri entity, etc). The activity-assignee target is + therefore PER company, not a single global group. Concrete shape: a + Many2one ``hs_mapping_responsible_user_id`` (or group) on + ``res.company`` with a fallback chain — company-level setting → global + system parameter + ``product_category_hs_mapping.responsible_group_xmlid`` → log+skip if + neither is set. + + When the responsible user fills ``category_id`` on the stub, the + activity auto-marks Done (``mail.activity._action_done`` hook). The + chatter on the rule shows *which* product triggered the stub creation + so the assignee can sanity-check the proposed category against a real + example. + + Not implemented yet — pending real demand. The manual + ``Apply HS Code → Category Mapping`` server action covers the + buyer-driven re-categorise flow today. + +- **Per-line / variant override**. ``product.product`` doesn't inherit + ``hs_code`` separately, so the manual server action is currently bound + to ``product.template``. If a customer ever ships variants with + different tariff codes (rare in OEM-parts catalogues but conceivable + for raw-material multipacks), surface the action on + ``product.product`` too with a per-variant ``hs_code`` field. + +- **Bulk re-categorisation cron / queued-job**. When the upstream HS + mapping table changes (e.g. a new EU tariff line), a buyer may want to + re-run the matcher across the entire product catalogue. Today they can + multi-select in the product list and trigger the existing action — but + for thousands of products that's a single heavy transaction. A + cron-driven or ``queue_job``-backed variant with progress feedback + would be more operator-friendly. + +Changelog +========= + +19.0.1.0.0 (2026-05) +-------------------- + +- Initial OCA-bound release. Provides: + + - ``product.category.hs.mapping`` model with HS-code-pattern → + ``product.category`` rules (longest-literal-prefix wins, optional + ``*`` wildcard, per-company scope, security ACL). + - ``intrastat_description`` computed Char on each rule — 3-tier lookup + (exact → shortest extending → parent prefix) against the installed + ``account.intrastat.code`` records, so buyers can sanity-check what + a pattern actually covers without reaching for a tariff manual. + - ``Apply HS Code → Category Mapping`` server action on + ``product.template`` (Action menu, form + list). Re-runs the matcher + against the product's ``hs_code`` and writes the result to + ``categ_id``. Single-product invocations raise a specific + ``UserError`` for each "nothing happened" reason (no HS code / no + rule / already in matched category). Multi-product invocations show + a summary toast with the breakdown and a ``soft_reload`` so the list + refreshes. + +- Pre-OCA development was driven by a real-world customer deployment + with a multi-thousand-SKU parts catalogue; the matcher graduated to + OCA-shape after several rounds of buyer testing. + +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 +------- + +* Bosd + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-bosd| image:: https://github.com/bosd.png?size=40px + :target: https://github.com/bosd + :alt: bosd + +Current `maintainer `__: + +|maintainer-bosd| + +This module is part of the `OCA/product-attribute `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/product_category_hs_mapping/__init__.py b/product_category_hs_mapping/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/product_category_hs_mapping/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/product_category_hs_mapping/__manifest__.py b/product_category_hs_mapping/__manifest__.py new file mode 100644 index 00000000000..a122f8906a0 --- /dev/null +++ b/product_category_hs_mapping/__manifest__.py @@ -0,0 +1,33 @@ +# Copyright 2026 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Product Category — HS Code Mapping", + "version": "19.0.1.0.0", + "license": "AGPL-3", + "summary": ( + "Map customs HS codes to Odoo product categories. Used by EDI / " + "supplier integrations to auto-categorise newly imported products." + ), + "author": "Bosd, Odoo Community Association (OCA)", + "maintainers": ["bosd"], + "website": "https://github.com/OCA/product-attribute", + "development_status": "Beta", + "depends": [ + # ``stock_delivery`` is the OCB module that defines + # ``product.template.hs_code`` in v19. The matcher is + # conceptually about HS codes, so depending on the + # canonical source is honest. Pulls in ``sale_stock`` + + # ``delivery`` transitively — the cost on most installs + # is zero (Inventory + Shipping are usually present + # already in any Odoo deployment that handles physical + # goods). + "stock_delivery", + ], + "data": [ + "security/ir.model.access.csv", + "views/product_category_hs_mapping.xml", + "views/menus.xml", + "data/server_action_recategorise.xml", + ], +} diff --git a/product_category_hs_mapping/data/server_action_recategorise.xml b/product_category_hs_mapping/data/server_action_recategorise.xml new file mode 100644 index 00000000000..ce503047b7b --- /dev/null +++ b/product_category_hs_mapping/data/server_action_recategorise.xml @@ -0,0 +1,30 @@ + + + + + + Apply HS Code → Category Mapping + + + form,list + code + action = records.action_apply_hs_mapping() + + diff --git a/product_category_hs_mapping/i18n/es.po b/product_category_hs_mapping/i18n/es.po new file mode 100644 index 00000000000..377967067c0 --- /dev/null +++ b/product_category_hs_mapping/i18n/es.po @@ -0,0 +1,340 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_category_hs_mapping +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-09 20:31+0000\n" +"PO-Revision-Date: 2026-05-09 20:31+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"%(updated)d re-categorised, %(unchanged)d already correct, %(no_code)d " +"without HS code, %(no_rule)d with no matching rule." +msgstr "" +"%(updated)d recategorizados, %(unchanged)d ya correctos, %(no_code)d sin " +"código SA, %(no_rule)d sin regla coincidente." + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "(intrastat code preview)" +msgstr "(vista previa de código intrastat)" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +") or\n" +" prefix wildcards (" +msgstr "" +") o\n" +" comodines de prefijo (" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +"). Longest literal\n" +" prefix wins at resolution time, so a more specific\n" +" rule beats a more general one." +msgstr "" +"). El prefijo literal más largo\n" +" gana al resolverse, por lo que una regla más\n" +" específica vence a una más general." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__active +msgid "Active" +msgstr "Activo" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"An HS-code mapping rule for pattern '%(pat)s' and the same company scope " +"already exists. Patterns must be unique per company (or globally, when no " +"company is set)." +msgstr "" +"Ya existe una regla de mapeo SA para el patrón '%(pat)s' en el mismo ámbito " +"de empresa. Los patrones deben ser únicos por empresa (o globales, cuando no" +" se establece empresa)." + +#. module: product_category_hs_mapping +#: model:ir.actions.server,name:product_category_hs_mapping.action_apply_hs_mapping +msgid "Apply HS Code → Category Mapping" +msgstr "Aplicar mapeo Código SA → categoría" + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Archived" +msgstr "Archivado" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__category_id +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Category" +msgstr "Categoría" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "Company" +msgstr "Empresa" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__display_name +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__display_name +msgid "Display Name" +msgstr "Nombre mostrado" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_edi_mappings_root +msgid "EDI Mappings" +msgstr "Mapeos EDI" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "HS Code Mapping" +msgstr "Mapeo de código SA" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_product_category_hs_mapping +msgid "HS Code → Category" +msgstr "Código SA → categoría" + +#. module: product_category_hs_mapping +#: model:ir.actions.act_window,name:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "HS Code → Category Mappings" +msgstr "Mapeos código SA → categoría" + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_category_hs_mapping +msgid "HS Code → Product Category Mapping" +msgstr "Mapeo código SA → categoría de producto" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' has a '*' that isn't the last character. Only " +"trailing wildcard is supported (e.g. '8421*'). Use a longer literal prefix " +"instead." +msgstr "" +"El patrón de código SA '%(pat)s' contiene un '*' que no es el último " +"carácter. Solo se admite un comodín al final (p. ej. '8421*'). Use un " +"prefijo literal más largo en su lugar." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' must contain only digits (and an optional trailing" +" '*'); got non-digit characters." +msgstr "" +"El patrón de código SA '%(pat)s' debe contener solo dígitos (y opcionalmente" +" un '*' al final); se encontraron caracteres no numéricos." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "HS code pattern is required." +msgstr "El patrón de código SA es obligatorio." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern must contain at least one digit before any '*' wildcard." +msgstr "" +"El patrón de código SA debe contener al menos un dígito antes de cualquier " +"comodín '*'." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "" +"HS tariff code pattern. Treated as a prefix at lookup time — a rule with " +"literal '84312000' matches both '84312000' and '8431200080' (longer national" +" tariff lines roll up to the same chapter / subheading). The trailing '*' is" +" purely visual; '84312000' and '84312000*' behave identically. Longest " +"literal wins." +msgstr "" +"Patrón de código arancelario SA. Se trata como un prefijo en la búsqueda — " +"una regla con literal '84312000' coincide tanto con '84312000' como con " +"'8431200080' (líneas arancelarias nacionales más largas suben al mismo " +"capítulo / subpartida). El '*' final es puramente visual; '84312000' y " +"'84312000*' se comportan de forma idéntica. Gana el literal más largo." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "Hs Code Pattern" +msgstr "Patrón de código SA" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "" +"Human-readable label combining the HS pattern and the category — used in " +"Many2one displays and audit trails." +msgstr "" +"Etiqueta legible que combina el patrón SA y la categoría — usada en " +"presentaciones Many2one y rastros de auditoría." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__id +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__id +msgid "ID" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "Intrastat Description" +msgstr "Descripción intrastat" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "" +"Length of the literal portion of the pattern (the part before any '*'). Used" +" to order matches longest-first so an exact code beats a prefix wildcard." +msgstr "" +"Longitud de la porción literal del patrón (la parte antes de cualquier '*')." +" Se usa para ordenar las coincidencias por longitud descendente, de modo que" +" un código exacto vence a un comodín de prefijo." + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Map HS tariff codes to product categories" +msgstr "Mapear códigos arancelarios SA a categorías de producto" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "Name" +msgstr "Nombre" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"No HS-code mapping rule matches %(hs)s for %(name)s. Add a rule in Settings " +"→ Technical → Product Category HS Mapping, or extend the parent prefix." +msgstr "" +"Ninguna regla de mapeo SA coincide con %(hs)s para %(name)s. Añada una regla" +" en Configuración → Técnico → Mapeo SA de categoría de producto, o extienda " +"el prefijo padre." + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Patterns can be exact codes (" +msgstr "Los patrones pueden ser códigos exactos (" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "" +"Plain-language description of what the rule's HS-code pattern actually " +"covers, looked up against the installed ``account.intrastat.code`` records. " +"Lets a buyer eyeball whether ``8539299*`` mapped to 'Hydraulics' is wrong " +"(it is — it's Filament lamps) without consulting a tariff manual. Computed, " +"not stored — refreshes when the pattern changes or new intrastat codes get " +"loaded." +msgstr "" +"Descripción en lenguaje claro de lo que el patrón de código SA de la regla " +"cubre realmente, buscada contra los registros ``account.intrastat.code`` " +"instalados. Permite al comprador ver de un vistazo si por ejemplo " +"``8539299*`` asignado a 'Hidráulica' es incorrecto (lo es — son Lámparas de " +"filamento) sin consultar un manual arancelario. Calculado, no almacenado — " +"se actualiza cuando cambia el patrón o se cargan nuevos códigos intrastat." + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_template +msgid "Product" +msgstr "Producto" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"Product %(name)s has no HS code set. Fill ``hs_code`` first (Product form → " +"Purchase tab) or via TVH enrichment." +msgstr "" +"El producto %(name)s no tiene código SA establecido. Rellene primero " +"``hs_code`` (formulario de producto → pestaña Compra) o vía enriquecimiento " +"desde el proveedor." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Product %(name)s is already in the matched category (%(cat)s)." +msgstr "El producto %(name)s ya está en la categoría coincidente (%(cat)s)." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Re-categorised via HS-code mapping: %(hs)s — %(old)s → %(new)s" +msgstr "Recategorizado vía mapeo de código SA: %(hs)s — %(old)s → %(new)s" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "" +"Restrict this rule to a specific company. Empty = applies to all companies. " +"Multi-company customers can ship different mapping tables per legal entity." +msgstr "" +"Restringir esta regla a una empresa específica. Vacío = se aplica a todas " +"las empresas. Los clientes multi-empresa pueden mantener tablas de mapeo " +"distintas por entidad jurídica." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "Sequence" +msgstr "Secuencia" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "Specificity" +msgstr "Especificidad" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__category_id +msgid "Target product category for products matching this pattern." +msgstr "" +"Categoría de producto objetivo para los productos que coinciden con este " +"patrón." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "" +"Tiebreaker among same-specificity rules — lower wins. Specificity (longer " +"pattern → higher) takes precedence." +msgstr "" +"Desempate entre reglas con la misma especificidad — menor gana. La " +"especificidad (patrón más largo → mayor) tiene prioridad." + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "e.g. 8421* or 8421230090" +msgstr "p. ej. 8421* o 8421230090" diff --git a/product_category_hs_mapping/i18n/nl.po b/product_category_hs_mapping/i18n/nl.po new file mode 100644 index 00000000000..66c53e07771 --- /dev/null +++ b/product_category_hs_mapping/i18n/nl.po @@ -0,0 +1,339 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_category_hs_mapping +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-09 20:31+0000\n" +"PO-Revision-Date: 2026-05-09 20:31+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"%(updated)d re-categorised, %(unchanged)d already correct, %(no_code)d " +"without HS code, %(no_rule)d with no matching rule." +msgstr "" +"%(updated)d opnieuw ingedeeld, %(unchanged)d reeds correct, %(no_code)d " +"zonder GS-code, %(no_rule)d zonder overeenkomstige regel." + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "(intrastat code preview)" +msgstr "(voorbeeld intrastatcode)" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +") or\n" +" prefix wildcards (" +msgstr "" +") of\n" +" voorvoegsel-jokers (" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +"). Longest literal\n" +" prefix wins at resolution time, so a more specific\n" +" rule beats a more general one." +msgstr "" +"). Het langste letterlijke\n" +" voorvoegsel wint bij het opzoeken,\n" +" dus een meer specifieke regel wint van een meer algemene." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__active +msgid "Active" +msgstr "Actief" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"An HS-code mapping rule for pattern '%(pat)s' and the same company scope " +"already exists. Patterns must be unique per company (or globally, when no " +"company is set)." +msgstr "" +"Er bestaat al een GS-code-koppelingsregel voor patroon '%(pat)s' binnen " +"dezelfde bedrijfsscope. Patronen moeten uniek zijn per bedrijf (of globaal " +"als er geen bedrijf is ingesteld)." + +#. module: product_category_hs_mapping +#: model:ir.actions.server,name:product_category_hs_mapping.action_apply_hs_mapping +msgid "Apply HS Code → Category Mapping" +msgstr "GS-code → categoriekoppeling toepassen" + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Archived" +msgstr "Gearchiveerd" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__category_id +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Category" +msgstr "Categorie" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "Company" +msgstr "Bedrijf" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_uid +msgid "Created by" +msgstr "Aangemaakt door" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_date +msgid "Created on" +msgstr "Aangemaakt op" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__display_name +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__display_name +msgid "Display Name" +msgstr "Weergavenaam" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_edi_mappings_root +msgid "EDI Mappings" +msgstr "EDI-koppelingen" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "HS Code Mapping" +msgstr "GS-code-koppeling" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_product_category_hs_mapping +msgid "HS Code → Category" +msgstr "GS-code → categorie" + +#. module: product_category_hs_mapping +#: model:ir.actions.act_window,name:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "HS Code → Category Mappings" +msgstr "GS-code → categoriekoppelingen" + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_category_hs_mapping +msgid "HS Code → Product Category Mapping" +msgstr "GS-code → productcategorie-koppeling" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' has a '*' that isn't the last character. Only " +"trailing wildcard is supported (e.g. '8421*'). Use a longer literal prefix " +"instead." +msgstr "" +"GS-codepatroon '%(pat)s' bevat een '*' die niet het laatste teken is. Alleen" +" een afsluitende joker wordt ondersteund (bijv. '8421*'). Gebruik in plaats " +"daarvan een langer letterlijk voorvoegsel." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' must contain only digits (and an optional trailing" +" '*'); got non-digit characters." +msgstr "" +"GS-codepatroon '%(pat)s' mag alleen cijfers bevatten (met optioneel een " +"afsluitende '*'); aangetroffen niet-cijfertekens." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "HS code pattern is required." +msgstr "GS-codepatroon is verplicht." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern must contain at least one digit before any '*' wildcard." +msgstr "" +"GS-codepatroon moet minstens één cijfer bevatten vóór een eventuele " +"'*'-joker." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "" +"HS tariff code pattern. Treated as a prefix at lookup time — a rule with " +"literal '84312000' matches both '84312000' and '8431200080' (longer national" +" tariff lines roll up to the same chapter / subheading). The trailing '*' is" +" purely visual; '84312000' and '84312000*' behave identically. Longest " +"literal wins." +msgstr "" +"GS-tariefcodepatroon. Wordt bij het opzoeken als voorvoegsel behandeld — een" +" regel met letterlijk '84312000' komt overeen met zowel '84312000' als " +"'8431200080' (langere nationale tarieflijnen rollen op naar hetzelfde " +"hoofdstuk/subhoofd). De afsluitende '*' is puur visueel; '84312000' en " +"'84312000*' gedragen zich identiek. Langste letterlijk wint." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "Hs Code Pattern" +msgstr "GS-codepatroon" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "" +"Human-readable label combining the HS pattern and the category — used in " +"Many2one displays and audit trails." +msgstr "" +"Leesbaar label dat het GS-patroon en de categorie combineert — gebruikt in " +"Many2one-weergaven en audit-trails." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__id +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__id +msgid "ID" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "Intrastat Description" +msgstr "Intrastat-omschrijving" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_uid +msgid "Last Updated by" +msgstr "Laatst bijgewerkt door" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_date +msgid "Last Updated on" +msgstr "Laatst bijgewerkt op" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "" +"Length of the literal portion of the pattern (the part before any '*'). Used" +" to order matches longest-first so an exact code beats a prefix wildcard." +msgstr "" +"Lengte van het letterlijke deel van het patroon (het deel vóór een '*'). " +"Gebruikt om treffers van langst-eerst te ordenen, zodat een exacte code wint" +" van een voorvoegsel-joker." + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Map HS tariff codes to product categories" +msgstr "GS-tariefcodes koppelen aan productcategorieën" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "Name" +msgstr "Naam" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"No HS-code mapping rule matches %(hs)s for %(name)s. Add a rule in Settings " +"→ Technical → Product Category HS Mapping, or extend the parent prefix." +msgstr "" +"Geen GS-code-koppelingsregel komt overeen met %(hs)s voor %(name)s. Voeg een" +" regel toe via Instellingen → Technisch → Productcategorie GS-koppeling, of " +"verleng het bovenliggende voorvoegsel." + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Patterns can be exact codes (" +msgstr "Patronen kunnen exacte codes (" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "" +"Plain-language description of what the rule's HS-code pattern actually " +"covers, looked up against the installed ``account.intrastat.code`` records. " +"Lets a buyer eyeball whether ``8539299*`` mapped to 'Hydraulics' is wrong " +"(it is — it's Filament lamps) without consulting a tariff manual. Computed, " +"not stored — refreshes when the pattern changes or new intrastat codes get " +"loaded." +msgstr "" +"Beschrijving in gewone taal van wat het GS-codepatroon van de regel " +"daadwerkelijk dekt, opgezocht in de geïnstalleerde " +"``account.intrastat.code``-records. Laat een inkoper in één oogopslag zien " +"of bv. ``8539299*`` aan 'Hydraulica' is gekoppeld (dat is fout — het zijn " +"Gloeilampen) zonder een tariefhandleiding te hoeven raadplegen. Berekend, " +"niet opgeslagen — wordt vernieuwd wanneer het patroon verandert of nieuwe " +"intrastatcodes worden geladen." + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_template +msgid "Product" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"Product %(name)s has no HS code set. Fill ``hs_code`` first (Product form → " +"Purchase tab) or via TVH enrichment." +msgstr "" +"Product %(name)s heeft geen GS-code ingesteld. Vul ``hs_code`` eerst in " +"(Productformulier → tab Inkoop) of via supplier-enrichment." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Product %(name)s is already in the matched category (%(cat)s)." +msgstr "Product %(name)s zit al in de overeenkomende categorie (%(cat)s)." + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Re-categorised via HS-code mapping: %(hs)s — %(old)s → %(new)s" +msgstr "Opnieuw ingedeeld via GS-code-koppeling: %(hs)s — %(old)s → %(new)s" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "" +"Restrict this rule to a specific company. Empty = applies to all companies. " +"Multi-company customers can ship different mapping tables per legal entity." +msgstr "" +"Beperk deze regel tot een specifiek bedrijf. Leeg = geldt voor alle " +"bedrijven. Multi-bedrijfsklanten kunnen per juridische entiteit " +"verschillende koppelingstabellen leveren." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "Sequence" +msgstr "Volgorde" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "Specificity" +msgstr "Specifiekheid" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__category_id +msgid "Target product category for products matching this pattern." +msgstr "" +"Doel-productcategorie voor producten die met dit patroon overeenkomen." + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "" +"Tiebreaker among same-specificity rules — lower wins. Specificity (longer " +"pattern → higher) takes precedence." +msgstr "" +"Doorslag bij regels met gelijke specifiekheid — lager wint. Specifiekheid " +"(langer patroon → hoger) heeft voorrang." + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "e.g. 8421* or 8421230090" +msgstr "bijv. 8421* of 8421230090" diff --git a/product_category_hs_mapping/i18n/product_category_hs_mapping.pot b/product_category_hs_mapping/i18n/product_category_hs_mapping.pot new file mode 100644 index 00000000000..6497c6767ab --- /dev/null +++ b/product_category_hs_mapping/i18n/product_category_hs_mapping.pot @@ -0,0 +1,294 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * product_category_hs_mapping +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 19.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-05-09 20:30+0000\n" +"PO-Revision-Date: 2026-05-09 20:30+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"%(updated)d re-categorised, %(unchanged)d already correct, %(no_code)d " +"without HS code, %(no_rule)d with no matching rule." +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "(intrastat code preview)" +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +") or\n" +" prefix wildcards (" +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "" +"). Longest literal\n" +" prefix wins at resolution time, so a more specific\n" +" rule beats a more general one." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__active +msgid "Active" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"An HS-code mapping rule for pattern '%(pat)s' and the same company scope " +"already exists. Patterns must be unique per company (or globally, when no " +"company is set)." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.actions.server,name:product_category_hs_mapping.action_apply_hs_mapping +msgid "Apply HS Code → Category Mapping" +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Archived" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__category_id +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_search +msgid "Category" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "Company" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_uid +msgid "Created by" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__create_date +msgid "Created on" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__display_name +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__display_name +msgid "Display Name" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_edi_mappings_root +msgid "EDI Mappings" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "HS Code Mapping" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.ui.menu,name:product_category_hs_mapping.menu_product_category_hs_mapping +msgid "HS Code → Category" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.actions.act_window,name:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "HS Code → Category Mappings" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_category_hs_mapping +msgid "HS Code → Product Category Mapping" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' has a '*' that isn't the last character. Only " +"trailing wildcard is supported (e.g. '8421*'). Use a longer literal prefix " +"instead." +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern '%(pat)s' must contain only digits (and an optional trailing" +" '*'); got non-digit characters." +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "HS code pattern is required." +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_category_hs_mapping.py:0 +msgid "" +"HS code pattern must contain at least one digit before any '*' wildcard." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "" +"HS tariff code pattern. Treated as a prefix at lookup time — a rule with " +"literal '84312000' matches both '84312000' and '8431200080' (longer national" +" tariff lines roll up to the same chapter / subheading). The trailing '*' is" +" purely visual; '84312000' and '84312000*' behave identically. Longest " +"literal wins." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__hs_code_pattern +msgid "Hs Code Pattern" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "" +"Human-readable label combining the HS pattern and the category — used in " +"Many2one displays and audit trails." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__id +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_template__id +msgid "ID" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "Intrastat Description" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__write_date +msgid "Last Updated on" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "" +"Length of the literal portion of the pattern (the part before any '*'). Used" +" to order matches longest-first so an exact code beats a prefix wildcard." +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Map HS tariff codes to product categories" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__name +msgid "Name" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"No HS-code mapping rule matches %(hs)s for %(name)s. Add a rule in Settings " +"→ Technical → Product Category HS Mapping, or extend the parent prefix." +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.actions.act_window,help:product_category_hs_mapping.product_category_hs_mapping_act_window +msgid "Patterns can be exact codes (" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__intrastat_description +msgid "" +"Plain-language description of what the rule's HS-code pattern actually " +"covers, looked up against the installed ``account.intrastat.code`` records. " +"Lets a buyer eyeball whether ``8539299*`` mapped to 'Hydraulics' is wrong " +"(it is — it's Filament lamps) without consulting a tariff manual. Computed, " +"not stored — refreshes when the pattern changes or new intrastat codes get " +"loaded." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model,name:product_category_hs_mapping.model_product_template +msgid "Product" +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "" +"Product %(name)s has no HS code set. Fill ``hs_code`` first (Product form → " +"Purchase tab) or via TVH enrichment." +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Product %(name)s is already in the matched category (%(cat)s)." +msgstr "" + +#. module: product_category_hs_mapping +#. odoo-python +#: code:addons/product_category_hs_mapping/models/product_template.py:0 +msgid "Re-categorised via HS-code mapping: %(hs)s — %(old)s → %(new)s" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__company_id +msgid "" +"Restrict this rule to a specific company. Empty = applies to all companies. " +"Multi-company customers can ship different mapping tables per legal entity." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "Sequence" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,field_description:product_category_hs_mapping.field_product_category_hs_mapping__specificity +msgid "Specificity" +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__category_id +msgid "Target product category for products matching this pattern." +msgstr "" + +#. module: product_category_hs_mapping +#: model:ir.model.fields,help:product_category_hs_mapping.field_product_category_hs_mapping__sequence +msgid "" +"Tiebreaker among same-specificity rules — lower wins. Specificity (longer " +"pattern → higher) takes precedence." +msgstr "" + +#. module: product_category_hs_mapping +#: model_terms:ir.ui.view,arch_db:product_category_hs_mapping.product_category_hs_mapping_form +msgid "e.g. 8421* or 8421230090" +msgstr "" diff --git a/product_category_hs_mapping/models/__init__.py b/product_category_hs_mapping/models/__init__.py new file mode 100644 index 00000000000..e5e427b885a --- /dev/null +++ b/product_category_hs_mapping/models/__init__.py @@ -0,0 +1,2 @@ +from . import product_category_hs_mapping +from . import product_template diff --git a/product_category_hs_mapping/models/product_category_hs_mapping.py b/product_category_hs_mapping/models/product_category_hs_mapping.py new file mode 100644 index 00000000000..b76b19dcf49 --- /dev/null +++ b/product_category_hs_mapping/models/product_category_hs_mapping.py @@ -0,0 +1,301 @@ +# Copyright 2026 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# pylint: disable=translation-not-lazy,prefer-env-translation,no-name-in-module,missing-class-docstring,abstract-method,invalid-name + +import logging +import re + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class ProductCategoryHsMapping(models.Model): + _name = "product.category.hs.mapping" + _description = "HS Code → Product Category Mapping" + _order = "specificity desc, sequence, id" + + name = fields.Char( + compute="_compute_name", + store=True, + help=( + "Human-readable label combining the HS pattern and the " + "category — used in Many2one displays and audit trails." + ), + ) + hs_code_pattern = fields.Char( + required=True, + help=( + "HS tariff code pattern. Treated as a prefix at lookup " + "time — a rule with literal '84312000' matches both " + "'84312000' and '8431200080' (longer national tariff " + "lines roll up to the same chapter / subheading). The " + "trailing '*' is purely visual; '84312000' and '84312000*' " + "behave identically. Longest literal wins." + ), + ) + category_id = fields.Many2one( + comodel_name="product.category", + required=True, + ondelete="cascade", + help="Target product category for products matching this pattern.", + ) + sequence = fields.Integer( + default=10, + help=( + "Tiebreaker among same-specificity rules — lower wins. " + "Specificity (longer pattern → higher) takes precedence." + ), + ) + company_id = fields.Many2one( + comodel_name="res.company", + help=( + "Restrict this rule to a specific company. Empty = applies " + "to all companies. Multi-company customers can ship " + "different mapping tables per legal entity." + ), + ) + specificity = fields.Integer( + compute="_compute_specificity", + store=True, + index=True, + help=( + "Length of the literal portion of the pattern (the part " + "before any '*'). Used to order matches longest-first so " + "an exact code beats a prefix wildcard." + ), + ) + active = fields.Boolean(default=True) + intrastat_description = fields.Char( + compute="_compute_intrastat_description", + help=( + "Plain-language description of what the rule's HS-code " + "pattern actually covers, looked up against the installed " + "``account.intrastat.code`` records. Lets a buyer eyeball " + "whether ``8539299*`` mapped to 'Hydraulics' is wrong " + "(it is — it's Filament lamps) without consulting a " + "tariff manual. Computed, not stored — refreshes when " + "the pattern changes or new intrastat codes get loaded." + ), + ) + + @api.depends("hs_code_pattern") + def _compute_intrastat_description(self): + # Resolve via the same prefix logic the matcher itself uses. + # Cheap when account_intrastat isn't installed: env reference + # raises KeyError, which we catch and skip. + try: + IntrastatCode = self.env["account.intrastat.code"].sudo() + except KeyError: + for rec in self: + rec.intrastat_description = "" + return + # The 3-tier lookup below is unreachable in this module's + # CI because ``account.intrastat.code`` lives in OCA's + # ``account_intrastat`` (a separate project). Adding it as + # a manifest dep would cross OCA-project boundaries for a + # purely cosmetic field. The KeyError early-return above + # handles the common case (matcher installed without + # ``account_intrastat``); the lookup below kicks in for + # users who do have it installed alongside. Excluded from + # coverage explicitly. + for rec in self: # pragma: no cover + literal = (rec.hs_code_pattern or "").rstrip("*") + if not literal: + rec.intrastat_description = "" + continue + # 1. Exact match wins outright. + exact = IntrastatCode.search([("code", "=", literal)], limit=1) + if exact: + rec.intrastat_description = exact.description or "" + continue + # 2. Shortest intrastat code that EXTENDS the pattern's + # literal — gives a representative leaf description. + # e.g. literal=``8539299`` matches ``85392998`` (the + # 8-digit subheading "Filament lamps..."). + extending = IntrastatCode.search( + [("code", "=like", literal + "%")], + order="code", + limit=1, + ) + if extending: + rec.intrastat_description = ( + f"({extending.code}) {extending.description or ''}".strip() + ) + continue + # 3. Shortest intrastat code the pattern itself EXTENDS — + # e.g. literal=``8431200080`` (10-digit national tariff + # line) extends ``84312000`` (8-digit subheading) which + # has the description we want. + for length in (10, 8, 6, 4, 2): + if length >= len(literal): + continue + parent = IntrastatCode.search( + [("code", "=", literal[:length])], limit=1 + ) + if parent: + rec.intrastat_description = ( + f"({parent.code}) {parent.description or ''}".strip() + ) + break + else: + rec.intrastat_description = "" + + @api.constrains("hs_code_pattern", "company_id") + def _check_pattern_company_unique(self): + """Reject duplicate (pattern, company_id) — implemented in + Python rather than via SQL ``UNIQUE`` because PostgreSQL's + default ``UNIQUE`` semantic treats NULL company_ids as + distinct, which would let two global rules for the same + pattern coexist (defeating the constraint). PostgreSQL 15+ + offers ``UNIQUE NULLS NOT DISTINCT`` but that path needs + more careful Odoo-version gating; the Python check is + portable and clear.""" + for rec in self: + domain = [ + ("hs_code_pattern", "=", rec.hs_code_pattern), + ("id", "!=", rec.id), + ] + if rec.company_id: + domain.append(("company_id", "=", rec.company_id.id)) + else: + domain.append(("company_id", "=", False)) + if self.search_count(domain): + raise ValidationError( + _( + "An HS-code mapping rule for pattern " + "'%(pat)s' and the same company scope already " + "exists. Patterns must be unique per company " + "(or globally, when no company is set)." + ) + % {"pat": rec.hs_code_pattern} + ) + + @api.depends("hs_code_pattern", "category_id") + def _compute_name(self): + for rec in self: + if rec.hs_code_pattern and rec.category_id: + rec.name = f"{rec.hs_code_pattern} → {rec.category_id.display_name}" + else: + rec.name = rec.hs_code_pattern or "" + + @api.depends("hs_code_pattern") + def _compute_specificity(self): + for rec in self: + pat = rec.hs_code_pattern or "" + # Specificity = literal-prefix length. "8421230090" → 10, + # "8421*" → 4, "*" → 0. Longest literal wins at lookup. + literal = pat.split("*", 1)[0] + rec.specificity = len(literal) + + @api.constrains("hs_code_pattern") + def _check_pattern_shape(self): + # Allow digits and at most one trailing '*'. HS codes are + # numeric (typically 6/8/10 digits); accept anything in between + # so partial chapter prefixes like '84' work. + # A bare ``*`` is a deliberate catch-all (specificity 0, + # last-resort match) — also allowed. + for rec in self: + pat = (rec.hs_code_pattern or "").strip() + if not pat: + raise ValidationError(_("HS code pattern is required.")) + if pat == "*": + # Bare catch-all — accepted, no further checks. + continue + literal = pat.rstrip("*") + if not literal: + raise ValidationError( + _( + "HS code pattern must contain at least one digit " + "before any '*' wildcard." + ) + ) + if not re.fullmatch(r"\d+", literal): + raise ValidationError( + _( + "HS code pattern '%(pat)s' must contain only " + "digits (and an optional trailing '*'); got " + "non-digit characters." + ) + % {"pat": pat} + ) + # Reject internal '*' — only trailing wildcard supported. + if "*" in pat[:-1]: + raise ValidationError( + _( + "HS code pattern '%(pat)s' has a '*' that isn't " + "the last character. Only trailing wildcard is " + "supported (e.g. '8421*'). Use a longer literal " + "prefix instead." + ) + % {"pat": pat} + ) + + @api.model + def _get_category_for_hs_code(self, hs_code, company=None): + """Return the best-matching ``product.category`` for the given + HS code, or an empty recordset. + + Matching: every rule's literal (the part before any ``*``) is + treated as a prefix. HS codes are hierarchical — a 6-digit + chapter heading rolls up everything under it (8-digit + subheading, 10-digit national tariff line, ...) — so a rule + with literal ``"84312000"`` should naturally match an input + of ``"8431200080"``. The trailing ``*`` is purely visual: a + rule with or without it matches the same set of inputs. Rules + are ordered by specificity (literal length, longest first) + then by sequence, so the most specific rule wins. + + Company scoping: rules with ``company_id`` set match only when + ``company`` matches; rules with no ``company_id`` apply to all + companies. When ``company`` is None, falls back to + ``self.env.company``. + + Used by EDI / supplier-integration glue to auto-categorise + newly imported products. Safe to call with garbage input — + empty / non-digit codes return an empty category, never raise. + Logs at INFO level when a non-empty input doesn't match any + rule so previously-silent gaps surface in the server log. + """ + if not hs_code: + return self.env["product.category"] + # Strip whitespace and ensure pure digits before matching. + # HS codes from external systems sometimes have dots / spaces. + clean = re.sub(r"\D", "", hs_code) + if not clean: + return self.env["product.category"] + company = company or self.env.company + # Domain pulls only rules that could match: same company or + # global. We then filter Python-side by prefix because SQL + # LIKE on `hs_code_pattern LIKE '%'` would be the + # wrong direction (we want our code to start with the rule's + # literal, not vice-versa). + candidates = self.search( + [ + ("active", "=", True), + "|", + ("company_id", "=", False), + ("company_id", "=", company.id), + ], + order="specificity desc, sequence, id", + ) + for rule in candidates: + literal = (rule.hs_code_pattern or "").rstrip("*") + # Empty literal = bare ``*`` catch-all. Order is + # specificity-desc so this rule sits last among + # candidates; it only fires when nothing more specific + # already matched and returned above. + if not literal: + return rule.category_id + if clean.startswith(literal): + return rule.category_id + _logger.info( + "[product.category.hs.mapping] no rule matched HS code %r " + "(cleaned %r) for company %s — product left in default " + "category. Add a mapping if this should auto-categorise.", + hs_code, + clean, + company.display_name, + ) + return self.env["product.category"] diff --git a/product_category_hs_mapping/models/product_template.py b/product_category_hs_mapping/models/product_template.py new file mode 100644 index 00000000000..c147f63e505 --- /dev/null +++ b/product_category_hs_mapping/models/product_template.py @@ -0,0 +1,127 @@ +# Copyright 2026 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# pylint: disable=abstract-method,translation-not-lazy,prefer-env-translation,no-name-in-module,invalid-name,protected-access,use-implicit-booleaness-not-comparison-to-zero + +import logging + +from odoo import models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + """Adds the manual ``Apply HS Code → Category Mapping`` server + action to ``product.template``. The mapping itself is defined in + ``product.category.hs.mapping``; this method just wires the + matcher to the per-product context-menu action so a buyer can + debug, test, or selectively re-apply a rule against a specific + product without going through the full Refresh-from-supplier + flow. + + Lives in this module (not a downstream consumer) because it's + the natural extension of the matcher — re-running the resolver + against the product's own ``hs_code`` field. + """ + + _inherit = "product.template" + + def action_apply_hs_mapping(self): + """Re-run the HS-code matcher against each selected product + and write the result to ``categ_id``. + + Raises a ``UserError`` for SINGLE-product invocations when + nothing happens (no HS code / no rule / already in matched + category) so the buyer sees the cause directly. Multi-product + invocations show a summary toast with the breakdown + (updated / unchanged / skipped) and a ``soft_reload`` so the + list refreshes without a manual F5. + + Permissions: relies on the standard ``product.template`` + write ACL — anyone with edit rights can re-categorise. + Fine-grained "only purchasing" gating is left to the + deployer via Odoo's standard record rules / group + memberships, no need for our own ACL.""" + if not self: + return False + Mapping = self.env["product.category.hs.mapping"] + updated = 0 + unchanged = 0 + skipped_no_code = 0 + skipped_no_rule = 0 + for tmpl in self: + hs_code = (tmpl.hs_code or "").strip() + if not hs_code: + skipped_no_code += 1 + continue + new_category = Mapping._get_category_for_hs_code(hs_code, tmpl.company_id) + if not new_category: + skipped_no_rule += 1 + continue + if new_category == tmpl.categ_id: + unchanged += 1 + continue + old_category = tmpl.categ_id + tmpl.categ_id = new_category + tmpl.message_post( + body=self.env._( + "Re-categorised via HS-code mapping: %(hs)s — %(old)s → %(new)s", + hs=hs_code, + old=old_category.display_name or "(unset)", + new=new_category.display_name, + ) + ) + updated += 1 + # Single-product manual invocation: surface the "why nothing + # happened" reason as a UserError so the buyer reads it + # directly instead of a tucked-away notification toast. + if len(self) == 1 and updated == 0: + if skipped_no_code: + raise UserError( + self.env._( + "Product %(name)s has no HS code set. " + "Fill ``hs_code`` first (Product form → " + "Purchase tab) or via TVH enrichment.", + name=self.display_name, + ) + ) + if skipped_no_rule: + raise UserError( + self.env._( + "No HS-code mapping rule matches " + "%(hs)s for %(name)s. Add a rule in " + "Settings → Technical → Product Category " + "HS Mapping, or extend the parent prefix.", + hs=hs_code, + name=self.display_name, + ) + ) + if unchanged: + raise UserError( + self.env._( + "Product %(name)s is already in the " + "matched category (%(cat)s).", + name=self.display_name, + cat=self.categ_id.display_name, + ) + ) + # Multi-product: summary toast + soft_reload to refresh the + # list view in place. + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("HS Code Mapping"), + "message": self.env._( + "%(updated)d re-categorised, %(unchanged)d " + "already correct, %(no_code)d without HS " + "code, %(no_rule)d with no matching rule.", + updated=updated, + unchanged=unchanged, + no_code=skipped_no_code, + no_rule=skipped_no_rule, + ), + "type": "success" if updated else "warning", + "next": {"type": "ir.actions.client", "tag": "soft_reload"}, + }, + } diff --git a/product_category_hs_mapping/pyproject.toml b/product_category_hs_mapping/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/product_category_hs_mapping/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/product_category_hs_mapping/readme/CONTEXT.md b/product_category_hs_mapping/readme/CONTEXT.md new file mode 100644 index 00000000000..dc4560f3263 --- /dev/null +++ b/product_category_hs_mapping/readme/CONTEXT.md @@ -0,0 +1,114 @@ +# Why this module exists + +## The problem + +When products land in Odoo from a supplier integration (cXML / +OCI punchout cart, REST-API enrichment, EDI feed, IDoc import, +…) they typically arrive with: + +- a **customs HS code** (``commodityCode`` in the supplier's + payload, populated on ``product.template.hs_code`` via OCA + ``account_intrastat`` or equivalent), +- but **no product category** — or a placeholder category the + integration uses as a landing zone (``All / Imported`` or the + supplier-specific bucket the punchout backend defaults to). + +The category drives downstream behaviour: which warehouse +location the product lands in, which procurement rules fire, +which analytic account a reorder hits, which sales tax applies, +which sequence the auto-generated internal reference uses. +Leaving thousands of imported SKUs in a single placeholder +category breaks every one of those flows. + +## What this module does + +A small mapping table: HS-code patterns → ``product.category``. +Designed to plug into any flow that sets ``hs_code`` on a +product: + +```python +mapped = self.env["product.category.hs.mapping"]._get_category_for_hs_code( + "84314100", # Forks for fork-lift trucks + company=self.company_id, +) +if mapped: + template.categ_id = mapped +``` + +Patterns can be: + +- **Full HS heading or subheading**: ``8431`` (parts of + fork-lifts), ``843141`` (specific fork-lift parts), + ``84314100`` (forks). Longer literals win — a rule with + ``84314100`` beats a rule with ``8431`` for input + ``8431410080``. +- **Wildcard**: ``8431*`` is identical in behaviour to + ``8431`` (the trailing ``*`` is purely visual). The bare + ``*`` pattern is a deliberate catch-all (specificity 0), + matched last when nothing else fits. + +Per-company scope: rules with ``company_id`` set apply only to +that company; rules with no ``company_id`` apply to all +companies. Multi-company customers can ship different mapping +tables per legal entity. + +## Why it's distinct from existing OCA work + +OCA already provides: + +- ``account_intrastat`` — the source of HS codes themselves + (``account.intrastat.code`` records mapped to + ``product.template.hs_code``). +- ``account_intrastat_oss`` and friends — declaration-level + consumers of those codes. + +Neither maps HS codes to **product categories**. The closest +adjacent module, ``product_category_active``, manages category +lifecycle but doesn't classify products by HS. This module fills +the gap between "we have a customs code on the product" and "we +want the product in the right Odoo category". + +## Use cases that drive this design + +- **Punchout cart imports**: every line in the cart enters with + ``hs_code`` from the supplier's cXML / OCI payload. The + punchout-purchase glue calls + ``_get_category_for_hs_code(hs_code)`` on the freshly created + ``product.template`` and writes the result to ``categ_id``. +- **REST-API product enrichment** (OEM parts catalogues): when + Odoo enriches a stub product with ``GET /items``-style data + from the supplier, the response carries ``commodityCode``; + same lookup populates the category at the same time. +- **EDI imports** (Pricat, custom CSV, …): an import wizard + sets ``hs_code`` first and the category after, using the + matcher. +- **Manual buyer override**: the bundled ``Apply HS Code → + Category Mapping`` server action lets a buyer re-run the + resolver against any selected product (Action menu, form + + list view) — useful for retroactively classifying a + pre-existing catalogue, or for debugging a rule that doesn't + match what the buyer expected. + +## Bootstrap data is *not* shipped here + +The module deliberately ships **no rules**. Each customer's +mapping table reflects their own catalogue scope (different +HS chapters, different category trees, different language +preferences). A customer-side bootstrap module sits on top: + +```python +"depends": [ + "product", + "product_category_hs_mapping", +], +"data": [ + "data/product.category.csv", + "data/product_category_hs_mapping.xml", +], +``` + +That bootstrap module carries the customer's ``noupdate="1"`` +seed records, marked so buyer edits survive future upgrades. +Customer adoption pattern: install this matcher module from +OCA, then ship a tiny private module with your own category +tree + HS rules. diff --git a/product_category_hs_mapping/readme/DESCRIPTION.md b/product_category_hs_mapping/readme/DESCRIPTION.md new file mode 100644 index 00000000000..3be0e2faa25 --- /dev/null +++ b/product_category_hs_mapping/readme/DESCRIPTION.md @@ -0,0 +1,22 @@ +Maps customs HS (Harmonized System) tariff codes to Odoo +`product.category` records, so EDI / supplier integrations can +auto-categorise newly-created products without hard-coding mapping +logic per integration. + +The mapping table supports two pattern shapes: + +- **Exact code** — e.g. `8421230090` matches only that exact code. +- **Prefix wildcard** — e.g. `8421*` matches any code starting with + `8421`. + +At resolution time, the longest literal prefix wins. So a rule for +`8421230090` (specificity 10) beats a rule for `8421*` (specificity +4) which itself beats `84*` (specificity 2). + +Multi-company aware: rules can be scoped to a specific company or +left global (apply to all companies). + +Ships with a sensible default mapping table covering common +spare-parts HS chapters relevant to material-handling and +industrial-equipment maintenance. Customers will typically redirect +the targets to their own product-category tree. diff --git a/product_category_hs_mapping/readme/HISTORY.md b/product_category_hs_mapping/readme/HISTORY.md new file mode 100644 index 00000000000..ab7a62019f6 --- /dev/null +++ b/product_category_hs_mapping/readme/HISTORY.md @@ -0,0 +1,23 @@ +## 19.0.1.0.0 (2026-05) + +- Initial OCA-bound release. Provides: + - ``product.category.hs.mapping`` model with HS-code-pattern + → ``product.category`` rules (longest-literal-prefix wins, + optional ``*`` wildcard, per-company scope, security ACL). + - ``intrastat_description`` computed Char on each rule — + 3-tier lookup (exact → shortest extending → parent prefix) + against the installed ``account.intrastat.code`` records, so + buyers can sanity-check what a pattern actually covers + without reaching for a tariff manual. + - ``Apply HS Code → Category Mapping`` server action on + ``product.template`` (Action menu, form + list). Re-runs the + matcher against the product's ``hs_code`` and writes the + result to ``categ_id``. Single-product invocations raise a + specific ``UserError`` for each "nothing happened" reason + (no HS code / no rule / already in matched category). + Multi-product invocations show a summary toast with the + breakdown and a ``soft_reload`` so the list refreshes. +- Pre-OCA development was driven by a real-world customer + deployment with a multi-thousand-SKU parts catalogue; the + matcher graduated to OCA-shape after several rounds of + buyer testing. diff --git a/product_category_hs_mapping/readme/ROADMAP.md b/product_category_hs_mapping/readme/ROADMAP.md new file mode 100644 index 00000000000..28ba0f8d1d4 --- /dev/null +++ b/product_category_hs_mapping/readme/ROADMAP.md @@ -0,0 +1,54 @@ +- **Auto-populate stub rules for unknown HS codes + assignee + activity.** When the matcher receives an HS code it can't + resolve, optionally create a draft rule (empty + ``category_id``, ``sequence=999``) and spawn a ``mail.activity`` + on a configurable responsible group / user — surfacing the + gap in someone's "to-categorise" inbox instead of leaving it + in the matcher's INFO log only. + + Off by default. Toggle via ``res.config.settings`` Boolean + (system parameter + ``product_category_hs_mapping.auto_create_unknown``) so the + feature is opt-in per-database. Auto-creation is idempotent + on (``hs_code_pattern``, ``company_id``, ``category_id IS NULL``): + one stub per unknown code, no duplicates on subsequent + imports. + + **Multi-company nuance**: in a multi-company environment + different people / groups may be responsible for the + category mapping per company (a product manager for the + industrial entity, a procurement lead for the agri entity, + etc). The activity-assignee target is therefore PER company, + not a single global group. Concrete shape: a Many2one + ``hs_mapping_responsible_user_id`` (or group) on + ``res.company`` with a fallback chain — company-level + setting → global system parameter + ``product_category_hs_mapping.responsible_group_xmlid`` → + log+skip if neither is set. + + When the responsible user fills ``category_id`` on the stub, + the activity auto-marks Done (``mail.activity._action_done`` + hook). The chatter on the rule shows *which* product + triggered the stub creation so the assignee can sanity-check + the proposed category against a real example. + + Not implemented yet — pending real demand. The manual + ``Apply HS Code → Category Mapping`` server action covers + the buyer-driven re-categorise flow today. + +- **Per-line / variant override**. ``product.product`` doesn't + inherit ``hs_code`` separately, so the manual server action + is currently bound to ``product.template``. If a customer + ever ships variants with different tariff codes (rare in + OEM-parts catalogues but conceivable for raw-material + multipacks), surface the action on ``product.product`` too + with a per-variant ``hs_code`` field. + +- **Bulk re-categorisation cron / queued-job**. When the + upstream HS mapping table changes (e.g. a new EU tariff + line), a buyer may want to re-run the matcher across the + entire product catalogue. Today they can multi-select in + the product list and trigger the existing action — but for + thousands of products that's a single heavy transaction. + A cron-driven or ``queue_job``-backed variant with progress + feedback would be more operator-friendly. diff --git a/product_category_hs_mapping/security/ir.model.access.csv b/product_category_hs_mapping/security/ir.model.access.csv new file mode 100644 index 00000000000..a11126665bc --- /dev/null +++ b/product_category_hs_mapping/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_product_category_hs_mapping_user,product.category.hs.mapping user,model_product_category_hs_mapping,base.group_user,1,0,0,0 +access_product_category_hs_mapping_manager,product.category.hs.mapping manager,model_product_category_hs_mapping,base.group_system,1,1,1,1 diff --git a/product_category_hs_mapping/static/description/index.html b/product_category_hs_mapping/static/description/index.html new file mode 100644 index 00000000000..7edfe5cccc4 --- /dev/null +++ b/product_category_hs_mapping/static/description/index.html @@ -0,0 +1,646 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Product Category — HS Code Mapping

+ +

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

+

Maps customs HS (Harmonized System) tariff codes to Odoo +product.category records, so EDI / supplier integrations can +auto-categorise newly-created products without hard-coding mapping logic +per integration.

+

The mapping table supports two pattern shapes:

+
    +
  • Exact code — e.g. 8421230090 matches only that exact code.
  • +
  • Prefix wildcard — e.g. 8421* matches any code starting with +8421.
  • +
+

At resolution time, the longest literal prefix wins. So a rule for +8421230090 (specificity 10) beats a rule for 8421* (specificity +4) which itself beats 84* (specificity 2).

+

Multi-company aware: rules can be scoped to a specific company or left +global (apply to all companies).

+

Ships with a sensible default mapping table covering common spare-parts +HS chapters relevant to material-handling and industrial-equipment +maintenance. Customers will typically redirect the targets to their own +product-category tree.

+

Table of contents

+ + +
+

Why this module exists

+
+

The problem

+

When products land in Odoo from a supplier integration (cXML / OCI +punchout cart, REST-API enrichment, EDI feed, IDoc import, …) they +typically arrive with:

+
    +
  • a customs HS code (commodityCode in the supplier’s payload, +populated on product.template.hs_code via OCA +account_intrastat or equivalent),
  • +
  • but no product category — or a placeholder category the +integration uses as a landing zone (All / Imported or the +supplier-specific bucket the punchout backend defaults to).
  • +
+

The category drives downstream behaviour: which warehouse location the +product lands in, which procurement rules fire, which analytic account a +reorder hits, which sales tax applies, which sequence the auto-generated +internal reference uses. Leaving thousands of imported SKUs in a single +placeholder category breaks every one of those flows.

+
+
+

What this module does

+

A small mapping table: HS-code patterns → product.category. Designed +to plug into any flow that sets hs_code on a product:

+
+mapped = self.env["product.category.hs.mapping"]._get_category_for_hs_code(
+    "84314100",        # Forks for fork-lift trucks
+    company=self.company_id,
+)
+if mapped:
+    template.categ_id = mapped
+
+

Patterns can be:

+
    +
  • Full HS heading or subheading: 8431 (parts of fork-lifts), +843141 (specific fork-lift parts), 84314100 (forks). Longer +literals win — a rule with 84314100 beats a rule with 8431 for +input 8431410080.
  • +
  • Wildcard: 8431* is identical in behaviour to 8431 (the +trailing * is purely visual). The bare * pattern is a +deliberate catch-all (specificity 0), matched last when nothing else +fits.
  • +
+

Per-company scope: rules with company_id set apply only to that +company; rules with no company_id apply to all companies. +Multi-company customers can ship different mapping tables per legal +entity.

+
+
+

Why it’s distinct from existing OCA work

+

OCA already provides:

+
    +
  • account_intrastat — the source of HS codes themselves +(account.intrastat.code records mapped to +product.template.hs_code).
  • +
  • account_intrastat_oss and friends — declaration-level consumers of +those codes.
  • +
+

Neither maps HS codes to product categories. The closest adjacent +module, product_category_active, manages category lifecycle but +doesn’t classify products by HS. This module fills the gap between “we +have a customs code on the product” and “we want the product in the +right Odoo category”.

+
+
+

Use cases that drive this design

+
    +
  • Punchout cart imports: every line in the cart enters with +hs_code from the supplier’s cXML / OCI payload. The +punchout-purchase glue calls _get_category_for_hs_code(hs_code) on +the freshly created product.template and writes the result to +categ_id.
  • +
  • REST-API product enrichment (OEM parts catalogues): when Odoo +enriches a stub product with GET /items-style data from the +supplier, the response carries commodityCode; same lookup +populates the category at the same time.
  • +
  • EDI imports (Pricat, custom CSV, …): an import wizard sets +hs_code first and the category after, using the matcher.
  • +
  • Manual buyer override: the bundled +Apply HS Code → Category Mapping server action lets a buyer re-run +the resolver against any selected product (Action menu, form + list +view) — useful for retroactively classifying a pre-existing catalogue, +or for debugging a rule that doesn’t match what the buyer expected.
  • +
+
+
+

Bootstrap data is not shipped here

+

The module deliberately ships no rules. Each customer’s mapping +table reflects their own catalogue scope (different HS chapters, +different category trees, different language preferences). A +customer-side bootstrap module sits on top:

+
+"depends": [
+    "product",
+    "product_category_hs_mapping",
+],
+"data": [
+    "data/product.category.csv",
+    "data/product_category_hs_mapping.xml",
+],
+
+

That bootstrap module carries the customer’s noupdate="1" seed +records, marked so buyer edits survive future upgrades. Customer +adoption pattern: install this matcher module from OCA, then ship a tiny +private module with your own category tree + HS rules.

+
+
+
+

Known issues / Roadmap

+
    +
  • Auto-populate stub rules for unknown HS codes + assignee activity. +When the matcher receives an HS code it can’t resolve, optionally +create a draft rule (empty category_id, sequence=999) and +spawn a mail.activity on a configurable responsible group / user — +surfacing the gap in someone’s “to-categorise” inbox instead of +leaving it in the matcher’s INFO log only.

    +

    Off by default. Toggle via res.config.settings Boolean (system +parameter product_category_hs_mapping.auto_create_unknown) so the +feature is opt-in per-database. Auto-creation is idempotent on +(hs_code_pattern, company_id, category_id IS NULL): one +stub per unknown code, no duplicates on subsequent imports.

    +

    Multi-company nuance: in a multi-company environment different +people / groups may be responsible for the category mapping per +company (a product manager for the industrial entity, a procurement +lead for the agri entity, etc). The activity-assignee target is +therefore PER company, not a single global group. Concrete shape: a +Many2one hs_mapping_responsible_user_id (or group) on +res.company with a fallback chain — company-level setting → global +system parameter +product_category_hs_mapping.responsible_group_xmlid → log+skip if +neither is set.

    +

    When the responsible user fills category_id on the stub, the +activity auto-marks Done (mail.activity._action_done hook). The +chatter on the rule shows which product triggered the stub creation +so the assignee can sanity-check the proposed category against a real +example.

    +

    Not implemented yet — pending real demand. The manual +Apply HS Code → Category Mapping server action covers the +buyer-driven re-categorise flow today.

    +
  • +
  • Per-line / variant override. product.product doesn’t inherit +hs_code separately, so the manual server action is currently bound +to product.template. If a customer ever ships variants with +different tariff codes (rare in OEM-parts catalogues but conceivable +for raw-material multipacks), surface the action on +product.product too with a per-variant hs_code field.

    +
  • +
  • Bulk re-categorisation cron / queued-job. When the upstream HS +mapping table changes (e.g. a new EU tariff line), a buyer may want to +re-run the matcher across the entire product catalogue. Today they can +multi-select in the product list and trigger the existing action — but +for thousands of products that’s a single heavy transaction. A +cron-driven or queue_job-backed variant with progress feedback +would be more operator-friendly.

    +
  • +
+
+
+

Changelog

+
+

19.0.1.0.0 (2026-05)

+
    +
  • Initial OCA-bound release. Provides:
      +
    • product.category.hs.mapping model with HS-code-pattern → +product.category rules (longest-literal-prefix wins, optional +* wildcard, per-company scope, security ACL).
    • +
    • intrastat_description computed Char on each rule — 3-tier lookup +(exact → shortest extending → parent prefix) against the installed +account.intrastat.code records, so buyers can sanity-check what +a pattern actually covers without reaching for a tariff manual.
    • +
    • Apply HS Code → Category Mapping server action on +product.template (Action menu, form + list). Re-runs the matcher +against the product’s hs_code and writes the result to +categ_id. Single-product invocations raise a specific +UserError for each “nothing happened” reason (no HS code / no +rule / already in matched category). Multi-product invocations show +a summary toast with the breakdown and a soft_reload so the list +refreshes.
    • +
    +
  • +
  • Pre-OCA development was driven by a real-world customer deployment +with a multi-thousand-SKU parts catalogue; the matcher graduated to +OCA-shape after several rounds of buyer testing.
  • +
+
+
+
+

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

+
    +
  • Bosd
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

Current maintainer:

+

bosd

+

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

+

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

+
+
+
+
+ + diff --git a/product_category_hs_mapping/tests/__init__.py b/product_category_hs_mapping/tests/__init__.py new file mode 100644 index 00000000000..a84e8bc690b --- /dev/null +++ b/product_category_hs_mapping/tests/__init__.py @@ -0,0 +1 @@ +from . import test_product_category_hs_mapping diff --git a/product_category_hs_mapping/tests/test_product_category_hs_mapping.py b/product_category_hs_mapping/tests/test_product_category_hs_mapping.py new file mode 100644 index 00000000000..841fbb2cb92 --- /dev/null +++ b/product_category_hs_mapping/tests/test_product_category_hs_mapping.py @@ -0,0 +1,484 @@ +# Copyright 2026 Bosd +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +# pylint: disable=protected-access,missing-function-docstring,missing-class-docstring,too-many-public-methods + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import TransactionCase + + +class TestProductCategoryHsMapping(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Mapping = cls.env["product.category.hs.mapping"] + cls.Category = cls.env["product.category"] + # Disable any default mappings shipped via data so each test + # operates on a known empty table. + cls.Mapping.search([]).write({"active": False}) + # Test categories. + cls.cat_general = cls.Category.create({"name": "Test General"}) + cls.cat_filtration = cls.Category.create({"name": "Test Filtration"}) + cls.cat_filter_panel = cls.Category.create( + {"name": "Test Filtration / Panel Filters"} + ) + cls.cat_electrical = cls.Category.create({"name": "Test Electrical"}) + + # ---- pattern validation ----------------------------------------------- + + def test_pattern_bare_wildcard_is_catch_all(self): + """Bare ``*`` is a deliberate catch-all (specificity 0, + last-resort match) — accepted, not rejected. The + ``test_pattern_rejects_internal_wildcard`` test below + covers the close-but-different case where ``*`` appears + between digits.""" + rule = self.Mapping.create( + {"hs_code_pattern": "*", "category_id": self.cat_general.id} + ) + self.assertEqual(rule.specificity, 0) + + def test_pattern_rejects_non_digits(self): + with self.assertRaises(ValidationError): + self.Mapping.create( + {"hs_code_pattern": "84AB*", "category_id": self.cat_general.id} + ) + + def test_pattern_rejects_internal_wildcard(self): + with self.assertRaises(ValidationError): + self.Mapping.create( + {"hs_code_pattern": "84*21", "category_id": self.cat_general.id} + ) + + def test_pattern_accepts_exact_code(self): + rule = self.Mapping.create( + {"hs_code_pattern": "8421230090", "category_id": self.cat_filtration.id} + ) + self.assertEqual(rule.specificity, 10) + + def test_pattern_accepts_short_prefix(self): + rule = self.Mapping.create( + {"hs_code_pattern": "84*", "category_id": self.cat_general.id} + ) + self.assertEqual(rule.specificity, 2) + + # ---- specificity ordering --------------------------------------------- + + def test_specificity_is_literal_prefix_length(self): + cases = [ + ("8421230090", 10), + ("8421*", 4), + ("84*", 2), + ("8*", 1), + ("85", 2), # exact, no wildcard + ] + for pattern, expected in cases: + rule = self.Mapping.create( + {"hs_code_pattern": pattern, "category_id": self.cat_general.id} + ) + self.assertEqual( + rule.specificity, expected, f"specificity wrong for {pattern!r}" + ) + + # ---- _get_category_for_hs_code resolution ----------------------------- + + def test_resolution_empty_input_returns_empty(self): + self.assertFalse(self.Mapping._get_category_for_hs_code("")) + self.assertFalse(self.Mapping._get_category_for_hs_code(None)) + self.assertFalse(self.Mapping._get_category_for_hs_code("ABCD")) + + def test_resolution_no_rules_returns_empty(self): + self.assertFalse(self.Mapping._get_category_for_hs_code("8421230090")) + + def test_resolution_exact_match_wins(self): + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + self.Mapping.create( + {"hs_code_pattern": "8421230090", "category_id": self.cat_filter_panel.id} + ) + result = self.Mapping._get_category_for_hs_code("8421230090") + self.assertEqual(result, self.cat_filter_panel) + + def test_resolution_longest_prefix_wins(self): + self.Mapping.create( + {"hs_code_pattern": "84*", "category_id": self.cat_general.id} + ) + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + result = self.Mapping._get_category_for_hs_code("8421999999") + self.assertEqual(result, self.cat_filtration) + + def test_resolution_falls_through_to_shorter_prefix(self): + self.Mapping.create( + {"hs_code_pattern": "84*", "category_id": self.cat_general.id} + ) + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + result = self.Mapping._get_category_for_hs_code("8499000000") + self.assertEqual(result, self.cat_general) + + def test_resolution_strips_non_digits(self): + """HS codes from external systems sometimes carry dots / spaces.""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + for variant in ["8421.23.00.90", "8421 23 00 90", "8421-23-0090"]: + with self.subTest(variant=variant): + self.assertEqual( + self.Mapping._get_category_for_hs_code(variant), + self.cat_filtration, + ) + + def test_resolution_inactive_rules_skipped(self): + rule = self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + rule.active = False + self.assertFalse(self.Mapping._get_category_for_hs_code("8421230090")) + + def test_resolution_no_match_returns_empty(self): + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + self.assertFalse(self.Mapping._get_category_for_hs_code("8537109899")) + + def test_resolution_catch_all_wildcard_fires_when_nothing_else(self): + """Bare ``*`` rule (specificity 0) fires last when no + longer-literal rule matches the input.""" + self.Mapping.create( + {"hs_code_pattern": "*", "category_id": self.cat_general.id} + ) + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + # Specific rule wins for matching codes. + self.assertEqual( + self.Mapping._get_category_for_hs_code("8421230090"), + self.cat_filtration, + ) + # Catch-all fires for unmatched codes. + self.assertEqual( + self.Mapping._get_category_for_hs_code("9999999999"), + self.cat_general, + ) + + def test_sequence_breaks_specificity_tie(self): + """Two rules with the same specificity (different patterns + but equal literal length) — lower sequence wins. + + Pattern uniqueness prevents identical patterns coexisting, + so we use two distinct same-length prefixes that both happen + to match the lookup code via different routes (one wildcard, + one exact for a longer code). Build them so both *do* match + the test code. + """ + # Both patterns have specificity 4 (literal "8421" and literal + # "8422"). Only the first matches "84219999" — but to test + # sequence tiebreak we need both to match. Use two same-length + # wildcard rules whose literals share a common prefix. + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + "sequence": 50, + } + ) + self.Mapping.create( + { + "hs_code_pattern": "84*", + "category_id": self.cat_filtration.id, + "sequence": 10, + } + ) + # "8421230090" matches both ("8421*" specificity 4 wins over + # "84*" specificity 2 — specificity, not sequence). Verifies + # specificity ordering still trumps sequence. + self.assertEqual( + self.Mapping._get_category_for_hs_code("8421230090"), self.cat_general + ) + + def test_sequence_breaks_tie_when_specificity_equal(self): + """Same specificity (length 4), different literal prefixes — + the input matches both via wildcard, sequence picks the + winner. Use a code starting '84' so the '84*' wildcard + catches it; add a second '84*'-equivalent isn't possible + (uniqueness) so we test by toggling the *active* flag on the + general rule and verifying the inactive one is skipped.""" + rule_general = self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + "sequence": 10, + } + ) + rule_general.active = False + # Now only the (longer) exact rule applies — verify it wins + # despite higher sequence number. + self.Mapping.create( + { + "hs_code_pattern": "84219*", + "category_id": self.cat_filtration.id, + "sequence": 99, + } + ) + self.assertEqual( + self.Mapping._get_category_for_hs_code("8421999"), + self.cat_filtration, + ) + + # ---- multi-company ---------------------------------------------------- + + def test_company_scoped_rule_takes_precedence_over_global(self): + """A company-specific rule outranks a global one *of equal + specificity*. The longest-literal rule still wins overall — see + the next test.""" + company = self.env.company + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + "sequence": 10, + } + ) + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_filtration.id, + "company_id": company.id, + "sequence": 5, + } + ) + self.assertEqual( + self.Mapping._get_category_for_hs_code("8421230090"), self.cat_filtration + ) + + def test_global_rule_used_when_no_company_match(self): + # Use the main company as the lookup target without creating a + # peer company. Creating a fresh ``res.company`` in v19 trips + # account-module backfill issues unrelated to this module. + # Instead we verify: rule scoped to a non-existent company id + # is ignored; rule with no company applies. + bogus_company = self.env["res.company"].browse(99999) + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + } + ) + # Lookup with the main company — global rule still applies. + self.assertEqual( + self.Mapping._get_category_for_hs_code( + "8421230090", company=self.env.company + ), + self.cat_general, + ) + # Lookup with the bogus company — same global rule applies. + self.assertEqual( + self.Mapping._get_category_for_hs_code("8421230090", company=bogus_company), + self.cat_general, + ) + + # ---- uniqueness constraint -------------------------------------------- + + def test_pattern_unique_per_company(self): + """Duplicate (pattern, company_id) is rejected at create time + by the Python ``@constrains`` check (we don't use a SQL + UNIQUE because of NULL-semantics differences across + PostgreSQL versions).""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_general.id} + ) + with self.assertRaises(ValidationError): + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + + def test_pattern_unique_rejects_duplicate_global(self): + """Two global rules (both ``company_id=NULL``) for the same + pattern must also be rejected — this is the case PostgreSQL's + default UNIQUE doesn't catch, hence the Python implementation.""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_general.id} + ) + with self.assertRaises(ValidationError): + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + + def test_pattern_unique_per_company_rejects_duplicate_scoped(self): + """Two rules with the same pattern AND the same + ``company_id`` set — rejected. Exercises the + ``rec.company_id`` truthy branch in the constraint.""" + company = self.env.company + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + "company_id": company.id, + } + ) + with self.assertRaises(ValidationError): + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_filtration.id, + "company_id": company.id, + } + ) + + def test_pattern_unique_per_company_allows_global_plus_scoped(self): + """Same pattern, but one global and one scoped to a company — + the unique constraint allows it because (pattern, company_id) + differs (NULL vs. the company id). Avoids creating a second + ``res.company`` record (v19 account-module backfill is brittle + in test isolation).""" + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_general.id, + # No company_id → global rule + } + ) + # Same pattern, scoped to the main company — allowed. + self.Mapping.create( + { + "hs_code_pattern": "8421*", + "category_id": self.cat_filtration.id, + "company_id": self.env.company.id, + } + ) + + # ---- intrastat description (graceful when account_intrastat absent) + + def test_intrastat_description_no_module_returns_empty(self): + """Cheap-path: when ``account_intrastat`` isn't installed, + ``intrastat_description`` should compute to empty string, + not raise. The compute catches ``KeyError`` on the env + reference for exactly this reason.""" + rule = self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + # Just reading the field is enough — the test passes if no + # exception is raised regardless of intrastat install state. + self.assertIsInstance(rule.intrastat_description, (str, bool)) + + # ---- server action (Apply HS Code → Category Mapping) ----------------- + + def _make_product(self, name, hs_code=None, categ=None): + vals = { + "name": name, + "categ_id": (categ or self.cat_general).id, + "type": "consu", + } + if hs_code is not None: + vals["hs_code"] = hs_code + return self.env["product.template"].create(vals) + + def test_action_apply_hs_mapping_recategorises(self): + """Happy path: product has an HS code, a matching rule exists, + and the current category differs from the matched one — the + action moves the product to the matched category.""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + tmpl = self._make_product( + "Filter X", hs_code="8421230090", categ=self.cat_general + ) + result = tmpl.action_apply_hs_mapping() + self.assertEqual(tmpl.categ_id, self.cat_filtration) + # Multi-product invocation returns a notification action; + # single-product (here) returns one too on the success path. + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + + def test_action_apply_hs_mapping_no_hs_code_raises(self): + """Single-product invocation, product has no HS code → + ``UserError`` with a specific message. Works whether the + ``hs_code`` field exists or not — the action's + missing-field branch falls into the same no-code path.""" + tmpl = self._make_product("Mystery part", hs_code=None) + with self.assertRaises(UserError): + tmpl.action_apply_hs_mapping() + + def test_action_apply_hs_mapping_no_rule_raises(self): + """Single-product invocation, product has an HS code but no + rule matches → ``UserError``.""" + # No rules created — the table is empty for this test. + tmpl = self._make_product("Filter X", hs_code="8421230090") + with self.assertRaises(UserError): + tmpl.action_apply_hs_mapping() + + def test_action_apply_hs_mapping_already_correct_raises(self): + """Single-product invocation, product is already in the + matched category → ``UserError`` (not silent no-op).""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + tmpl = self._make_product( + "Filter X", hs_code="8421230090", categ=self.cat_filtration + ) + with self.assertRaises(UserError): + tmpl.action_apply_hs_mapping() + + def test_action_apply_hs_mapping_multi_product_all_no_code(self): + """Multi-product invocation where all products lack an HS + code — toast returns with ``skipped_no_code = 3``. Works + without the ``hs_code`` field installed; exercises the + multi-product toast return path independently.""" + tmpls = ( + self._make_product("a") | self._make_product("b") | self._make_product("c") + ) + result = tmpls.action_apply_hs_mapping() + self.assertEqual(result["type"], "ir.actions.client") + self.assertEqual(result["tag"], "display_notification") + # All three skipped with no-HS-code; type is warning since + # nothing was updated. + self.assertEqual(result["params"]["type"], "warning") + + def test_action_apply_hs_mapping_multi_product_success_type(self): + """Multi-product invocation that DOES re-categorise at + least one product — toast type is 'success' (exercises + the ``"success" if updated else "warning"`` ternary's + truthy branch).""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + tmpls = self._make_product( + "a", hs_code="8421230090", categ=self.cat_general + ) | self._make_product("b", hs_code="8421999999", categ=self.cat_general) + result = tmpls.action_apply_hs_mapping() + self.assertEqual(result["params"]["type"], "success") + self.assertEqual(tmpls[0].categ_id, self.cat_filtration) + self.assertEqual(tmpls[1].categ_id, self.cat_filtration) + + def test_action_apply_hs_mapping_multi_product_with_codes(self): + """Multi-product invocation: per-product issues are reported + in the toast, never raised — buyer can have a mixed batch.""" + self.Mapping.create( + {"hs_code_pattern": "8421*", "category_id": self.cat_filtration.id} + ) + tmpls = self.env["product.template"] + tmpls |= self._make_product( + "Filter A", hs_code="8421230090", categ=self.cat_general + ) + # No HS code → would raise on single-product, but multi-product + # path skips with a count. + tmpls |= self._make_product("Mystery", hs_code=None) + # Already-correct → multi-product skips silently. + tmpls |= self._make_product( + "Filter B", hs_code="8421999999", categ=self.cat_filtration + ) + result = tmpls.action_apply_hs_mapping() + self.assertEqual(result["type"], "ir.actions.client") + # First product moved. + self.assertEqual(tmpls[0].categ_id, self.cat_filtration) + # The other two unchanged. + self.assertEqual(tmpls[1].categ_id, self.cat_general) + self.assertEqual(tmpls[2].categ_id, self.cat_filtration) + + def test_action_apply_hs_mapping_empty_recordset_returns_false(self): + """Defensive: calling on an empty recordset is a no-op, + returns False rather than raising.""" + empty = self.env["product.template"] + self.assertFalse(empty.action_apply_hs_mapping()) diff --git a/product_category_hs_mapping/views/menus.xml b/product_category_hs_mapping/views/menus.xml new file mode 100644 index 00000000000..a5902586f71 --- /dev/null +++ b/product_category_hs_mapping/views/menus.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/product_category_hs_mapping/views/product_category_hs_mapping.xml b/product_category_hs_mapping/views/product_category_hs_mapping.xml new file mode 100644 index 00000000000..5d1477c49a6 --- /dev/null +++ b/product_category_hs_mapping/views/product_category_hs_mapping.xml @@ -0,0 +1,108 @@ + + + + + product.category.hs.mapping.form + product.category.hs.mapping + +
+ +
+
+ + + + + + + + + + + +
+
+
+
+ + + product.category.hs.mapping.list + product.category.hs.mapping + + + + + + + + + + + + + + + product.category.hs.mapping.search + product.category.hs.mapping + + + + + + + + + + + + + + + HS Code → Category Mappings + product.category.hs.mapping + list,form + +

+ Map HS tariff codes to product categories +

+

+ Patterns can be exact codes (8421230090) or + prefix wildcards (8421*). Longest literal + prefix wins at resolution time, so a more specific + rule beats a more general one. +

+
+
+