Skip to main content

Factory Configuration

Per-factory key/value config: keys, readers, parsers, AdminJS input dispatch, seed-migration patterns.

Deep-dive doc split out of .claude/rules/architecture.md (which is now an index). Append new findings about this area here, not to the index.

Factory Configuration (FactoryConfiguration)

Separate from FeatureFlag — a key/value store for tunable runtime parameters, scoped per factory. Schema: factoryCode (nullable VarChar(20) FK → Factory.factoryCode, ON DELETE SET NULL ON UPDATE CASCADE), configKey (VarChar(100)), configValue (VarChar(500), all values stored as strings — callers cast to Number/Boolean), description, isActive (Boolean default true, treated as "use default" when false), audit columns. Uniqueness is composite @@unique([factoryCode, configKey]) so each factory holds its own row per key. The factoryCode column is kept nullable to leave room for future genuinely-global keys, but every key shipped so far is per-factory.

  • Model: src/models/factoryConfiguration/factoryConfiguration.model.ts — class with static methods. Every reader takes (factoryCode: string | null, tx?); passing null short-circuits to the hardcoded default (no NULL/global row is consulted). Reads use findFirst({ where: { factoryCode, configKey, isActive: true } }); inactive rows / missing rows fall back to a per-key hardcoded default (e.g., DEFAULT_ESTIMATED_DAYS = 14, isHangtagZplFormat → false).

  • Service: src/services/factoryConfiguration/factoryConfiguration.service.ts — thin pass-through to model with the same (factoryCode, tx?) shape.

  • Admin resource: src/routers/admin/resources/factoryConfiguration/factoryConfiguration.ts — gated by adminRoleAuth, sets createdBy/updatedBy via a before hook on the new action. The factoryCode FK auto-renders as a Factory dropdown via @adminjs/prisma.

  • Consumers: getLeadTimeDays(factoryCode, category, tx?) (per-category key via CATEGORY_LEAD_TIME_KEYS: lead-time-days-dress / lead-time-days-scarf / lead-time-days-jewelry — supersedes an earlier single global getEstimatedDeliveryDays/estimated-delivery-days key, which no longer exists in the codebase; falls back to hardcoded DEFAULT_ESTIMATED_DAYS = 14 when category/factoryCode is null or no active row matches), getSkuLimit(factoryCode, category, tx?) (per-category key via CATEGORY_SKU_LIMIT_KEY: max-dress-sku-limit / max-scarf-sku-limit / max-jewelry-sku-limit; falls back to per-category hardcoded SKU_LIMIT_DEFAULTS — dress=2, scarf=12, jewelry=3 — formerly src/constants/shipmentSkuLimit.ts, now folded into the model as the single source of truth), isHangtagZplFormat (key hangtag-zpl-format, one row per factory seeded false), isDoubleColumnHangtag (key double-column-hangtag, one row per factory seeded false; Milly auto-flipped to true by the seed migration via factoryNameEn match), getHangtagZplDpmm (key hangtag-zpl-dpmm, one row per factory seeded '8'; Milly auto-flipped to '24' via factoryNameEn match — drives ^PW/^LL/font/barcode scaling in the ZPL template), getHangtagZplDarkness (key hangtag-zpl-printer-darkness, one row per factory seeded '' with isActive=FALSE so ^MD is omitted by default; returns number | null rather than a typed default — the renderer treats null as "omit ^MD"), isHidePOTotalCostEnabled (key hide-po-total-cost, boolean, one row per factory seeded 'false' by 20260616100000_add_hide_po_total_cost_config; see PO Total Cost Hiding under Admin Resource Patterns). Pattern when adding a new key: add a static reader on the model with the (factoryCode, tx?) signature, expose via the service, and let callers parse the typed value (Number(...) for numerics, value === 'true' for booleans, allow-list-Set match for enum strings).

  • Caller pattern for resolving factoryCode: when only factoryId is in scope (e.g., on a bare PurchaseOrder record), look up the factory via FactoryService.findUnique({ where: { factoryId } }) and pass factory?.factoryCode ?? null. Avoid adding include: { factory: true } to upstream loads just for this — the targeted findUnique is cheaper and keeps the caller chain unchanged. Example: _makeWorkOrderEstimatedDates in src/services/workorder/workorder.service.ts — calls getLeadTimeDays only as a fallback when poRecord.c2sSla (a per-PO override field) is unset; validateShipmentSkuLimitsByCategory in src/services/shipment/shipment.service.ts is the sole caller of getSkuLimit.

  • Migration pattern for changing one factory's per-category config value (established by 20260615201713_lead_time_days_per_category, reused verbatim by 20260714000001_seed_jewelry_factory_config): seed the key as an inactive/default row for every factory first (idempotent via ON CONFLICT ("factoryCode","configKey") DO NOTHING), then a DO $$ ... UPDATE ... FROM "Factory" AS f WHERE fc."configKey" = '<key>' AND fc."factoryCode" = f."factoryCode" AND LOWER(TRIM(f."factoryNameEn")) = '<name>' ... GET DIAGNOSTICS rows_flipped = ROW_COUNT; RAISE NOTICE ... $$ block flips that one factory's row to the real value + isActive = TRUE. Matching on case-insensitive/trimmed factoryNameEn (not factoryCode) makes the migration environment-agnostic (factoryCode can differ per env). A later migration that only needs to update an already-seeded value (no new key, no new rows) can skip the seed step and go straight to the DO $$ UPDATE ... $$ block scoped by factoryNameEn.

  • Gotcha — the Jewelry-category factory's factoryNameEn codename is 'Enigma', not 'Jewelry'. 20260714000001_seed_jewelry_factory_config originally matched factory."factoryNameEn" = 'Jewelry'; INFRA-577 (#419) edited that already-applied migration file in place to correct it to 'Enigma' (the factory's actual display name), touching no new migration. Two consequences for any later migration that targets this factory (e.g. 20260716090000_update_jewelry_lead_time_to_7, INFRA-580): (1) match on 'enigma', not 'jewelry' — always re-read the seed migration's current file content rather than trusting an earlier read from the same or a prior session, since the file's content can silently change underneath a stale mental model; (2) explicitly SET "isActive" = TRUE in the flip, don't rely on the row already being active — any environment where the seed migration ran before INFRA-577's in-place correction landed inserted the row with isActive = FALSE (the name didn't match at insert time), so a value-only update has no runtime effect on getLeadTimeDays/getSkuLimit until the row is explicitly reactivated. Editing an already-applied migration's SQL also changes its _prisma_migrations.checksum — worth flagging if prisma migrate deploy ever starts rejecting deploys with a "migration modified" error in an environment that already ran the pre-edit version.

  • Two interchangeable idempotency idioms exist across the config migrationsON CONFLICT ("factoryCode","configKey") DO NOTHING (20260511193617, 20260519170000, 20260615201713) and WHERE NOT EXISTS (SELECT 1 FROM "FactoryConfiguration" fc WHERE fc."factoryCode" = … AND fc."configKey" = …) (20260616084818, 20260616100000, 20260714000001). Both work; ON CONFLICT is the shorter form and relies on the composite unique index. createdBy/updatedBy is 'Birdy Bot' in the earlier set (matching factoryConfigurationBeforeNewHook's system-create convention) and 'migration' in the SKU-limit/jewelry set — 'Birdy Bot' is the intended convention; prefer it for new migrations. description is populated inconsistently (the ZPL keys got theirs retroactively in 20260519180000_describe_hangtag_zpl_configs, scoped to description IS NULL OR = '' so ops edits aren't clobbered) — worth setting up front on new rows since it's what operators see in AdminJS.

  • Full key set a factory needs (11 rows, as of INFRA-613): lead-time-days-dress, lead-time-days-scarf, lead-time-days-jewelry, max-dress-sku-limit, max-scarf-sku-limit, max-jewelry-sku-limit, hangtag-zpl-format, double-column-hangtag, hangtag-zpl-dpmm, hangtag-zpl-printer-darkness, hide-po-total-cost. Because each was seeded by a different historical migration that ran FROM "Factory" at its own point in time, this list only exists implicitly — a factory row created later than all of them gets none of them. When onboarding a factory, seed all 11 in one migration rather than assuming any earlier one will catch it.

Config-key registry + AdminJS input dispatch

Two collaborating pieces wire a configKey into AdminJS so editors get the right form input:

  • src/constants/factoryConfigurationKeys.ts is the single source of truth: it exports FACTORY_CONFIG_KEYS (as const literal map) and a FactoryConfigKey union type, plus companion Set<string> allow-lists for per-value-shape rendering (today: BOOLEAN_FACTORY_CONFIG_KEYS). The model layer references the literal keys when building where clauses, the AdminJS component references the Sets, and the seed migrations reference the string values directly (SQL can't import TS). When adding a new key with a non-string value shape, define a new Set here (e.g., NUMERIC_ENUM_FACTORY_CONFIG_KEYS) — don't hardcode the key list inside the component.
  • src/components/properties/ConfigValueInput/ConfigValueInput.tsx is the dispatcher mounted on the configValue property of the FactoryConfiguration resource (src/routers/admin/resources/factoryConfiguration/factoryConfiguration.ts:32-36). It reads record.params.configKey at render time, looks the key up in the relevant Set, and renders a <Select> for booleans or a plain <Input type="text"> otherwise. Both the new and edit forms share this component (AdminJS reuses the edit slot for both). To add a new rendering shape, extend the dispatcher with another branch and a new Set in the constants file — keep the allow-list logic in the constants file, not in the component.

Model-layer value parsers

Each non-string value shape gets a tiny private parser inside the model (src/models/factoryConfiguration/factoryConfiguration.model.ts), kept symmetric with the AdminJS input shape:

  • BooleansparseBooleanConfigValue(value) returns value?.toLowerCase() === 'true'. Case-insensitive on purpose (defensive against pre-dropdown / raw-SQL edits); strict on aliases ('1', 'yes' read as false).
  • Numerics → inline Number(...) + Number.isFinite + range check, falling back to a per-key hardcoded default. See getEstimatedDeliveryDays (zero / negative / NaN → DEFAULT_ESTIMATED_DAYS = 14).

Pattern: keep the parser private to the model file; expose only typed readers (Promise<number>, Promise<boolean>, etc.) to the service layer. The service is a 1:1 pass-through and never sees raw configValue strings.