Skip to content

[19.0][ADD] product_category_hs_mapping#2292

Open
bosd wants to merge 1 commit into
OCA:19.0from
bosd:19.0-add-product_category_hs_mapping
Open

[19.0][ADD] product_category_hs_mapping#2292
bosd wants to merge 1 commit into
OCA:19.0from
bosd:19.0-add-product_category_hs_mapping

Conversation

@bosd
Copy link
Copy Markdown
Contributor

@bosd bosd commented May 9, 2026

Summary

A small bridge module between HS codes (managed by OCA account_intrastat) and Odoo product categories. When an integration sets product.template.hs_code from an EDI feed / cXML cart / OCI punchout / REST enrichment, the matcher resolves the HS code to a product.category so the product lands in the right tree.

mapped = self.env["product.category.hs.mapping"]._get_category_for_hs_code(
    "84314100",
    company=self.company_id,
)
if mapped:
    template.categ_id = mapped

What's in the box

  • product.category.hs.mapping model: HS-code-pattern → product.category rules

    • Longest literal prefix wins (8431200080 matches a rule for 84312000)
    • Optional trailing * wildcard (purely visual; 84312000 and 84312000* behave identically)
    • Bare * accepted as a catch-all (specificity 0, last-resort match)
    • Per-company scope (rules with company_id set match only for that company; rules without apply globally)
    • Standard security ACL
  • intrastat_description computed Char on each rule: 3-tier lookup against account.intrastat.code records (exact → shortest extending → parent prefix). Lets buyers eyeball whether 8539299* mapped to "Hydraulics" is wrong (it is — it's "Filament lamps") without consulting a tariff manual. Cheap when account_intrastat isn't installed (env reference catches KeyError).

  • Apply HS Code → Category Mapping server action on product.template (Action menu, form + list views). Re-runs the matcher against the product's hs_code and writes the result to categ_id. Single-product invocations raise a specific UserError per "nothing happened" reason (no HS code / no rule / already in matched category). Multi-product invocations show a summary toast with the breakdown.

What's not in the box

No bootstrap data. Each customer's mapping table reflects their own catalogue scope (different HS chapters, different category trees, different language preferences). The adoption pattern is documented in CONTEXT.md: install this matcher module from OCA, then ship a tiny private module with your own category tree + HS rules.

Why this is distinct from existing OCA work

  • account_intrastat — the source of HS codes themselves. Manages account.intrastat.code records mapped to product.template.hs_code. Doesn't categorise.
  • account_intrastat_oss and friends — declaration-level consumers of those codes.
  • 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 drove the design

  • Punchout cart imports: every line in the cart enters with hs_code from the supplier's cXML / OCI payload; 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): commodityCode from GET /items-style responses populates the category at the same time.
  • EDI imports (Pricat, custom CSV): an import wizard sets hs_code first, category after, using the matcher.
  • Manual buyer override: the bundled server action lets a buyer re-run the resolver on any selected product — useful for retroactively classifying a pre-existing catalogue, or for debugging a rule that doesn't match what they expected.

Tests

tests/test_product_category_hs_mapping.py covers:

  • Pattern shape validation (digits + optional trailing *, bare *, no internal wildcards, no non-digits)
  • Per-company uniqueness constraint (Python-side, not SQL UNIQUE — handles NULL company_id correctly)
  • Specificity ordering (longest literal wins)
  • Wildcard equivalence (8431 and 8431* match the same set)
  • Catch-all * last-resort behaviour
  • Per-company scope (global vs. company-specific rules)
  • Garbage input safety (empty / non-digit codes return empty category, never raise)
  • Server action: re-categorise + UserError per "nothing happened" reason

Deployment notes

  • Pre-OCA development was driven by a real-world deployment with a multi-thousand-SKU parts catalogue; the matcher graduated to OCA-shape after several rounds of buyer testing.
  • Server action is bound to product.template only (not product.product) — hs_code lives on the template; per-variant tariff codes are rare in OEM-parts catalogues. The variant binding is on the ROADMAP.

Roadmap items (in readme/ROADMAP.md)

  • Auto-populate stub rules for unknown HS codes + assignee activity (multi-company aware)
  • Per-line / variant override (bind action on product.product)
  • Bulk re-categorisation cron / queued-job for catalogue-wide refreshes

@OCA-git-bot OCA-git-bot added series:19.0 mod:product_category_hs_mapping Module product_category_hs_mapping labels May 9, 2026
@bosd bosd force-pushed the 19.0-add-product_category_hs_mapping branch from b5f84ce to 4411034 Compare May 9, 2026 21:09
Map customs HS codes to Odoo product categories. Used by EDI /
supplier integrations to auto-categorise newly imported products.

- product.category.hs.mapping model: HS-code-pattern →
  product.category rules. Longest literal prefix wins
  (a rule for 84312000 matches both 84312000 and
  8431200080); optional trailing * wildcard is purely
  visual; bare * accepted as a catch-all (specificity 0,
  last-resort match). Per-company scope (rules without
  company_id apply globally; rules with company_id
  match only that company). Standard security ACL.

- intrastat_description computed Char on each rule:
  3-tier lookup against account.intrastat.code records
  (exact → shortest extending → parent prefix). Lets buyers
  eyeball whether 8539299* mapped to 'Hydraulics' is
  wrong (it is — it's 'Filament lamps') without consulting a
  tariff manual. Cheap when account_intrastat (OCA) isn't
  installed: env reference catches KeyError, returns empty.

- 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 per 'nothing happened' reason
  (no HS code / no rule / already in matched category).
  Multi-product invocations show a summary toast with the
  breakdown plus a soft_reload to refresh the list.

- No bootstrap data shipped — each customer's mapping table
  reflects their own catalogue scope (different HS chapters,
  different category trees). Adoption pattern documented in
  CONTEXT.md: install the matcher from OCA, then ship a
  small private module with your own category tree + HS
  rules.

- Translations: nl + es + canonical .pot (45 messages).

Closes the gap between OCA account_intrastat (which
manages HS codes themselves) and product categorisation —
neither account_intrastat nor product_category_active maps
HS to product.category.

Pre-OCA development was driven by a real-world deployment
with a multi-thousand-SKU parts catalogue; the matcher
graduated to OCA-shape after several rounds of buyer
testing.
@bosd bosd force-pushed the 19.0-add-product_category_hs_mapping branch from 4411034 to 41f9c66 Compare May 10, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

mod:product_category_hs_mapping Module product_category_hs_mapping series:19.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants