Skip to content

Design: Item Manufacturer Field

The parts catalog gains an optional Item Manufacturerwho makes a part, distinct from the supplier you buy it from. Instead of a plain string, a manufacturer is a BusinessAffiliate carrying a BusinessRole of type MANUFACTURER. This is the exact model already used for suppliers (a BusinessAffiliate with a VENDOR role), so the design reuses that machinery rather than duplicating it: it executes PDEV-875 first, generalizing SupplierReference into a role-agnostic BusinessRoleReference (a role field; the same { name, eId, affiliateEId, rId, retired, provenance } wire shape), then adds a single-valued manufacturer link on Item.

Two behaviors define the feature. First, resolution: when an item is written with a manufacturer, the backend links the reference to a live MANUFACTURER role — with the caller choosing, via an optional mode parameter that defaults to PROPAGATE, between STRICT (reject an unlinked reference) and PROPAGATE (find-or-create the affiliate + role atomically). Second, consistency: a listener keeps every linked item’s stored manufacturer in sync when the role is renamed or retired — identical to the supplier reverse-sync — which is why the full denormalization (retired / rId / provenance) and the retired guard are kept, not dropped.

The value is surfaced in the item form (Ordering Details typeahead), the catalog filter and column, and the card / Kanban / label / breadcrumb print payloads. Manufacturer-management screens, print-template testData.json, and bulk-import are explicit follow-ups (see Out of Scope).

#DecisionChosen Option
DQ-001Where does find-or-create live?Backend resolver (atomic), not BFF choreography
DQ-002Fixed behavior or caller-selected?STRICT/PROPAGATE mode; optional, defaults to PROPAGATE (client always explicit); hard-required deferred to PDEV-1107
DQ-003New ManufacturerReference or generalize?Generalize SupplierReferenceBusinessRoleReference (execute PDEV-875)
DQ-004Denormalization (retired/rId/provenance) + retired guard?Kept — parity with ItemSupply, forward-compat with mgmt screens
DQ-005Keep the role→item listener?Required — keeps Item.manufacturer.name consistent unless item deleted/retired
DQ-006Lookup endpointReuse the generic role-filtered business-affiliate lookup (role=MANUFACTURER)
DQ-007Frontend contract modeAlways reference — net-new, no legacy/probe
DQ-008Execution scopeFE + BE + printing (incl. Kanban); templates & bulk-import → follow-ups
DQ-009PDEV-875 handlingExecuted inline as Phase 1 T-01; this project closes it

Full rationale in Decision Log.


PlantUML diagram

Per DQ-003, one value object (BusinessRoleReference) serves both links, distinguished by its role. Crucially, role is supplied per slot — a compile-time constant injected by the component factory (VENDOR for the supplier_ref slot, MANUFACTURER for manufacturer_ref) — not a persisted or serialized field. So there is no role column, no migration, and no backfill, and the wire JSON shape stays {name, eId, affiliateEId, rId, retired, provenance} (PDEV-875 invariant). Reads remain single-query: the reference’s denormalized columns live on the owner row and the role is never looked up. ItemSupply.supplier keeps role = VENDOR; Item.manufacturer is a new, single-valued role = MANUFACTURER reference — no primary/secondary, no default pointer (contrast the supplier’s primarySupply/secondarySupply). The reference is a cross-Universe soft reference (no SQL FK); name is the source of truth, eId/affiliateEId link to the role and its parent affiliate.

  • Package: cards.arda.operations.reference.businessaffiliates.business
  • Change: add MANUFACTURER. No DB migration — the column is a free VARCHAR with no CHECK.
  • Design decision: DQ-003.

BusinessRoleReference (was SupplierReference)

Section titled “BusinessRoleReference (was SupplierReference)”
  • Package: cards.arda.operations.reference.businessaffiliates.domain
  • Responsibility: canonical, role-agnostic soft reference from an owner (supply or item) to a BusinessRole.
  • Key fields: name, eId?, affiliateEId?, rId?, retired, provenance?, and role: BusinessRoleType — the latter is a non-serialized, non-persisted field (DQ-010, sub-form a): excluded from the JSON and from the flattened columns, populated per slot at the deserialize/component boundary (supplier_refVENDOR, manufacturer_refMANUFACTURER). It carries a code comment stating it is not persisted and how it is populated; the resolver/listener read it from the VO.
  • Key methods: smart constructor invoke(...)name not blank; eId != null ⇒ affiliateEId != null.
  • Design decision: DQ-003, DQ-004, DQ-010.
  • Package: cards.arda.operations.reference.item.business
  • Change: manufacturer: BusinessRoleReference.Value? (optional). validate allows empty/absent.
  • Persisted via businessRoleReferenceComponent("manufacturer_ref") in ItemPersistence; migration adds the full flattened column set on item (name, entity_id, affiliate_eid, record_id, retired, provenance_*).
  • Design decision: DQ-003, DQ-004.

