dodil/erp-method

modules/product/module.yaml

# Products & price lists: the module source.
#
# PROVENANCE. Written 2026-10-18 for milestone M1 (Lead to Cash), conveyor steps 1-2 (spec + BPMN), from:
#   - the pattern (pattern.md) and the shared vocabulary (shared.yaml);
#   - what already exists: brick-quote 0.2.0 keeps a price book (quote_prices) "until a second brick needs to
#     write prices" (its README). Sales order and invoicing are that second and third brick, so the price book
#     moves here and quote reads it through a seam (PRD-20);
#   - APQC PCF v8.0: 11740 (2.1.4 Manage product and service master data), 20593 (3.3.3 Develop and manage
#     pricing), 11494 (3.3.3.12 Determine customer segment specific pricing), 11497 (3.3.3.9 Communicate and
#     implement price changes);
#   - the end-to-end process and customization level of a mainstream SME suite's Sales app (products,
#     price lists and price rules), as a parity reference only: the data model is our own.
# SIGNED OFF BY THE CUSTOMER: Seemo, DODIL (reference customer), 2026-10-18, after the M1 review page. Each
# process is signed on its own (signoff/product/<process>.json, erp-compose signoff verify); the decisions
# settled on the review are under `decisions:` below. This is the reference vocabulary a build starts from.
#
# Three decisions an implementer must not undo:
#   - a product's id is the customer's own code (a SKU), a string, fixed once set; the name can change
#   - a price is never edited in place: a new row with a later valid_from supersedes it, so a document
#     priced last year re-prices identically and an audit can say what the price WAS on a date
#   - a product carries a TAX CATEGORY, never a tax code or a rate: which code applies depends on the
#     customer and the date, and that is the tax module's decision (tax.yaml TAX-04)
#
# VERSION 2 (2026-10-22, wave 2, DRAFT for sign-off: the product process v3 -> v4 and the price list process
# v4 -> v5 need re-signing; erp-brick-product 0.1.0 builds version 1). "Up to SAP standard" for units of measure
# (SAP MM/SD: MARM, MEAN, condition pricing unit, rounding), keeping the exact-fraction model:
#   - several GTINs per unit, one main and the others (SAP MEAN), each unique across the catalogue (PRD-27)
#   - per unit: gross and net weight, volume and length x width x height, each with its unit (SAP MARM) (PRD-28)
#   - unit roles base, sales, purchase, issue, production, warehouse and delivery (PRD-29); per role a minimum
#     quantity and a multiple ("full pallets only", "deliver in 6s"), answered by GET /products/{product_id}/rounding
#     and enforced as a gate by the module that orders, sells or delivers (PRD-30, PRD-32)
#   - a price row carries a PRICING QUANTITY (2.4500 per 100 C62); find_price answers the price per pricing quantity
#     of its pricing unit and the exact line amount (PRD-31; shared.yaml units.uom.pricing)
#   - the Odoo slice for units, packagings and barcodes (PRD-33, `migration:`); catch weight and a lot's own
#     conversion are designed in inventory and reserved here (`deferred:`, milestone M7); `sap_parity:` at the end
#
# VERSION 3 (2026-10-22, SAP full-scope pass, DRAFT for sign-off: the product process v4 -> v5 needs re-signing):
#   - weights, volume and dimensions per unit are DATED rows (prod_unit_measure), never frozen: they are not part of
#     valuation (SAP MARM); a correction is a new row from today, and every document keeps the copy it took when it
#     was written (PRD-28). A unit's conversion, packaging level and decimals stay frozen once used
#   - product types (SAP MTART) decide kind, stocking, what must be set to activate and the code format (PRD-35)
#   - classes and characteristics for search and variants (SAP classification, class type 001) (PRD-36)
#   - per-site data (SAP MARC): site status, procurement type, lead times and the replenishment fields inventory
#     plans with (PRD-37); the MRP-run fields are reserved for M7
#   - rounding profiles with thresholds for system-proposed quantities (SAP OWD1) (PRD-38)

# Version 6 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
#   - the price answer's line carries category_code (the product template's category), so sales-order's category
#     conditions match (SO-30); sales-order is listed as a caller of GET /products/{product_id}/convert
#   - GET /price-lists/{price_list_id}/prices?on=: a whole published price list in force on a date, for a register's
#     snapshot (POS-30)
#   - PROCESS (set_site_data): procurement_type make and planning_method mrp are accepted when manufacturing is in
#     the composition (gate manufacturing_installed replaces not_reserved_m7); without it they stay refused
#   - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
# Version 8 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 7 stays the signed one until it is signed):
#   - seams only, no process change: GET /products/{product_id} and .../convert listed for manufacturing and pos;
#     .../round-proposal for manufacturing; PRD-31's price for timesheets and pos; tool_names gains
#     price_product_lines (POST /prices/batch, as erp-brick-product serves it)
#
# Version 7 (2026-10-25, DRAFT for sign-off; a seam, no process change):
#   - POST /prices/batch: many lines in, one answer per line out — the price GET /products/{product_id}/price gives,
#     and on request the delivery rounding (GET .../rounding) and the conversion (GET .../convert) of the same line, on
#     the same date. sales-order prices, re-prices and confirms an order with ONE call instead of one per line per
#     route; product reads each product, list and rule once per batch (PRD-41). Measured 2026-10-25 on the
#     Lead-to-Cash composition: drafting an order cost product ~14 statements per line, confirming ~6 more per line
#
id: product
name: Products & price lists
short: Products
version: 8
maturity: tested                      # erp-brick-product 0.1.0 implements version 1; versions 2 and 3 are drafts for sign-off
summary: >-
  One list of what you sell, with its unit, how it is billed and how it is taxed, and the prices each
  customer pays: list prices per currency, customer price lists, quantity breaks and dated price changes,
  so a quote, an order and an invoice all charge the same price without anyone re-typing it.

decisions:                            # settled by the reference customer on the M1 review, 2026-10-18
  - decided: "the price book moves from brick-quote to this module; quote asks find_price (PRD-20) after a one-way migration of quote_prices, and stops writing them"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "a unit price is a RATE, NUMERIC(19,4) (shared.yaml money.rates); a line total is NUMERIC(18,2). Quote's list_price widens from (18,2) in the migration"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "no variants in M1: a size, colour or term is a product of its own (known_gaps)"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - decided: "the AI duplicate check (ai_check_duplicate) is on by default, mode suggest"
    by: "Seemo, DODIL (reference customer)"
    date: "2026-10-18"
  - proposed: "v2: a unit carries one main GTIN and any number of others; a GTIN is unique across every product and unit, and a retired GTIN is never given to another product"
    status: "for sign-off (wave 2)"
  - proposed: "v2: every per-unit attribute (conversion, GTINs, weights, volume, dimensions, roles, rounding) follows the template -> SKU rule: set on the template, inherited by every SKU, a SKU's own row wins, frozen once a document or stock uses it (inherited rows included); measures are per ONE of the unit, and a document copies them when it is written"
    status: "for sign-off (wave 2); v3 narrows the freeze, see below"
  - proposed: "v3: weights, volume and dimensions are NOT frozen once used (they are not valuation): they are dated rows, a correction is a new row from today or later, and documents keep the values they copied; the conversion, the packaging level and the quantity decimals stay frozen once used"
    status: "for sign-off (SAP full-scope pass)"
  - proposed: "v3: a product has a type (trading goods, finished, raw material, service, non-stock, packaging); the type decides kind, whether it is stocked by default, which unit roles must be set to activate, and its code format; it changes only while nothing has used the product"
    status: "for sign-off (SAP full-scope pass)"
  - proposed: "v3: per-site data (site status, procurement type, lead times, reorder point, safety stock, min / max, lot size, rounding profile) lives here, one row per product and site from a date after today; inventory plans from it and never keeps its own copy"
    status: "for sign-off (SAP full-scope pass)"
  - proposed: "v2: rounding (minimum and multiple) is product master data per role; product only ANSWERS whether a quantity meets it; purchasing gates it (overridable by a second person), sales-order warns or blocks per its owner control (default warn)"
    status: "for sign-off (wave 2)"
  - proposed: "v2: a price row's pricing_quantity is a positive whole number (1 to 100000), default 1; find_price never divides a price per 100 down to a per-piece rate"
    status: "for sign-off (wave 2)"

facets:
  domain: master-data
  industries: [any]
  customer_words:
    - "our product list"
    - "price list"
    - "different prices for different customers"
    - "volume discounts"
    - "the price goes up on the 1st of January"
    - "SKUs"
    - "case and pallet barcodes"
    - "price per 100"
    - "we only buy full pallets"
    - "pack weights and dimensions"
    - "raw materials and trading goods"
    - "search by material and size"
    - "reorder point per warehouse"
  replaces: [a spreadsheet of prices, the price tab in the accounting package, Odoo Products and Pricelists]
  pillars: [sql, vector]          # vector: the duplicate-product check (AI step) compares descriptions
  first_module_fit: foundation
  depends_on:
    - module: business-partner
      why: a customer price list is assigned to a party, by bp_id
      required: recommended
    - module: tax
      why: the tax categories a product may carry are the tax module's list
      required: recommended
  feeds: [crm, sales-order, ar, purchasing]
  owns_master_data: [product]
  references_master_data: [business_partner, tax_category]

actors:
  - id: catalogue_manager
    name: Whoever keeps the product list (operations, the owner)
    does: adds products with their type, sets their unit, category, tax category and billing type, classifies them, archives what is no longer sold
    sees: every product
    permissions: [product:product:read, product:product:write, product:product:archive, product:category:write, product:class:write, product:settings:write]
  - id: planner
    name: Whoever plans stock per site (the buyer, the warehouse manager)
    does: sets per product and site whether it is bought or transferred, its lead times, reorder point, safety stock, min / max and lot size, and blocks a product at one site
    sees: every product and its site data; never cost
    permissions: [product:product:read, product:site:write]
  - id: pricing_manager
    name: Whoever sets prices (the owner, a commercial manager)
    does: keeps price lists and their rules, schedules price changes, assigns customers to price lists
    sees: every product and price
    permissions: [product:product:read, product:price_list:read, product:price_list:write, product:price_list:publish, product:customer_price_list:write, product:settings:write]
  - id: owner
    name: The owner or a finance controller
    does: sets the controls on pricing (whether publishing needs a second person, how far a price may move before it warns)
    sees: every product and price
    permissions: [product:product:read, product:price_list:read, product:settings:controls]
  - id: sales_rep
    name: Sales
    does: looks up a product and the price a customer pays
    sees: active products and the prices of published price lists; never cost
    permissions: [product:product:read, product:price_list:read]
  - id: system
    name: The system
    does: resolves prices on read, checks for duplicates, suggests a new product's details, flags unusual prices, imports a catalogue
    sees: everything, through its own service account
    permissions: [product:product:read, product:price_list:read, product:catalogue:import, product:product:suggest, product:price_list:check, product:migration:read, product:unit:mark_used]

