Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions product_category_hs_mapping/README.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/OCA/product-attribute/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 <https://github.com/OCA/product-attribute/issues/new?body=module:%20product_category_hs_mapping%0Aversion:%2019.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.

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 <https://odoo-community.org/page/maintainer-role>`__:

|maintainer-bosd|

This module is part of the `OCA/product-attribute <https://github.com/OCA/product-attribute/tree/19.0/product_category_hs_mapping>`_ project on GitHub.

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
1 change: 1 addition & 0 deletions product_category_hs_mapping/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
33 changes: 33 additions & 0 deletions product_category_hs_mapping/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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",
],
}
30 changes: 30 additions & 0 deletions product_category_hs_mapping/data/server_action_recategorise.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2026 Bosd
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<!--
Server action: re-run the HS-code matcher against the selected
product(s) and write the result to ``categ_id``.

Bound to ``product.template`` so it shows up in the Action
(extra-context) menu on the product form AND on the product
list (multi-select). The variant model ``product.product``
isn't bound — buyers re-categorise at the template level since
``hs_code`` lives on the template; if a future customer needs
per-variant tariff codes, mirror this entry on
``model_product_product``.

``noupdate="1"`` so deployers can rebind the action to a
different group / hide it without losing the change on
upgrade. The ``binding_*`` fields are what surface the entry
in the Action menu.
-->
<odoo noupdate="1">
<record id="action_apply_hs_mapping" model="ir.actions.server">
<field name="name">Apply HS Code → Category Mapping</field>
<field name="model_id" ref="product.model_product_template" />
<field name="binding_model_id" ref="product.model_product_template" />
<field name="binding_view_types">form,list</field>
<field name="state">code</field>
<field name="code">action = records.action_apply_hs_mapping()</field>
</record>
</odoo>
Loading
Loading