Manufacturer resolver (generalized from ItemVendorResolver)

Section titled “Manufacturer resolver (generalized from ItemVendorResolver)”
  • Package: cards.arda.operations.reference.item.service
  • Responsibility: resolve a BusinessRoleReference for a target BusinessRoleType under a STRICT/PROPAGATE mode — defaulting to PROPAGATE when the caller omits it (DQ-002, backward-compatible; the frontend still always passes it explicitly).
  • Key method: resolve(ref, role, mode = PROPAGATE, asOf, tenantId, baService): Result<BusinessRoleReference.Value?>. PROPAGATE reuses findByNameAndRole / findByName / createBusinessRole / add and keeps the retired guard (DQ-012 from the supplier work). STRICT validates a fully-linked reference and rejects an unlinked non-empty one.
  • Retirement granularity (DQ-004): per-role when a BusinessRole retires, all-roles when the BusinessAffiliate retires. PROPAGATE may attach a MANUFACTURER role to a live affiliate even if that affiliate’s VENDOR role is retired, but refuses a retired affiliate (no resurrection).
  • Design decision: DQ-001, DQ-002.
  • Package: cards.arda.operations.reference.item.service
  • Responsibility: react to MANUFACTURER role rename/retire and keep linked items’ stored manufacturer consistent.
  • New query: findItemsByManufacturerRole(roleEId, asOf) — tenant-scoped, the item-level analog of CrossItemSupplyUniverse.findSuppliesBySupplierRole.
  • Design decision: DQ-005.

Manufacturer resolution during an item write (happy path shown for PROPAGATE).

PlantUML diagram

Entry point is the item create/update service, which passes the caller-supplied mode to the resolver before persisting, inside the existing withTenant { … } transaction — so affiliate/role creation and the item write commit or roll back together (DQ-001). Resolution runs only when the incoming manufacturer name/link differs from the stored one. Error paths: under STRICT, a non-empty reference lacking eId/affiliateEId fails validation (400); under PROPAGATE, linking to a retired affiliate is rejected by the retired guard; a blank/absent name stores null (unlinked, no resolution).

Consistency reaction when a manufacturer role is renamed or retired.

PlantUML diagram

The listener is registered via BusinessAffiliateService.addRoleObserver and reacts only to MANUFACTURER notifications (it ignores VENDOR and others). Items already deleted/retired are skipped (DQ-005). The refresh is projection-only (no re-resolution) to avoid a cascade loop, and follows the same bitemporal heal-on-write discipline (coordinate-tie avoidance) established by the supplier work.

Item create/update gains an optional resolution-mode parameter that defaults to PROPAGATE when omitted (DQ-002). Making it required-with-no-default would break every existing caller of the shared item-write endpoint (a missing required field is rejected, unlike an unknown extra field which the backend ignores) and would break independent backend-before-frontend deploys — so the hard requirement is deferred to PDEV-1107. The Arda frontend always sends the mode explicitly, so intent is explicit within our stack; the default exists only for not-yet-upgraded callers.

  • Method: POST / PUT
  • Path: existing item routes (e.g. /v1/reference-data/item/item), plus optional mode parameter resolution-mode=STRICT|PROPAGATE (query or body field; absent ⇒ PROPAGATE).
  • Authentication: Bearer API key + tenant/author headers (unchanged).
  • Request: item payload with manufacturer: BusinessRoleReference ({ name, eId?, affiliateEId?, … }); the role (MANUFACTURER) is implied by the manufacturer slot and is not part of the serialized payload (DQ-010); manufacturer omitted/blank ⇒ unlinked.
  • Response: the persisted item with the resolved (linked) manufacturer reference.
  • Error responses:
    • 400STRICT with a non-empty but unlinked reference. (Omitting resolution-mode is not an error today — it defaults to PROPAGATE; it becomes an error under PDEV-1107.)
    • 409/validation — PROPAGATE attempt to link a retired affiliate (retired guard).
    • 500 — persistence/transaction failure (whole write rolls back).