stories:
  # ── person ────────────────────────────────────────────────────────────────────────────────
  - id: PRD-01
    kind: person
    actor: catalogue_manager
    story: As the person who keeps the product list, I want to add a product once with its code, unit and how it is billed, so that sales, orders and invoices all mean the same thing.
    given: no product with code HOST-M
    when: I add HOST-M "Managed hosting, per month", unit MON, category hosting, tax category standard, billing recurring / periodic, invoicing policy ordered
    then: a draft product HOST-M exists; adding a second product with code HOST-M is refused ("HOST-M already exists"); the code is fixed from now on, the name is not
    screen: Products
    path: { process: product.product, choices: {} }
    reference: not built
    customer_varies: the product code format, and whether codes are typed or generated
  - id: PRD-02
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want a product to be sellable only once it is complete, so that nobody quotes something that cannot be invoiced.
    given: a draft product with no tax category
    when: I activate it
    then: refused, naming what is missing (tax category); with a unit, a tax category, a billing type and an invoicing policy it becomes active and appears to sales
    screen: Products
    reference: not built
  - id: PRD-03
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want to archive a product we no longer sell, so that it disappears from new quotes without breaking old documents.
    given: an active product on three issued invoices and one open quote
    when: I archive it
    then: it is no longer offered for new lines; the invoices and the open quote keep their copy of it unchanged; reactivating it is allowed and recorded
    screen: Products
    reference: not built
  - id: PRD-04
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want products in categories, so that a price rule or a report can cover a whole family at once.
    given: categories hosting and services
    when: I move a product to services
    then: from tomorrow, price rules on the services category apply to it; a price asked for today or earlier still uses hosting; documents already priced are unchanged
    screen: Products
    reference: not built
  - id: PRD-23
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want one product counted in several units, each converting exactly to the one we keep stock in, so that purchasing buys packs, sales sells bottles and production consumes litres without anyone converting by hand.
    given: COLA-075 "Cola 0.75 L" with base unit LTR (litre); a bottle (unit C62) is 0.75 LTR; a pack PK24 is 24 bottles
    when: I set PK24 as its purchase unit, the bottle as its sales unit and LTR as its issue unit
    then: 2 packs bought = 48 bottles = 36 LTR in stock; a quantity in any of its units converts exactly (a numerator and a denominator, never a decimal factor); a unit with a different meaning for another product (a pack of 12) is that product's own conversion; a conversion is fixed once a document or stock uses it, and a changed pack size is a new unit
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "Odoo 19 relative units (uom.uom relative_uom_id, packagings merged into units); SAP material master base unit + alternative units (MARM numerator / denominator), order / sales / issue unit"
    customer_varies: which units and packs exist; whether a supplier sells in its own pack (purchasing chooses one of the product's units per supplier)
  - id: PRD-24
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want a product that comes in sizes or colours kept as one template with a variant per combination, so that I describe it once and each variant is its own SKU to price, stock and sell.
    given: template COLA "Cola" with attribute volume (0.33 L, 0.75 L, 1.5 L), category drinks, tax category standard
    when: I generate its variants
    then: three draft products COLA-033, COLA-075 and COLA-150 exist, each its own SKU (product_id) carrying the template's category, tax category, billing and units unless it sets its own; generating again adds only missing combinations; a product with no variants is a template with exactly one product; every price, document line and stock row names a product_id, never a template
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "Odoo product.template / product.product (the variant is the SKU); SAP generic article and variants (retail), configurable material (configure-to-order, later)"
    customer_varies: the attributes (size, colour, volume, material); whether every combination exists or only some
  - id: PRD-25
    kind: person
    actor: catalogue_manager
    story: As the logistics lead, I want each packaging level of a product to carry its own barcode, so that a warehouse scans a case or a pallet and knows how many units it holds.
    given: COLA-075 with the bottle, PK24 and a pallet PALLET80 of 80 packs
    when: I give each level its GTIN
    then: scanning the pallet's GTIN answers COLA-075, unit PALLET80, 1 920 bottles; a GTIN is unique across all products and units
    screen: Products
    reference: "GS1 GTIN per trade item and packaging level; Odoo 19 packaging barcodes"
    customer_varies: whether barcodes are used at all; which levels exist
  - id: PRD-27
    kind: person
    actor: catalogue_manager
    story: As the logistics lead, I want a unit to carry several barcodes, one of them the main one, so that our own case barcode and the supplier's both scan to the same pack.
    given: COLA-075 unit PK24 with main GTIN 05012345000015; the supplier's case carries 04006381333931
    when: I add 04006381333931 to PK24 as an additional GTIN, then try to add it to COLA-150, then make it the main one, then retire it
    then: "scanning either GTIN answers COLA-075, PK24, 24 bottles (18 LTR), and says which is the main one; adding it to COLA-150 is refused (\"04006381333931 is COLA-075 PK24\"); making it main makes 05012345000015 an additional one (a unit has exactly one main GTIN while it has any); a GTIN with a wrong GS1 check digit is refused naming the digit expected; a retired GTIN no longer answers a scan for new documents, still names COLA-075 PK24 on every document that scanned it, and is never given to another product"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP MEAN (several EANs per material and unit, one main EAN, MARM-EAN11); GS1 General Specifications 4.2 (check digit); Odoo product.packaging barcode (16-18), product.uom barcode (19)"
    customer_varies: whether supplier barcodes are kept beside your own; GTIN-8/12/13/14 or the customer's internal codes
  - id: PRD-28
    kind: person
    actor: catalogue_manager
    story: As the logistics lead, I want each unit's weight, volume and dimensions recorded, so that a delivery note shows its weight and a carrier can be booked without measuring the boxes again.
    given: COLA-075 units C62 (bottle), PK24 and PALLET80
    when: I record PK24 at gross 19.2 KGM, net 18.0 KGM, volume 0.027 MTQ, 40 x 27 x 25 CMT, and the bottle at gross 0.79 KGM, net 0.75 KGM
    then: "GET /products/COLA-075 answers each unit with the measures in force today and their units; a net weight above the gross weight is refused; a weight in a unit that is not a mass (a volume in a length) is refused naming the unit's kind; a unit with no measures answers them as unknown, never 0. v3: measures are DATED rows (prod_unit_measure), never frozen by use: after a delivery copied 19.2 KGM, I correct PK24 to 19.6 KGM from today; the delivery keeps 19.2, a delivery written after the correction copies 19.6, and GET /products/COLA-075?on=<an earlier date> answers 19.2; a measure dated before today is refused; a second correction on the same day replaces the first only while no document has copied it, else it is refused (\"a document copied today's row: correct from tomorrow\"); the conversion of PK24 stays frozen"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP MARM (BRGEW gross weight, NTGEW net weight on MARA, GEWEI weight unit, VOLUM / VOLEH volume, LAENG x BREIT x HOEHE / MEABM dimensions per alternative unit; changeable at any time, deliveries copy them into LIPS); Odoo product weight and volume (base unit only)"
    customer_varies: which units need measures (usually the case and the pallet); kg or lb, cm or in
  - id: PRD-29
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want to say which unit each part of the business uses (buying, selling, issuing to work, producing, handling in the warehouse, delivering), so that every screen opens in the unit people actually count in.
    given: COLA-075, base LTR, with units C62, PK24 and PALLET80
    when: I set purchase PK24, sales C62, issue LTR, production LTR, warehouse PK24 and delivery C62
    then: "each role names one of the product's own units (or the base unit); a role naming a unit the product does not have is refused; base and sales are required to activate, the others default to the base unit when unset and say so; a role set on the template COLA is inherited by every variant unless the variant has its own row; a role is a dated row, so changing it is a new row from a later date: the default for new lines only, never a document or a price for today or earlier"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP MARA-MEINS base, MARA-BSTME order unit, MVKE-VRKME sales unit, MARC-AUSME unit of issue, MARC-FRTME production unit, MLGN-LVSME WM unit, MVKE-SCHME delivery unit"
    customer_varies: which roles differ from the base unit; most firms set only purchase and sales
  - id: PRD-30
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want a minimum and a multiple per product and role, so that we buy only full pallets and deliver only in sixes without anyone remembering it.
    given: COLA-075, purchase unit PK24, delivery unit C62
    when: I set the purchase rule "at least 80, in multiples of 80 PK24" (a full pallet) and the delivery rule "multiples of 6 C62" from tomorrow
    then: "two dated unit_role rows exist (on the template where set there, inherited by every variant), each in the unit it was written in; GET /products/COLA-075/rounding?role=purchase&quantity=100&unit_code=PK24 answers {met: false, next_down: 80, next_up: 160, reason: \"COLA-075 is bought in multiples of 80 PK24\"}; 3 PALLET80 meets it (240 PK24); 20 bottles is not a multiple of 6, 24 is; a multiple that does not convert exactly into the asked unit is answered in the rule's own unit; a rule dated today or earlier is refused; product enforces nothing itself: purchasing and sales-order decide what an unmet rule does"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP rounding value and rounding profile (MARC-BSTRF, EINE-RDPRF), minimum order quantity (MARC-BSTMI), minimum delivery quantity and delivery unit (MVKE-LFMNG, MVKE-SCHME)"
    customer_varies: which products have rules; a minimum, a multiple, or both
  - id: PRD-34
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want packs, barcodes, weights, roles and rounding set once on a template and inherited by its variants, with a variant overriding only what differs, so that three volumes of one drink are not three sets of the same data.
    given: "template COLA, base unit LTR, variants COLA-033, COLA-075 and COLA-150; the bottle C62 is per SKU (0.33, 0.75 and 1.5 LTR: litres per bottle differ by variant); on the template PK24 = 24 C62 (relative to the bottle), gross 19.2 KGM, purchase role PK24 in multiples of 80"
    when: "COLA-150 gets its own PK24 row with gross 37.4 KGM, a PK6 = 6 C62 and its own sales role PK6 from tomorrow; then an order for COLA-075 uses PK24; then the template's PK24 conversion is changed, its weight corrected to 19.4 KGM from today, and a GTIN is put on the template"
    then: "COLA-075's PK24 is 18 LTR (24 x 0.75, composed exactly), 19.2 KGM and purchase-rounded to 80 from the template; COLA-150's PK24 is 36 LTR and 37.4 KGM (its own rows win, whole), and it sells in PK6 from tomorrow while the others sell by the bottle; COLA-033 inherits everything but its bottle; after the order, the template's PK24 conversion is frozen (\"PK24 on template COLA is used by sales order SO-2026-0012 for COLA-075\"), COLA-150's own conversion is still editable until it is used; the weight correction is a new dated template row, inherited by COLA-075 and COLA-033 for documents written from now (v3), the order keeps 19.2; the template GTIN is refused (\"COLA has 3 products: a GTIN names one, set it on the SKU\")"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP generic article: variant-specific MARM / MEAN rows over the generic article's; Odoo 19 relative units on product.template"
    customer_varies: which data differ by variant (usually the base conversion, the barcodes and the weights)
  - id: PRD-35
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want each product to have a type (something we resell, something we make, a raw material, a service, a consumable we do not stock), so that the right things are asked before it can be used and each module treats it the right way. v5 (seemo, 2026-10-24) - the type is MANDATORY on every door (by hand, the catalogue import PRD-22, the Odoo slice PRD-33), so a product without one is refused, never defaulted silently.
    given: "types seeded: LEGACY (v5: for products that existed before types were mandatory; carries the product's own kind and stocked_default, sellable and purchasable, no required roles, so nothing changes until a person re-types), TRADING (goods, stocked, sold and bought; needs sales and purchase units), FINISHED (goods, stocked, sold; production fields reserved for M7), RAW (goods, stocked, bought, not sold), SERVICE (service, never stocked), NONSTOCK (goods bought for use, not stocked), PACKAGING (goods, stocked, bought); TRADING has the code format TRD-{seq:5}"
    when: I add a TRADING product without a code, activate it without a purchase unit, add a RAW product to a sales order, change a product's type after a receipt used it, and change a draft's type
    then: "the TRADING product is numbered TRD-00001 from the type's series (claimed with a plain INSERT, never reused, no gap from a refused add); activating it is refused (gate type_requirements, \"TRADING needs a purchase unit\"); the sales order line is refused by product's line data (sellable false: \"RAW products are not sold: change the type or make a TRADING product\"); the type change after use is refused (gate type_unused); the draft's type change is recorded; v5: at creation the product COPIES its type's settings (kind, stocked_default, sellable, valuation_class, required unit roles), so a later edit of the TYPE changes only products created afterwards, never an existing one; change_product_type re-copies the new type's settings onto the product; a product added with no type is refused 422 (gate type_named) on every door; inventory reads stocked_default from GET /products/{product_id} to decide whether to stock it, and an account map may name the type's valuation_class"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP material type (MTART: HAWA trading goods, FERT finished, ROH raw, DIEN service, NLAG non-stock, VERP packaging; T134 field selection, number range, quantity / value update, valuation class); Odoo product type (goods | service | combo) and is_storable"
    customer_varies: which types exist; whether codes are typed or numbered per type
  - id: PRD-36
    kind: person
    actor: catalogue_manager
    story: As the catalogue manager, I want to describe products by characteristics (material, length, thread, colour) in classes, so that anyone can find the right screw by what it is, not by guessing its code.
    given: "characteristics MATERIAL (list: A2, A4, ZINC), LENGTH (number, unit MMT), THREAD (list: M3, M4, M5); class SCREWS with MATERIAL and THREAD required and LENGTH optional; products SCREW-M4-20 and SCREW-M4-30"
    when: I put both in SCREWS, set SCREW-M4-20 to A2, M4, 20 MMT and SCREW-M4-30 to A4, M4, 30 MMT, set LENGTH to 2 CMT, set MATERIAL to BRASS, and search MATERIAL A2 and LENGTH at least 20 MMT
    then: "the search answers SCREW-M4-20 only; 2 CMT is stored as 20 MMT, converted exactly through unit_conversion; BRASS is refused (\"MATERIAL allows A2, A4, ZINC\"); activating a product in SCREWS without THREAD is refused (gate class_complete); a value change is a new dated row from today, so the class a product was in and its values on any date stay answerable; a template's values are inherited by its variants unless a variant sets its own; a template attribute may name a characteristic, so generated variants carry their value (volume 0.75 L)"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP classification (CL01 / CT04 / CL20N, class type 001 material class, search by characteristic values CL30N); Odoo has product attributes for variants only"
    customer_varies: whether classification is wanted at all (usually for technical goods and spare parts)
  - id: PRD-37
    kind: person
    actor: planner
    story: As the person who plans stock, I want to say per site whether a product is bought or transferred from another site, how long that takes, and when to reorder, so that each warehouse replenishes the way it really works.
    given: "COLA-075 at sites WH1 (Leeds) and WH2 (York); purchase unit PK24 (18 LTR)"
    when: "from tomorrow I set WH1: procurement buy, planned delivery 5 days, goods-in 1 day, planning min_max, reorder point 360 LTR, safety stock 90 LTR, max 1 440 LTR, rounding profile PALLET-LAYER; WH2: procurement transfer from WH1, 2 days, planning reorder_point 72 LTR, lot size fixed 144 LTR; and block COLA-075 for sale at WH2 from next Monday"
    then: "each is a dated row (product_id, site_id, valid_from); GET /products/COLA-075/sites/WH1 answers it with quantities in the base unit and in the purchase unit; a reorder point above the max, a safety stock above the reorder point, a supplying site equal to the site, or a row dated today or earlier is refused with the reason; procurement make and planning mrp are refused (\"production planning arrives in M7\"); from Monday GET /products/COLA-075/sites/WH2 answers site_status no_sale and sales-order's line check refuses COLA-075 for delivery from WH2; a site the product has no row for answers planning none, procurement buy, status active"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP plant data (MARC: MMSTA plant status, BESKZ procurement type, SOBSL special procurement (stock transfer), PLIFZ planned delivery time, WEBAZ GR processing time, DISMM MRP type VB / VM / V1, MINBE reorder point, EISBE safety stock, MABST maximum, DISLS lot size EX / FX / HB, RDPRF rounding profile); Odoo reordering rules (stock.warehouse.orderpoint min / max per warehouse)"
    customer_varies: whether more than one site exists; which products are planned at all
  - id: PRD-38
    kind: person
    actor: planner
    story: As the planner, I want a proposed order quantity rounded to full pallets or layers when it is close to one, so that we do not buy 75 packs when a pallet of 80 is cheaper to ship.
    given: "COLA-075 purchase unit PK24 with the purchase rule at least 10 PK24; rounding profile PALLET-LAYER: step 80 PK24 (a pallet) rounding up from 90%, step 10 PK24 (a layer) rounding up from 50%"
    when: inventory's replenishment proposes 75, 47 and 163 PK24 and asks the rounded quantity
    then: "GET /products/COLA-075/round-proposal answers 80 (75 is 94% of a pallet), 50 (no pallet reached; 47 is 4 layers and 70% of a fifth) and 160 (2 pallets; the 3 left are under both thresholds); each answer names the steps applied and is checked against the purchase rule (min 10); a person's own order quantity is never rounded by a profile (it is gated by the purchase rule only, PRD-30); a step that is not a multiple of the purchase rule's multiple is refused when the profile is saved"
    screen: Products
    path: { process: product.product, choices: {} }
    reference: "SAP rounding profile (OWD1, static: threshold value per rounding step; MARC-RDPRF), applied to planned and proposed quantities"
    customer_varies: whether pallet and layer rounding matters (wholesale, bulky goods)
  - id: PRD-05
    kind: person
    actor: pricing_manager
    story: As the person who sets prices, I want a price list per currency with a list price for each product, so that every quote starts from the same number.
    given: a price list LIST-GBP in GBP
    when: I set HOST-M to 120.00 from 2026-11-01
    then: a price row (LIST-GBP, HOST-M, unit MON, valid from 2026-11-01) exists; a second price for the same product and date is refused; an earlier row is not changed
    screen: Price lists
    path: { process: product.price_list, choices: {} }
    reference: "partial: brick-quote 0.2.0 quote_prices holds (price_book_id, product_id, currency, valid_from, list_price)"
  - id: PRD-06
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want rules (a fixed price, a percentage off another list, a quantity break, a date window), so that customer and volume pricing is not a spreadsheet beside the system.
    given: list LIST-GBP with HOST-M at 120.00, and list PARTNER-GBP with the rules, in this order, 1 "from 10 units, 15% off LIST-GBP" and 2 "10% off LIST-GBP for category hosting"
    when: a price is asked for 12 HOST-M on PARTNER-GBP
    then: 102.00 a unit, with the rule that produced it named; for 5 units 108.00; the rules apply in the list's stated order and the first that matches wins
    screen: Price lists
    reference: not built
    customer_varies: whether rules are needed at all; most small firms want one list per currency
  - id: PRD-26
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want to price a product per unit, so that a pack can cost less than the same bottles sold one by one.
    given: LIST-GBP COLA-075 at 1.20 a bottle and 24.00 a PK24; and a rule "all variants of template COLA, 5% off" on PARTNER-GBP
    when: prices are asked for 2 PK24, for 48 bottles, and for 3 bottles of COLA-150 on PARTNER-GBP
    then: 48.00 for the packs (the pack's own row), 57.60 for the bottles (the bottle's row), and COLA-150's bottle price less 5%; a unit with no row of its own is priced from the base-unit row by its exact conversion, and the answer says so; quantity breaks compare quantities in the base unit
    screen: Price lists
    reference: "SAP condition records per sales unit; Odoo pricelist per product variant"
    customer_varies: whether packs are priced on their own or always from the single unit
  - id: PRD-31
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want to state a price per 100 or per 1000 units, so that a screw costing a fraction of a penny is priced exactly, the way the supplier and the customer quote it.
    given: SCREW-M4, base unit C62, on LIST-GBP
    when: I set 2.4500 per 100 C62 from tomorrow, and a price is then asked for 250 C62 and for 3 BX500 (a box of 500)
    then: "the row is (LIST-GBP, SCREW-M4, C62, valid_from) with pricing_quantity 100; find_price answers {unit_price: \"2.4500\", pricing_quantity: 100, pricing_unit_code: C62, amount: \"6.13\"} for 250 (6.125 rounded once, half up) and {unit_price: \"2.4500\", pricing_quantity: 100, pricing_unit_code: C62, amount: \"36.75\"} for 3 BX500 (1 500 C62), the pricing unit kept as the row's because the box has no row of its own; a pricing quantity of 0, a fraction or above 100000 is refused; a percentage rule applies to the price per pricing quantity; quantity breaks still compare in the base unit"
    screen: Price lists
    path: { process: product.price_list, choices: {} }
    reference: "SAP condition pricing unit and condition unit (KONP-KPEIN / KMEIN), price unit on the info record and PO (EINE-PEINH, EKPO-PEINH); Odoo has no pricing quantity (a price is per one unit)"
    customer_varies: whether any product is priced per 10, 100 or 1000; most firms price per one
  - id: PRD-07
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want a new price list or a price change to be checked before sales sees it, so that a typo does not reach a customer.
    given: a draft list with a price 90% below the list it is based on
    when: I publish it
    then: the publish shows the warning and, where the setting asks for it, waits for a second person's approval; once published, sales prices from it on the valid_from date; a publish that would change the price on a date already past is refused
    screen: Price lists
    reference: not built
  - id: PRD-08
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want to give a customer their own price list, so that the price they agreed is the price they are quoted.
    given: a customer with bp_id 7311 and the published list PARTNER-GBP
    when: I assign PARTNER-GBP to them for GBP
    then: from the assignment's valid_from (tomorrow or later, never a past date) every price asked for that customer in GBP resolves from PARTNER-GBP; a customer with no assignment gets the currency's default list on that date
    screen: Price lists
    reference: not built
  - id: PRD-09
    kind: person
    actor: pricing_manager
    story: As the pricing manager, I want to raise a set of prices by a percentage from a date, so that the annual increase is one action rather than two hundred edits.
    given: 40 hosting products on LIST-GBP
    when: I raise category hosting by 5% from 2027-01-01, rounded to the list's rounding rule
    then: 40 new price rows valid from 2027-01-01 exist, as a draft change; nothing changes before publish; running it twice creates nothing more
    screen: Price lists
    reference: not built
  - id: PRD-10
    kind: person
    actor: sales_rep
    story: As a sales rep, I want to look up what a customer pays for a product today, so that I can answer on the phone.
    given: a customer on PARTNER-GBP
    when: I ask for HOST-M, 12 units, today
    then: I see 102.00, the list it came from and the rule; never the cost
    screen: Products
    reference: not built

  # ── system ────────────────────────────────────────────────────────────────────────────────
  - id: PRD-11
    kind: system
    actor: system
    trigger: on read (derived)
    story: The system resolves a price when it is asked (product, quantity, customer, currency, date), so that no stored "current price" can go stale.
    given: rows valid from 2026-11-01 (120.00) and 2027-01-01 (126.00)
    when: a price is asked for 2026-12-31 and for 2027-01-01
    then: 120.00 and 126.00; the answer names the price list, the rule and the row it used; asking twice gives the same answer
    reference: not built
  - id: PRD-12
    kind: system
    actor: system
    trigger: when a product is added or its description changes
    story: The system checks whether the product already exists under another code, so that the list does not fill with duplicates.
    given: HOST-M "Managed hosting, per month" exists
    when: someone adds HOSTING-MONTHLY "Monthly managed hosting"
    then: the catalogue manager is shown the likely duplicate and may continue; nothing is merged automatically
    reference: not built
  - id: PRD-32
    kind: system
    actor: system
    trigger: on read (derived), when a purchasing, sales-order or inventory line asks
    story: The system answers whether a quantity meets a product's rounding rule for a role, so that every module that orders, sells or delivers applies the same rule.
    given: "COLA-075 purchase rule at least 80, multiple 80 PK24 from 2026-11-01; delivery rule multiple 6 C62"
    when: "GET /products/COLA-075/rounding is asked for (purchase, 100 PK24, on 2026-11-02), (purchase, 100 PK24, on 2026-10-30), (delivery, 2 PK24) and (production, 5 LTR)"
    then: "unmet with next_down 80 and next_up 160; met (no rule in force yet) with reason \"no rule on 2026-10-30\"; met (48 C62 is a multiple of 6); met with reason \"no rule for role production\"; each answer names the rule row it used; asking twice gives the same answer; nothing is stored"
    reference: not built

  # ── integration ───────────────────────────────────────────────────────────────────────────
  - id: PRD-20
    kind: integration
    actor: system
    trigger: when a quote line is added or re-priced
    story: The quote asks this module for the price instead of keeping its own price book.
    given: brick-quote with the price book in quote_prices (0.2.0)
    when: a rep adds a line
    then: quote calls find_price(product_id, quantity, unit_code, bp_id, currency, priced_on), unit_code defaulting to the product's sales unit, and stores the answer on its line; quote_prices is migrated here once, keyed the same, and quote stops writing it
    owner: product
    reference: not built
  - id: PRD-21
    kind: integration
    actor: system
    trigger: when an order or invoice line names a product
    story: Orders and invoices copy what they need from the product at that moment, so that a later rename never rewrites a document.
    given: HOST-M with its description, unit, tax category and invoicing policy
    when: a sales order line is added for it
    then: the order line holds a copy of those fields, the product_id, the unit it was sold in and that unit's conversion to the base unit; the product module is not called again for that line
    owner: product
    reference: not built
  - id: PRD-22
    kind: integration
    actor: system
    trigger: on a one-off import
    story: A catalogue from a spreadsheet or another system arrives once and can be re-run safely.
    given: a file of 300 products and their list prices
    when: it is imported twice
    then: 300 products and 300 price rows exist, keyed on the product code and (list, product, unit, valid_from); the second run changes nothing
    owner: product
    reference: not built
  - id: PRD-33
    kind: integration
    actor: system
    trigger: on the one-off Odoo migration (milestone M8), after business-partner
    story: The customer's Odoo units, packagings and barcodes arrive once as our units, their exact conversions and their GTINs, so that every later brick maps an Odoo quantity to ours without a float.
    given: "an Odoo 19 database with units Units, Pack of 24 (relative to Units, factor 24), kg and g, product COLA-075 sold in Units and Pack of 24 with a packaging barcode on the pack; and an Odoo 17 database with product.packaging Pack of 24 (qty 24, barcode 04006381333931)"
    when: the migration runs twice
    then: "Units -> C62, kg -> KGM, g -> GRM (fixed conversions from prod_unit_conversion), Pack of 24 -> the product unit PK24 with numerator 24 / denominator 1; the pack barcode -> PK24's main GTIN, a second barcode for the same unit an additional GTIN; product weight and volume -> the base unit's gross weight and volume; every price row pricing_quantity 1; an Odoo factor that is not an exact fraction within the unit's rounding stops the run with the unit listed; the second run changes nothing; GET /migration-map/{db}/uom.uom answers each Odoo unit's unit_code for the later bricks"
    owner: product
    reference: not built

  - id: PRD-39
    kind: integration
    actor: system
    trigger: when inventory plans replenishment, checks availability per site, or a selling or buying brick checks a product at a site
    story: Inventory, sales-order and purchasing read a product's site data from this module, so that a reorder point, a lead time or a site block is kept in one place.
    given: "COLA-075 with site rows for WH1 and WH2 (PRD-37)"
    when: "inventory's nightly replenishment asks GET /product-sites?site_id=WH1&planning=active, sales-order asks GET /products/COLA-075/sites/WH2?on=2026-11-02 for a line delivered from WH2, and the same is asked twice"
    then: "inventory receives every product planned at WH1 with its row in force on the asked date; sales-order receives site_status and the planned delivery days; the answers are derived from the dated rows and repeat exactly; nothing is copied into inventory's tables beyond what a proposal records it used (the row's valid_from)"
    owner: product
    reference: not built
  - id: PRD-40
    kind: integration
    actor: system
    trigger: when a shop's register takes its price snapshot (pos POS-30), and when any module needs a whole list
    story: A register reads a whole published price list once instead of one product at a time, so that a layout of 200 buttons is priced in one call with the same answers find_price gives.
    given: 'LIST-SHOP-GBP published, 180 products, two of them with a scale (from 10 C62); a price row of COFFEE-250 changing on 2026-12-01'
    when: 'pos calls GET /price-lists/LIST-SHOP-GBP/prices?on=2026-11-20, then pages with offset'
    then: >-
      every product with a price on the list in force on 2026-11-20 is answered once per (unit_code, from_quantity)
      with the price find_price would give for that quantity, its pricing quantity and unit and priced_from (rows
      derived from a price rule answer the rule's result, never the rule); next_change_on says 2026-12-01, the first
      date after `on` on which any answered row changes, so the register knows when its snapshot goes stale; a draft or
      retired list is 404; customer-specific lists are never part of it
    owner: product
    reference: not built
  - id: PRD-41
    kind: integration
    actor: system
    trigger: when sales-order drafts, re-prices or confirms an order (SO-02, SO-28), and when any module prices many lines at once
    story: An order of many lines asks product once for every line's price (and, at confirm, its delivery rounding) instead of once per line per route, so that a fifty-line order costs product what a one-line order costs and every line gets the answer the one-line routes give.
    given: 'TRD-00001 and TRD-00002 active with GBP rows on the default list; TRD-00002 delivered in multiples of 6 (a delivery role rule); customer bp 1001'
    when: 'sales-order calls POST /prices/batch {bp_id: 1001, currency: GBP, priced_on: 2026-11-02, lines: [{line_no: 1, product_id: TRD-00001, quantity: "3"}, {line_no: 2, product_id: TRD-00002, quantity: "4", rounding_role: delivery}, {line_no: 3, product_id: NOPE, quantity: "1"}]}'
    then: >-
      one answer per line, in the order asked, each naming its line_no: line 1's price is field for field what GET
      /products/TRD-00001/price?quantity=3&bp_id=1001&currency=GBP&priced_on=2026-11-02 answers; line 2 also carries
      rounding, what GET /products/TRD-00002/rounding?role=delivery&quantity=4&unit_code=C62&on=2026-11-02 answers (met
      false, next_up 6); line 3 is {status: 404, reason: "no product NOPE"} and lines 1 and 2 are still answered (a line's
      refusal never refuses the batch); more than 200 lines is refused 422 as a whole; the reads product makes do not grow
      with lines of products already in the batch (the statement budget test, 2 lines against 20)
    owner: product
    reference: "erp-brick-product 0.2.2 (branch perf/batch-price until signed)"

processes:
  - key: product
    pcf_id: 11740
    pcf_name: Manage product and service master data
    reference_names: ["Product master data"]
    brick: product
  - key: price_list
    pcf_id: 20593
    pcf_name: Develop and manage pricing
    reference_names: ["Pricing", "Price lists"]
    brick: product

workflows:
  - entity: product
    states: [draft, active, archived]
    terminal: [archived]
    transitions:
      - { key: add_product, name: Add the product, from: none, to: draft, by: catalogue_manager, gate: "the code is not in use; a product type is named, mandatory on every door (v5, gate type_named)", effect: "the code is fixed from now on (typed, or claimed from the type's series); kind and stocked_default taken from the type" }
      - { key: change_product_type, name: Change the product type, from: draft, to: draft, by: catalogue_manager, requires: reason, gate: "readiness: type_unused (no document or stock has used any unit of the product)", effect: "v5: kind, stocked_default, sellable, valuation_class and the required unit roles are COPIED again from the new type; the code is kept; from LEGACY it is allowed on an active, used product once, when the new type's kind and stocked_default equal the product's own (no change of behaviour)" }
      - { key: ai_check_duplicate, name: "AI checks for a likely duplicate (suggest)", from: draft, to: draft, by: system, gate: "the duplicate check is on", effect: "a likely duplicate is shown; nothing is merged" }
      - { key: ai_suggest_details, name: "AI suggests category, unit and tax category (suggest)", from: draft, to: draft, by: system, gate: "the field is empty and the step is on", effect: "a suggestion the catalogue manager accepts or changes" }
      - { key: generate_variants, name: Generate the variants of a template, from: none, to: draft, by: catalogue_manager, gate: "a template with at least one attribute and its values", effect: "one draft product per missing combination; existing ones untouched" }
      - { key: set_units, name: Set the base unit and the other units, from: draft, to: draft, by: catalogue_manager, gate: "every unit converts exactly to the base unit; every role names one of the product's units", effect: "the roles chosen (sales, purchase, issue, production, warehouse, delivery); packaging levels, their GTINs (one main per unit), weights, volumes and dimensions" }
      - { key: complete_product, name: "Complete unit, category, tax category and billing", from: draft, to: draft, by: catalogue_manager }
      - { key: classify_product, name: Classify the product, from: draft, to: draft, by: catalogue_manager, gate: "each value allowed by its characteristic (list, number with a unit of the right kind, date)", effect: "class assignments and dated characteristic values; searchable by them" }
      - { key: activate_product, name: Activate the product, from: draft, to: active, by: catalogue_manager, gate: "readiness: units_complete, type_requirements (the roles and fields its type asks for, v3), class_complete (every required characteristic of its classes, v3); tax category, billing type and invoicing policy set", effect: "offered to sales (where its type is sellable) and to purchasing (where purchasable)" }
      - { key: add_unit, name: Add a unit or a pack size, from: active, to: active, by: catalogue_manager, gate: "a new unit code with an exact conversion; a conversion already used is never changed", effect: "the new unit can be bought, sold or issued from now" }
      - { key: add_gtin, name: Add or retire a barcode on a unit, from: active, to: active, by: catalogue_manager, gate: "a valid GS1 check digit; the GTIN on no other product or unit, retired ones included; on a template only while it has one product", effect: "one main GTIN per unit, the others additional, on the SKU (or a one-product template); a retired GTIN still names its unit on old documents" }
      - { key: set_measures, name: Record or correct weights, volume and dimensions, from: active, to: active, by: catalogue_manager, gate: "net weight not above gross; each measure's unit of the right kind (mass, volume, length); valid_from today or later (v3); a row for today is replaced only while no document has copied it", effect: "a dated prod_unit_measure row per one of the unit, on the template (inherited) or the SKU (its own row wins); never frozen by use (v3): documents keep the copy they took" }
      - { key: set_rounding, name: Set a role's unit, minimum and multiple, from: active, to: active, by: catalogue_manager, gate: "the role's unit or another unit of the product; starts after today", effect: "a dated unit_role row on the template (inherited) or the SKU (its own row wins); purchasing and sales-order gate on it from its valid_from; a used row is never edited" }
      - { key: reclassify_product, name: Change a classification value, from: active, to: active, by: catalogue_manager, gate: "the value allowed; valid_from today or later", effect: "a new dated value row; search answers by the value in force on the date asked" }
      - { key: set_site_data, name: Set the product's data for a site, from: active, to: active, by: planner, gate: "readiness: site_row_after_today, site_quantities_consistent (safety stock <= reorder point <= max), supplier_site_valid (a transfer names another site of the same company), manufacturing_installed (v6: procurement make and planning mrp only when manufacturing is in the composition; was not_reserved_m7)", effect: "a dated prod_site row; inventory plans from it and sales-order and purchasing read its site status from its valid_from" }
      - { key: rename_product, name: Change name or description, from: active, to: active, by: catalogue_manager, effect: "documents already raised keep their copy" }
      - { key: archive_product, name: Archive the product, from: active, to: archived, by: catalogue_manager, effect: "no new lines; existing documents unchanged" }
      - { key: reactivate_product, name: Reactivate the product, from: archived, to: active, by: catalogue_manager, requires: reason }
    note: "a product is never deleted once a document names it. Every per-unit row (conversion, GTIN, measures, role, rounding) is set on the template or the SKU and the SKU's own row wins; a conversion, packaging level, decimals and GTIN are frozen once a document or stock uses them, an inherited template row included; measures, roles and rounding are dated rows instead (v3: measures are no longer frozen, a correction is a new row from today)"
    readiness:
      - { transition: activate_product, gate: units_complete, reason: "no base or sales unit, or a role names a unit the product does not have", fix: "Products: units" }
      - { transition: add_gtin, gate: gtin_check_digit, reason: "the last digit is not the GS1 check digit of the others", fix: "re-scan or re-type the barcode" }
      - { transition: add_gtin, gate: gtin_unique, reason: "the GTIN already names another product or unit (retired GTINs included)", fix: "check the barcode; a GTIN is never reused" }
      - { transition: set_measures, gate: net_not_above_gross, reason: "the net weight is above the gross weight", fix: "correct one of them" }
      - { transition: set_measures, gate: measure_unit_kind, reason: "the weight unit is not a mass, the volume unit not a volume, or the dimension unit not a length", fix: "pick a unit of that kind (Settings: units)" }
      - { transition: set_measures, gate: measure_not_backdated, reason: "a measure starts today at the earliest (in the business time zone)", fix: "date it today or later" }
      - { transition: set_measures, gate: today_row_uncopied, reason: "a document already copied today's measures for this unit", fix: "date the correction tomorrow" }
      - { transition: add_product, gate: type_named, reason: "no product type was named", fix: "name one (TRADING, FINISHED, RAW, SERVICE, NONSTOCK, PACKAGING or your own); LEGACY only for a product that already existed" }
      - { transition: change_product_type, gate: type_unused, reason: "a document or stock already used this product", fix: "make a new product of the other type and archive this one" }
      - { transition: activate_product, gate: type_requirements, reason: "the product's type asks for a role or field that is not set (e.g. TRADING needs a purchase unit)", fix: "Products: units, or change the type while it is a draft" }
      - { transition: activate_product, gate: class_complete, reason: "a required characteristic of one of its classes has no value", fix: "Products: classification" }
      - { transition: set_site_data, gate: site_row_after_today, reason: "site data starts tomorrow at the earliest", fix: "pick a later date" }
      - { transition: set_site_data, gate: site_quantities_consistent, reason: "safety stock above the reorder point, or the reorder point above the maximum", fix: "correct the quantities" }
      - { transition: set_site_data, gate: supplier_site_valid, reason: "a transfer must name another site of the same company", fix: "pick the supplying site, or procurement buy" }
      - { transition: set_site_data, gate: manufacturing_installed, reason: "procurement make and planning mrp need the production module, which this system does not have", fix: "install manufacturing, or use buy or transfer, and reorder_point, min_max or manual" }
      - { transition: set_rounding, gate: starts_after_today, reason: "a rounding rule starts tomorrow at the earliest", fix: "pick a later date" }
  - entity: price_list
    states: [draft, published, retired]
    terminal: [retired]
    transitions:
      - { key: open_price_list, name: Open a price list, from: none, to: draft, by: pricing_manager, gate: "a currency (ISO 4217); a base list in the same currency with the same VAT basis; rounding no finer than the currency's minor unit", effect: "based on another list, or standalone" }
      - { key: set_prices, name: Set prices and rules, from: draft, to: draft, by: pricing_manager, gate: "a pricing quantity is a whole number from 1 to 100000", effect: "dated rows per product and unit, each a price per its pricing quantity (default 1), rules, and the list's default_from, all still editable; nothing visible to sales yet" }
      - { key: ai_price_check, name: "AI flags unusual prices (suggest)", from: draft, to: draft, by: system, gate: "the price check is on", effect: "outliers against the base list and similar products are listed for the pricing manager" }
      - { key: publish_price_list, name: Publish the price list, from: draft, to: published, by: pricing_manager, approval: true, gate: "every row and rule has a valid_from after today, or today while the currency has no published price yet; a default_from after today, or today for the first default list of a currency; the approver is not the author where the setting asks for one", effect: "sales prices from it on each row's valid_from; from its default_from it is the currency's default list" }
      - { key: change_prices, name: Schedule a price change, from: published, to: published, by: pricing_manager, approval: true, gate: "every new row starts after today", effect: "new rows with a later valid_from; old rows are never edited" }
      - { key: assign_customer, name: Assign customers to the list, from: published, to: published, by: pricing_manager, gate: "the assignment starts after today", effect: "their prices in this currency resolve from this list from that date" }
      - { key: retire_price_list, name: Retire the price list, from: published, to: retired, by: pricing_manager, gate: "not the default of its currency today or later, and not the base of another list or rule", effect: "assigned customers fall back to the default list; prices for past dates still resolve from it" }
    note: "the CURRENT price is derived on read from the dated rows (PRD-11); nothing stores it. One default per currency on any date: the latest default_from on or before it. Nothing changes a price for a date already past"

entities:
  - id: product_template
    meaning: what several products have in common (Cola, in three volumes); a product with no variants has a template with exactly one product
    natural_key: product_template_id (the customer's own code, e.g. COLA)
    key_attributes: [name, description, category_code, tax_category, billing_kind, invoicing_policy, base_unit_code, attributes (name and values, e.g. volume 0.33 L / 0.75 L / 1.5 L)]
    relationships: ["1..n product", "0..n product_unit"]
    master_data: "module-owned; read only through its products"
    reference_table: prod_template
    note: "never named on a document, a price or a stock row: those name the product (the SKU)"
  - id: product
    meaning: something the business sells (or buys), goods or a service, with how it is billed and taxed; one SKU, a variant of its template
    natural_key: "product_id: the customer's own code (SKU), VARCHAR, fixed once set (shared.yaml keys.product_id)"
    key_attributes: [product_template_id, attribute_values, name, description, "product_type_code (v3: prod_product_type)", "commodity_code (v3: the 8-digit CN code for Intrastat and customs; read by tax)", "country_of_origin (v3: ISO 3166; read by tax and ar)", kind (goods | service, from the type), base_unit_code, sales_unit_code, purchase_unit_code, issue_unit_code, "production_unit_code, warehouse_unit_code, delivery_unit_code (v2; every *_unit_code is the role in force today, derived from unit_role)", "parallel_unit_code (RESERVED until M7: catch weight; NULL, refused if set)", "lot_conversion_allowed (RESERVED until M7: batch-specific units; false)", category_code, tax_category, billing_kind (one_off | recurring | metered), billing_plan (single | periodic | arrears), invoicing_policy (ordered | delivered), lifecycle, cost_amount]
    relationships: ["-> product_template", "-> product_category", "-> unit", "-> tax_category (tax)", "0..n product_unit", "0..n price_row"]
    master_data: "SHARED: sole writer product; sales (quote, sales-order), invoicing and purchasing read it"
    reference_table: product
    note: "cost_amount is the standard cost, read for margin; sales never sees it (permissions)"
  - id: product_category
    meaning: a family of products a rule or a report covers
    natural_key: category_code
    key_attributes: [name, parent_code]
    master_data: module-owned (configuration)
    reference_table: prod_category
  - id: product_category_membership
    meaning: which category a product, and a category's parent, sits in from a date, so a price rule on a category resolves as it stood on the date asked
    natural_key: [product_or_category, valid_from]
    key_attributes: [category_code]
    master_data: module-owned (append-only; a move adds a row from tomorrow)
    reference_table: prod_category_membership
  - id: unit
    meaning: how a quantity is counted (each, hour, day, month, kilogram)
    natural_key: "unit_code: UN/ECE Recommendation 20 code (C62 one, HUR hour, DAY day, MON month, KGM kilogram)"
    key_attributes: [name, decimals, "kind (v2: count | mass | volume | length | area | time | other; a measure's unit must be of its kind)"]
    master_data: module-owned (configuration, seeded)
    reference_table: prod_unit
    note: "Rec 20 codes are what a UBL / Peppol e-invoice line carries (cbc:InvoicedQuantity unitCode), so seeding them now spares a mapping later"
  - id: product_unit
    meaning: a unit a product can be counted in and its exact conversion to the product's base unit, with its packaging level, barcode and measures
    natural_key: [owner (product_template_id or product_id), unit_code]
    key_attributes: [numerator, denominator (quantity in base unit = quantity x numerator / denominator), "relative_unit_code (v2: the unit numerator / denominator are stated in, default the base unit; PK24 = 24 C62 on a template whose C62 differs per variant)", packaging_level (each | inner | case | pallet | none), "gtin (v1; v2: the main GTIN, kept equal to its prod_gtin row of kind main)", quantity_decimals, "(v3: gross_weight, net_weight, volume and dimensions moved to the dated prod_unit_measure; the v2 columns are read as its first row)", "variable_per_lot (RESERVED until M7: false)", einvoice_unit_code (a UN/ECE Rec 20 or Rec 21 code, e.g. XPK package, XBX box; required before the unit is used on a document), status (active | retired)]
    master_data: "module-owned; a product's own row overrides its template's for the same unit"
    reference_table: prod_product_unit
    note: >-
      THE TEMPLATE -> SKU RULE, for every per-unit attribute (v2 says it once for all of them: the conversion, the
      GTINs, gross and net weight, volume, length x width x height, the unit roles and each role's rounding): a row
      set on the TEMPLATE is inherited by every SKU of it; a SKU's OWN row for the same unit (or role) wins, whole
      row, never field by field; and a row is FROZEN once a document or stock uses it, an inherited template row
      included (the document names the SKU, the freeze lands on the row it read). A frozen row is never edited: a
      changed pack size, a corrected weight on a used pack or a new barcode level is a new unit code (or, for a role
      or a rounding rule, a new row from a later date). A unit whose conversion differs by variant (LTR on COLA,
      where a bottle is 0.75 or 1.5 LTR) is per SKU; a template row may state its conversion relative to such a unit
      (PK24 = 24 C62), and each SKU's conversion to its base is composed exactly from the chain, own rows first, and
      copied composed onto documents. A pack code is the customer's own (PK24) but never one of the seeded UN/ECE
      codes unless it carries that code's real conversion. Fixed conversions (KGM-GRM, LTR-MLT) come from
      unit_conversion, never from a product row
  - id: product_unit_measure
    meaning: "a unit's gross and net weight, volume and length x width x height, per ONE of the unit, from a date (v3, SAP MARM: not valuation, so never frozen)"
    natural_key: [owner (product_template_id or product_id), unit_code, valid_from]
    key_attributes: ["gross_weight, net_weight (NUMERIC(19,6))", weight_unit_code, "volume (NUMERIC(19,6))", volume_unit_code, "length, width, height (NUMERIC(19,6))", dimension_unit_code, set_by, set_at, copied (a document has copied this row; derived from the documents' copies, never typed)]
    relationships: ["-> product_unit"]
    master_data: "module-owned (append-only; valid_from today or later; the row for today may be replaced only while no document has copied it)"
    reference_table: prod_unit_measure
    note: >-
      follows the template -> SKU rule for which row applies (the SKU's own rows for a unit, else the template's), but
      NOT its freeze: a correction is a new row, and the row in force on a date is the latest valid_from on or before
      it. A document (receipt, delivery, order line) copies the row in force when it is written and keeps it; nothing
      re-reads it later. A measure never set answers null (unknown), never 0
  - id: product_type
    meaning: "what kind of product this is for every module (SAP material type): trading goods, finished, raw material, service, non-stock, packaging (v3)"
    natural_key: "product_type_code (TRADING, FINISHED, RAW, SERVICE, NONSTOCK, PACKAGING seeded; the customer may add their own)"
    key_attributes: [name, kind (goods | service), stocked_default (bool), sellable (bool), purchasable (bool), required_roles (list of unit roles that must be set to activate), "code_format (a pattern such as TRD-{seq:5}, or typed)", valuation_class (a code an account map may name; inventory and purchasing read it), "production_fields (RESERVED until M7: false)", status (active | retired)]
    master_data: module-owned (configuration, seeded)
    reference_table: prod_product_type
    note: "a type is retired, never deleted, while a product carries it; changing a product's type is allowed only while nothing has used it (change_product_type). v5: a product COPIES the type's kind, stocked_default, sellable, valuation_class and required roles when it is created and again on change_product_type; editing a type later never reaches an existing product (the copy is the product's own, so a stock or price decision cannot move under a document). LEGACY is seeded for products that pre-date mandatory types"
  - id: product_code_series
    meaning: "the next number of a type's code format (v3)"
    natural_key: [product_type_code, number]
    key_attributes: [product_id, claimed_at]
    master_data: "module-owned (a claim row per number, plain INSERT: never reused, no gap from a refused add because the number is claimed in the add's own write)"
    reference_table: prod_code_series
  - id: characteristic
    meaning: "a property a product can be described and found by (material, length, thread) (v3, SAP classification)"
    natural_key: characteristic_code
    key_attributes: [name, data_type (text | number | date | list), unit_code (number only; values convert exactly through unit_conversion), allowed_values (list only), multi_value (bool), decimals]
    master_data: module-owned (configuration)
    reference_table: prod_characteristic
  - id: product_class
    meaning: "a set of characteristics that describe a family (SCREWS: material, thread, length) (v3)"
    natural_key: class_code
    key_attributes: [name, "characteristics: [{characteristic_code, required}]"]
    master_data: module-owned (configuration)
    reference_table: prod_class
  - id: product_classification
    meaning: "a product's (or template's) class and its characteristic values, from a date (v3)"
    natural_key: [owner (product_template_id or product_id), class_code, characteristic_code, valid_from]
    key_attributes: ["values (list; numbers stored in the characteristic's unit, exact)", set_by]
    master_data: "module-owned (append-only; valid_from today or later; a template's values are inherited, a SKU's own row wins)"
    reference_table: prod_classification
  - id: product_site
    meaning: "how one product is procured, planned and allowed at one site (SAP plant data, MARC), from a date (v3)"
    natural_key: [product_id, site_id (inventory's warehouse_id), valid_from]
    key_attributes: ["site_status (active | no_purchase | no_sale | blocked)", "procurement_type (buy | transfer | make; v6: make accepted when manufacturing is in the composition, else refused)", supplying_site_id, planned_delivery_days, gr_processing_days, "planning_method (none | reorder_point | min_max | manual | mrp; v6: mrp accepted when manufacturing is in the composition: manufacturing's MRP-lite plans the product, inventory's replenishment does not)", "reorder_point, safety_stock, max_stock (NUMERIC(19,4) in the base unit)", "lot_size_method (exact | fixed | up_to_max)", fixed_lot_size, rounding_profile_code, "mrp_controller, planning_time_fence_days, lot_size_procedure, production_version (RESERVED until M7: NULL)", set_by]
    relationships: ["-> product", "-> warehouse (inventory)", "-> rounding_profile"]
    master_data: "module-owned (append-only; valid_from after today); read by inventory, sales-order and purchasing only through GET /products/{product_id}/sites/{site_id} and GET /product-sites"
    reference_table: prod_site
    note: >-
      site_id is inventory's warehouse_id (shared key, VARCHAR), validated through inventory's GET /warehouses/{warehouse_id}
      when inventory is installed, else a code validated against nothing. A product with no row at a site is
      procurement buy, planning none, status active there. Product stores the parameters; inventory computes ABC
      classes and proposals from them (inventory INV-42, INV-44) and never writes them
  - id: rounding_profile
    meaning: "how a SYSTEM-PROPOSED quantity is rounded to pallets or layers when close to one (v3, SAP OWD1 static profile)"
    natural_key: rounding_profile_code
    key_attributes: ["steps: [{multiple (exact, in unit_code), round_up_pct}] largest first", unit_code, status]
    master_data: module-owned (configuration)
    reference_table: prod_rounding_profile
    note: "applied only to proposals (inventory's replenishment, GET /products/{product_id}/round-proposal); a person's order is gated by the role's rule (PRD-30), never rounded"
  - id: product_gtin
    meaning: "a barcode (GTIN) that names one product unit: one main per unit, any number of additional ones (v2, SAP MEAN)"
    natural_key: "gtin (14 digits, left-padded with zeros; unique across every product and unit, for ever)"
    key_attributes: [owner (product_template_id or product_id), unit_code, kind (main | additional), source (ours | supplier | migration), status (active | retired), added_on, retired_on]
    relationships: ["-> product_unit"]
    master_data: "module-owned; GET /gtin/{gtin} reads it"
    reference_table: prod_gtin
    note: >-
      follows the template -> SKU rule of product_unit, with one limit GS1 sets: a GTIN names exactly ONE trade item,
      so a template may carry a GTIN only while it has exactly one product; generating a second variant moves the
      template's GTIN rows to that first SKU (the same write), and each further SKU carries its own. A SKU's own
      GTINs for a unit replace the inherited ones. A GTIN is frozen once a document scanned it: a retired row is
      never deleted and its GTIN never given to another product. Making an additional GTIN main turns the previous
      main into an additional one in the same write
  - id: unit_role
    meaning: "which unit one role uses (sales, purchase, issue, production, warehouse, delivery) and that role's rounding (a minimum and a multiple), from a date (v2)"
    natural_key: [owner (product_template_id or product_id), role, valid_from]
    key_attributes: [unit_code (one of the product's units, or the base), min_quantity, rounding_multiple (exact, in unit_code; either may be empty), set_by]
    relationships: ["-> product_template or product", "-> product_unit"]
    master_data: "module-owned (append-only; valid_from after today, the first rows of a draft product may start today); read through GET /products/{product_id} (roles) and GET /products/{product_id}/rounding, never by another brick's SQL"
    reference_table: prod_unit_role
    note: >-
      follows the template -> SKU rule: the template's row is inherited, a SKU's own row for the role wins, and a row
      a document used is frozen (a change is a new row from a later date, so a document re-reads the role and rule it
      was written under). product.<role>_unit_code is the role in force today, derived from these rows, never typed
      separately; the base unit is not a role row (it is the product's, fixed once stock exists). Product answers a
      rounding rule, the caller decides: purchasing turns an unmet purchase rule into a gate a second person may
      override; sales-order warns or blocks on the delivery rule per its owner control
  - id: unit_conversion
    meaning: a conversion true for every product (1 KGM = 1000 GRM)
    natural_key: [from_unit_code, to_unit_code]
    key_attributes: [numerator, denominator]
    master_data: module-owned (configuration, seeded from UN/ECE Rec 20)
    reference_table: prod_unit_conversion
  - id: price_list
    meaning: the prices a group of customers pays in one currency
    natural_key: price_list_id (the customer's own code, e.g. LIST-GBP)
    key_attributes: [name, currency (ISO 4217), based_on, prices_include_tax, rounding, status (draft | published | retired), default_from (date; the currency's default list from that date, until a later default_from)]
    relationships: ["1..n price_row", "1..n price_rule", "0..n customer_price_list"]
    master_data: module-owned (configuration)
    reference_table: prod_price_list
  - id: price_row
    meaning: one product's price on one list from a date
    natural_key: [price_list_id, product_id, unit_code, valid_from]
    key_attributes: [unit_price (NUMERIC 19,4, per pricing_quantity of unit_code), "pricing_quantity (v2: a whole number 1..100000, default 1; 2.4500 per 100 C62)", valid_to]
    master_data: module-owned (configuration)
    reference_table: prod_price
    note: "one price per product from a date; quantity breaks are price rules, not rows. Migrated from brick-quote quote_prices (price_book_id becomes price_list_id); never updated in place"
  - id: price_rule
    meaning: a price derived from another (a percentage off a list, a quantity break, a category-wide rule)
    natural_key: [price_list_id, seq]
    key_attributes: [applies_to (product | template | category | all), target, min_quantity, valid_from, valid_to, method (fixed | discount_pct | formula), unit_code (the unit a fixed price or surcharge is stated in, set when written, never re-read from the product), fixed_price (NUMERIC 19,4), "pricing_quantity (v2: the quantity a fixed price or surcharge is per, default 1)", discount_pct (percent), surcharge_amount, base_list]
    master_data: module-owned (configuration)
    reference_table: prod_price_rule
  - id: customer_price_list
    meaning: which list a customer is priced from, per currency
    natural_key: [bp_id, currency, valid_from]
    key_attributes: [price_list_id]
    note: "append-only: a new assignment adds a row from a later date, so the list a customer was priced from on any past date stays answerable"
    relationships: ["-> business_partner", "-> price_list"]
    master_data: module-owned
    reference_table: prod_customer_price_list
  - id: settings
    meaning: how this business keeps products and prices
    natural_key: key
    key_attributes: [value]
    master_data: module-owned (configuration)
    reference_table: prod_settings

master_data:
  owns: [product, prod_template, prod_product_unit, prod_unit_measure, prod_product_type, prod_code_series, prod_characteristic, prod_class, prod_classification, prod_site, prod_rounding_profile, prod_gtin, prod_unit_role, prod_unit_conversion, prod_category, prod_category_membership, prod_unit, prod_price_list, prod_price, prod_price_rule, prod_customer_price_list, prod_settings]
  references:
    - entity: business_partner
      owner: business-partner
      how: prod_customer_price_list.bp_id = business_partner.bp_id
      if_absent: "customer price lists cannot be assigned; every customer is priced from the currency's default list"
    - entity: tax_category
      owner: tax
      how: product.tax_category = tax_category.category_code
      if_absent: "the tax category is a free code validated against nothing; the tax module validates it the day it is installed"
    - entity: warehouse
      owner: inventory
      how: prod_site.site_id = inv_warehouse.warehouse_id (GET /warehouses/{warehouse_id})
      if_absent: "site rows name a code validated against nothing; nothing plans from them until inventory is installed"
  warning: >-
    A product list kept in sales AND in invoicing is how a renamed product bills under its old name and a
    price rise reaches quotes but not renewals. One writer, everyone else copies on the line.

jobs:
  - story: PRD-11
    trigger: on read
    runs: price resolution
    where_options: [derived on read]
    idempotent: "nothing is stored"
  - story: PRD-12
    trigger: when a product is added or its description changes
    runs: the duplicate check (embedding similarity over name + description)
    where_options: [in the same request]
    idempotent: "a suggestion only; re-running shows the same candidates"

seams:
  - story: PRD-20
    with: crm
    owner: product
    rule: "quote calls product's find_price route (or the use case in process); it never reads prod_ tables. The one-off migration copies quote_prices into prod_price, keyed the same"
  - story: PRD-21
    with: sales-order
    owner: product
    rule: "an order or invoice line copies description, the unit and its conversion to the base unit, tax category, billing type and invoicing policy at the moment the line is added; nothing is joined back later"
  - story: PRD-22
    with: business-partner
    owner: product
    rule: "the import writes products and prices through this module's own use cases; customer price lists name bp_ids that already exist"
  # ── v2: the routes the ordering, selling and stocking bricks call (inbound) ──────────────────
  - story: PRD-31
    with: sales-order
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/price?quantity=&unit_code=&bp_id=&currency=&priced_on="
    permission: product:price_list:read
    request: "query: quantity (string, in unit_code), unit_code (default the sales unit), bp_id, currency, priced_on (default today)"
    response: "{product_id, currency, unit_price (string, NUMERIC 19,4, per pricing_quantity of pricing_unit_code), pricing_quantity (int), pricing_unit_code, pricing_numerator, pricing_denominator, amount (string, NUMERIC 18,2: the exact line amount for the asked quantity, rounded once), priced_from {price_list_id, rule_seq, row}, line {description, unit_code, unit_numerator, unit_denominator, tax_category, category_code (v6: the product template's category, product_template.category_code; what sales-order's category conditions match), invoicing_policy, billing_kind, billing_plan, product_type_code, sellable (v3), measures {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, valid_from} (v3: the unit's row in force on priced_on, for freight)}}"
    rule: "the pricing unit is the row's own unit and pricing quantity; only where the row's pricing_quantity is 1 and the price converted into the asked unit is exact at four decimals is it re-expressed in the asked unit (v1 behaviour); the caller copies all of it onto the line and computes nothing but the discount"
    status: "aligned 2026-10-22 with sales-order SO-02; v6: line.category_code added (sales-order v8 SO-30 reads it)"
  - story: PRD-31
    with: pos
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/price?quantity=&unit_code=&bp_id=&currency=&priced_on="
    permission: product:price_list:read
    request: "query: quantity (string, in unit_code), unit_code (default the sales unit), bp_id, currency, priced_on (default today)"
    response: "{product_id, currency, unit_price (string, NUMERIC 19,4, per pricing_quantity of pricing_unit_code), pricing_quantity (int), pricing_unit_code, pricing_numerator, pricing_denominator, amount (string, NUMERIC 18,2: the exact line amount for the asked quantity, rounded once), priced_from {price_list_id, rule_seq, row}, line {description, unit_code, unit_numerator, unit_denominator, tax_category, category_code (v6: the product template's category, product_template.category_code; what sales-order's category conditions match), invoicing_policy, billing_kind, billing_plan, product_type_code, sellable (v3), measures {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, valid_from} (v3: the unit's row in force on priced_on, for freight)}}"
    rule: "the pricing unit is the row's own unit and pricing quantity; only where the row's pricing_quantity is 1 and the price converted into the asked unit is exact at four decimals is it re-expressed in the asked unit (v1 behaviour); the caller copies all of it onto the line and computes nothing but the discount"
    status: "product v8 2026-10-26 (spec gaps wave 4): listed for pos POS-30"
  - story: PRD-31
    with: timesheets
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/price?quantity=&unit_code=&bp_id=&currency=&priced_on="
    permission: product:price_list:read
    request: "query: quantity (string, in unit_code), unit_code (default the sales unit), bp_id, currency, priced_on (default today)"
    response: "{product_id, currency, unit_price (string, NUMERIC 19,4, per pricing_quantity of pricing_unit_code), pricing_quantity (int), pricing_unit_code, pricing_numerator, pricing_denominator, amount (string, NUMERIC 18,2: the exact line amount for the asked quantity, rounded once), priced_from {price_list_id, rule_seq, row}, line {description, unit_code, unit_numerator, unit_denominator, tax_category, category_code (v6: the product template's category, product_template.category_code; what sales-order's category conditions match), invoicing_policy, billing_kind, billing_plan, product_type_code, sellable (v3), measures {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, valid_from} (v3: the unit's row in force on priced_on, for freight)}}"
    rule: "the pricing unit is the row's own unit and pricing quantity; only where the row's pricing_quantity is 1 and the price converted into the asked unit is exact at four decimals is it re-expressed in the asked unit (v1 behaviour); the caller copies all of it onto the line and computes nothing but the discount"
    status: "product v8 2026-10-26 (spec gaps wave 4): listed for timesheets TS-14 (the price of the billed service on the work date)"
  - story: PRD-32
    with: purchasing
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/rounding?role=&quantity=&unit_code=&on="
    permission: product:product:read
    request: "query: role (purchase | sales | issue | production | warehouse | delivery), quantity (string), unit_code, on (default today)"
    response: "{product_id, role, rule: {unit_code, min_quantity, rounding_multiple, valid_from} | null, quantity_in_rule_unit, met, next_down, next_up, reason}"
    rule: "purchasing asks with role purchase at price_lines and confirm_order and turns met false into its purchase_rounding gate; derived, nothing stored"
    status: aligned 2026-10-22 with purchasing PUR-37
  - story: PRD-32
    with: sales-order
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/rounding?role=&quantity=&unit_code=&on="
    permission: product:product:read
    request: "query: role delivery, quantity (string, the line's), unit_code (the line's), on (the order date)"
    response: '{product_id, role, rule | null, quantity_in_rule_unit, met, next_down, next_up, reason}'
    rule: "sales-order asks with role delivery at confirm and amend and turns met false into its delivery_multiple gate (warn or block per its owner control)"
    status: aligned 2026-10-22 with sales-order SO-28
  - story: PRD-28
    with: inventory
    owner: product
    direction: inbound
    route: "GET /products/{product_id}?on="
    permission: product:product:read
    request: "path product_id; query on (default today: the date the measures and roles are read for)"
    response: '{product_id, name, description, kind, product_type_code, stocked_default, valuation_class, tax_category, commodity_code (v3), country_of_origin (v3), base_unit_code, roles {sales, purchase, issue, production, warehouse, delivery}, units: [{unit_code, numerator, denominator, quantity_decimals, gtins: [{gtin, kind}], measures: {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, length, width, height, dimension_unit_code, valid_from} | null}], lifecycle, version}'
    rule: "inventory copies a unit's measures (the row in force when the line is written, with its valid_from) onto a receipt or delivery line; a measure never set answers null (unknown), never 0; a later correction never changes a copy (v3)"
    status: aligned 2026-10-22 with inventory INV-25
  - story: PRD-28
    with: pos
    owner: product
    direction: inbound
    route: "GET /products/{product_id}?on="
    permission: product:product:read
    request: "path product_id; query on (default today: the date the measures and roles are read for)"
    response: '{product_id, name, description, kind, product_type_code, stocked_default, valuation_class, tax_category, commodity_code (v3), country_of_origin (v3), base_unit_code, roles {sales, purchase, issue, production, warehouse, delivery}, units: [{unit_code, numerator, denominator, quantity_decimals, gtins: [{gtin, kind}], measures: {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, length, width, height, dimension_unit_code, valid_from} | null}], lifecycle, version}'
    rule: "inventory copies a unit's measures (the row in force when the line is written, with its valid_from) onto a receipt or delivery line; a measure never set answers null (unknown), never 0; a later correction never changes a copy (v3)"
    status: "product v8 2026-10-26 (spec gaps wave 4): listed for pos POS-30"
  - story: PRD-28
    with: manufacturing
    owner: product
    direction: inbound
    route: "GET /products/{product_id}?on="
    permission: product:product:read
    request: "path product_id; query on (default today: the date the measures and roles are read for)"
    response: '{product_id, name, description, kind, product_type_code, stocked_default, valuation_class, tax_category, commodity_code (v3), country_of_origin (v3), base_unit_code, roles {sales, purchase, issue, production, warehouse, delivery}, units: [{unit_code, numerator, denominator, quantity_decimals, gtins: [{gtin, kind}], measures: {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, length, width, height, dimension_unit_code, valid_from} | null}], lifecycle, version}'
    rule: "inventory copies a unit's measures (the row in force when the line is written, with its valid_from) onto a receipt or delivery line; a measure never set answers null (unknown), never 0; a later correction never changes a copy (v3)"
    status: "product v8 2026-10-26 (spec gaps wave 4): listed for manufacturing MFG-22"
  - story: PRD-27
    with: inventory
    owner: product
    direction: inbound
    route: "GET /gtin/{gtin}"
    permission: product:product:read
    request: "path gtin (8, 12, 13 or 14 digits; left-padded to 14)"
    response: "{gtin, product_id, unit_code, kind (main | additional), status, quantity_base, base_unit_code}; 404 for a GTIN never issued; a retired GTIN answers status retired and is refused for a new line by the caller"
    rule: "the scanner resolves every barcode here; it never keeps its own barcode table"
    status: built; aligned 2026-10-22 with inventory INV-25
  # ── v3: site data, proposals and classification ─────────────────────────────────────────────
  - story: PRD-39
    with: inventory
    owner: product
    direction: inbound
    route: "GET /product-sites?site_id=&planning=&on="
    permission: product:product:read
    request: "query: site_id (warehouse_id), planning (active = planning_method not none), on (default today)"
    response: "[{product_id, site_id, valid_from, site_status, procurement_type, supplying_site_id, planned_delivery_days, gr_processing_days, planning_method, reorder_point, safety_stock, max_stock, lot_size_method, fixed_lot_size, rounding_profile_code, base_unit_code, purchase_unit_code}] paged by product_id"
    rule: "inventory's replenishment reads the rows in force on its business date (INV-44) and records each row's valid_from on its proposal; it keeps no copy of the parameters. v6: a row with procurement_type make or planning_method mrp is manufacturing's to plan; inventory's replenishment skips it (inventory v6)"
    status: aligned 2026-10-22 with inventory INV-44
  - story: PRD-39
    with: manufacturing
    owner: product
    direction: inbound
    route: "GET /product-sites?site_id=&planning=&on="
    permission: product:product:read
    request: "query: site_id (the warehouse), planning active, on (the planning date)"
    request_schema:
      - { field: site_id, type: warehouse_id, required: true, in: query }
      - { field: planning, type: string, required: false, in: query, values: [active] }
      - { field: "on", type: date, required: false, in: query }
    response: "[{product_id, site_id, valid_from, site_status, procurement_type, planning_method, safety_stock, lot_size_method, fixed_lot_size, rounding_profile_code, planned_delivery_days, gr_processing_days, ...}] (the full row, as for inventory)"
    rule: "v6: manufacturing's MRP-lite plans the rows with procurement_type make (planning_method mrp or reorder_point) and never writes them; the fields mrp_controller, planning_time_fence_days, lot_size_procedure and production_version stay reserved (NULL)"
    status: "added 2026-10-25 (v6) for manufacturing v1 MFG-22"
  - story: PRD-40
    with: pos
    owner: product
    direction: inbound
    route: "GET /price-lists/{price_list_id}/prices?on=&limit=&offset="
    permission: product:price_list:read
    request: "path price_list_id; query on (YYYY-MM-DD, default today), limit (1 to 1000, default 500), offset"
    request_schema:
      - { field: price_list_id, type: price_list_id, required: true, in: path }
      - { field: "on", type: date, required: false, in: query }
      - { field: limit, type: integer, required: false, in: query }
      - { field: offset, type: integer, required: false, in: query }
    response: "200 {price_list_id, currency, prices_include_tax (bool), on, next_change_on (YYYY-MM-DD or null: the first date after `on` on which an answered row changes), prices: [{product_id, unit_code, from_quantity (string; 1 unless a scale), unit_price (string, NUMERIC 19,4, per pricing_quantity of pricing_unit_code), pricing_quantity (int), pricing_unit_code, valid_from, valid_to (or null), priced_from {price_list_id, rule_seq, row}}], more (bool)}; ordered by product_id, unit_code, from_quantity; 404 when the list is not published"
    rule: "PRD-40: the answers find_price gives for each row of the list on that date (a rule-derived price is its result); a register snapshots it with next_change_on as its horizon and is re-priced by find_price at sync if it sold past it; customer price lists are not included"
    status: "added 2026-10-25 (v6) for pos v1 POS-30"
  - story: PRD-41
    with: sales-order
    owner: product
    direction: inbound
    route: "POST /prices/batch"
    permission: product:price_list:read
    request: "body: {bp_id, currency, priced_on (YYYY-MM-DD, default today: the date of every answer), lines: [{line_no (the caller's key, echoed), product_id, quantity (string, in unit_code), unit_code (default the sales unit), price (bool, default true), rounding_role (optional: delivery | sales | purchase | issue | production | warehouse — asks the rounding of this line's quantity and unit for that role), convert (bool, default false — asks the conversion of the quantity from unit_code to unit_code, i.e. in_base_unit)}]}; 1 to 200 lines"
    request_schema:
      - { field: bp_id, type: bp_id, required: false }
      - { field: currency, type: currency, required: true }
      - { field: priced_on, type: date, required: false }
      - { field: lines, type: array, required: true, items: [{ field: line_no, type: integer, required: true }, { field: product_id, type: product_id, required: true }, { field: quantity, type: decimal, required: true }, { field: unit_code, type: unit_code, required: false }, { field: price, type: boolean, required: false }, { field: rounding_role, type: string, required: false, values: [delivery, sales, purchase, issue, production, warehouse] }, { field: convert, type: boolean, required: false }] }
    response: "200 {bp_id, currency, priced_on, lines: [{line_no, product_id, price, rounding, conversion}]} in the order asked; each of price / rounding / conversion is the one-line route's 200 answer (GET /products/{product_id}/price, .../rounding, .../convert — same fields, same values), or {status, reason} with the status and reason that route would have answered (404 no product, 409 not offered, 422 not counted in that unit or no price), or null when not asked; 422 for the whole batch only when the body itself is invalid (no lines, more than 200, a currency that is not ISO 4217)"
    rule: "PRD-41: the batch is the one-line routes run over the lines inside one read — the same answers, never a second pricing logic; a line's refusal is that line's answer and never refuses the others. The rounding and conversion parts need product:product:read as well (a part the caller may not read answers {status: 403}). sales-order calls it once per draft, re-price and confirm instead of find_price, rounding and convert per line"
    status: "added 2026-10-25 (v7) for sales-order v10 SO-02, SO-28"
  - story: PRD-39
    with: sales-order
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/sites/{site_id}?on="
    permission: product:product:read
    request: "path product_id, site_id; query on (default today)"
    response: "{product_id, site_id, valid_from | null, site_status, procurement_type, supplying_site_id, planned_delivery_days, gr_processing_days, planning_method, reorder_point, safety_stock, max_stock, lot_size_method, fixed_lot_size, rounding_profile_code}; a site with no row answers the defaults (buy, none, active) with valid_from null"
    rule: "sales-order refuses a line delivered from a site whose status is no_sale or blocked (gate site_allows_sale); purchasing refuses a purchase for a site whose status is no_purchase or blocked; inventory reads procurement_type to propose a purchase or a transfer"
  - story: PRD-38
    with: inventory
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/round-proposal?site_id=&quantity=&unit_code=&on="
    permission: product:product:read
    request: "query: site_id (its rounding profile), quantity (string, the proposal), unit_code, on"
    response: "{product_id, proposed, rounded, unit_code, steps_applied: [{multiple, round_up_pct, result}], purchase_rule {met, reason}}"
    rule: "derived, nothing stored; a site with no profile answers rounded = the purchase rule's next_up where unmet, else the proposal"
    status: aligned 2026-10-22 with inventory INV-44
  - story: PRD-38
    with: manufacturing
    owner: product
    direction: inbound
    route: "GET /products/{product_id}/round-proposal?site_id=&quantity=&unit_code=&on="
    permission: product:product:read
    request: "query: site_id (its rounding profile), quantity (string, the proposal), unit_code, on"
    response: "{product_id, proposed, rounded, unit_code, steps_applied: [{multiple, round_up_pct, result}], purchase_rule {met, reason}}"
    rule: "derived, nothing stored; a site with no profile answers rounded = the purchase rule's next_up where unmet, else the proposal"
    status: "product v8 2026-10-26 (spec gaps wave 4): listed for manufacturing MFG-22"
  - story: PRD-36
    with: crm
    owner: product
    direction: inbound
    route: "GET /products?char=<characteristic_code>:<op><value>&class_code=&on="
    permission: product:product:read
    request: "query: char repeated (op = | >= | <= | in), class_code, on (default today)"
    response: "[{product_id, name, class_code, values {characteristic_code: value}}] paged; a number is compared after an exact conversion to the characteristic's unit"
    rule: "quote, sales-order and purchasing search products by characteristics through this route only; no brick keeps its own attribute table"
  - story: PRD-39
    with: inventory
    owner: inventory
    direction: outbound
    route: "GET /warehouses/{warehouse_id}"
    permission: inv:stock:read
    request: "path warehouse_id"
    response: "{warehouse_id, company_code, status}"
    rule: "set_site_data checks the site exists and, for a transfer, that the supplying site has the same company_code; without inventory installed the code is validated against nothing"
    status: aligned 2026-10-22 with inventory INV-01
  - story: PRD-33
    with: purchasing
    owner: product
    direction: inbound
    route: "GET /migration-map/{db}/{model}?ids="
    permission: product:migration:read
    request: "path db, model (product.product, product.template, uom.uom, product.packaging, product.pricelist); query ids"
    response: "[{odoo_model, odoo_id, ours}]: ours is product_id, unit_code, (product_id, unit_code) for a packaging, or price_list_id; an id not migrated is omitted"
    rule: "the one map route (shared.yaml conventions.odoo_migration.map_route); purchasing, sales-order and inventory resolve Odoo products and units through it, never by names"
  - story: PRD-28
    with: purchasing
    owner: product
    direction: inbound
    route: GET /products/{product_id}?on=
    permission: product:product:read
    request: 'path product_id; query on (default today: the date the measures and roles are read for)'
    response: '{product_id, name, description, kind, product_type_code, stocked_default, valuation_class, tax_category, commodity_code (v3), country_of_origin (v3), base_unit_code, roles {sales, purchase, issue, production, warehouse, delivery}, units: [{unit_code, numerator, denominator, quantity_decimals, gtins: [{gtin, kind}], measures: {gross_weight, net_weight, weight_unit_code, volume, volume_unit_code, length, width, height, dimension_unit_code, valid_from} | null}], lifecycle, version}'
    rule: 'built (erp-brick-product get_product; on= and the v2/v3 fields are product v3): a purchase line copies description, kind, tax_category and the default purchase unit (roles.purchase in force on the date) at line time and never asks again'
    status: aligned 2026-10-22 with purchasing PUR-24
  - story: PRD-28
    with: tax
    owner: product
    direction: inbound
    route: GET /products/{product_id}?on=
    permission: product:product:read
    request: 'path product_id; query on (default today: the date the measures and roles are read for)'
    response: '{product_id, name, description, kind, product_type_code, stocked_default, valuation_class, tax_category, commodity_code