Manufacturer suggestions reuse the existing generic lookup (DQ-006):

  • Method: GET
  • Path: /v1/reference-data/business-affiliate/lookup?name=<search>&role=MANUFACTURER
  • Response: BusinessAffiliateLookupResult (entries carry the ids needed to build a linked reference).

FilePackage/PathPurpose
V0xx__item_manufacturer_ref.sqloperations item migrationsAdd manufacturer_ref_* columns to item
manufacturer resolver entry point…/reference/item/serviceThin MANUFACTURER entry into the shared resolver
listener + cross query…/reference/item/service + …/item/persistencefindItemsByManufacturerRole + role reaction
ManufacturerTypeahead (or reuse AsyncTypeahead)arda-frontend-app src/components/itemsOrdering-Details typeahead
lookupManufacturers + adapterarda-frontend-app src/libGeneric-lookup client + …AsOptions adapter
BFF routearda-frontend-app src/app/api/arda/…/lookup-manufacturersPass-through to generic lookup
FileChange Description
SupplierReference.ktBusinessRoleReference.kt (+ component, factory, ~46 imports)PDEV-875 rename + role field
BusinessAffiliate.kt (BusinessRoleType)Add MANUFACTURER
Item.kt, ItemPersistence.ktAdd manufacturer field + component
ItemVendorResolver / item CRUD serviceGeneralize to role + mode; thread resolution-mode
ItemSupplyRoleListener (or peer) + item service/cascadeReact to MANUFACTURER
item print builders — ItemPrinter.kt, ItemPrintingService.kt, KanbanCardPrinter.ktThread item_manufacturer (card/Kanban) / manufacturer (label/breadcrumb)
FE types/items.ts, types/arda-api.ts, constants/types.ts, mappers/ardaMappers.ts, store/slices/cacheSlice.ts, ItemFormPanel.tsx, items grid/filter configManufacturer field, always-reference mapping, typeahead, filter, column
  • Manufacturer management screens (managed entity CRUD/merge). Denormalized fields are added now to support them later (DQ-004).
  • Print-template layout / testData.json in the printing-templates repo — follow-up ticket (DQ-008).
  • Bulk-import round-trip of item.manufacturer — follow-up ticket, created after the backend is complete (DQ-008).
  • api-test/Bruno API tests — backend behavior is covered by ContainerizedPostgres integration tests.

Per-task TDD: unit tests first (compiling, failing), then implementation, then integration/E2E, then a self-contained commit. The PDEV-875 rename is a refactor — the existing suite stays green.

TestTargetValidates
smart-constructor incl. roleBusinessRoleReference.Valueinvariants (blank name; eId ⇒ affiliateEId)
resolver — default modemanufacturer resolverabsent resolution-mode resolves as PROPAGATE (backward-compatible)
resolver — STRICTmanufacturer resolverrejects non-empty unlinked reference; passes fully-linked
resolver — PROPAGATEmanufacturer resolverfind existing / reuse same-name BA / create new / retired guard
component round-tripBusinessRoleReferenceComponentflatten/materialize incl. manufacturer_ref_*
mapper round-tripFE ardaMappersalways-reference build + read incl. empty/undefined
typeaheadFE ManufacturerTypeahead/adaptersuggestions, select existing, add new, clear
TestSetupValidates
item create/update round-tripContainerizedPostgresmanufacturer persisted + re-linked; both modes
listener renameContainerizedPostgresrole rename updates linked items’ manufacturer.name
listener retireContainerizedPostgresrole retire marks linked items’ reference retired
deleted/retired item skippedContainerizedPostgreslistener does not touch deleted/retired items
generic lookupContainerizedPostgresrole=MANUFACTURER returns matching affiliates
VENDOR non-regressionContainerizedPostgressupplier path unchanged after generalization
TestValidates
create item w/ manufacturer (existing + new via typeahead), reopen, edit, clearform round-trip
filter catalog by manufacturer; toggle Manufacturer columncatalog integration

  • Goal
  • Decision Log
  • Project Plan
  • Workbook analysis — workbooks/notebooks/manufacturer-field/ba-manufacturer-changes-analysis.md
  • Supplier/vendor precedent — PDEV-731, PDEV-928 (operations businessaffiliates + item modules)


Copyright: (c) Arda Systems 2025-2026, All rights reserved