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?); passingnullshort-circuits to the hardcoded default (no NULL/global row is consulted). Reads usefindFirst({ 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 byadminRoleAuth, setscreatedBy/updatedByvia abeforehook on thenewaction. ThefactoryCodeFK auto-renders as a Factory dropdown via@adminjs/prisma. -
Consumers:
getLeadTimeDays(factoryCode, category, tx?)(per-category key viaCATEGORY_LEAD_TIME_KEYS:lead-time-days-dress/lead-time-days-scarf/lead-time-days-jewelry— supersedes an earlier single globalgetEstimatedDeliveryDays/estimated-delivery-dayskey, which no longer exists in the codebase; falls back to hardcodedDEFAULT_ESTIMATED_DAYS = 14whencategory/factoryCodeis null or no active row matches),getSkuLimit(factoryCode, category, tx?)(per-category key viaCATEGORY_SKU_LIMIT_KEY:max-dress-sku-limit/max-scarf-sku-limit/max-jewelry-sku-limit; falls back to per-category hardcodedSKU_LIMIT_DEFAULTS— dress=2, scarf=12, jewelry=3 — formerlysrc/constants/shipmentSkuLimit.ts, now folded into the model as the single source of truth),isHangtagZplFormat(keyhangtag-zpl-format, one row per factory seededfalse),isDoubleColumnHangtag(keydouble-column-hangtag, one row per factory seededfalse; Milly auto-flipped totrueby the seed migration viafactoryNameEnmatch),getHangtagZplDpmm(keyhangtag-zpl-dpmm, one row per factory seeded'8'; Milly auto-flipped to'24'viafactoryNameEnmatch — drives^PW/^LL/font/barcode scaling in the ZPL template),getHangtagZplDarkness(keyhangtag-zpl-printer-darkness, one row per factory seeded''withisActive=FALSEso^MDis omitted by default; returnsnumber | nullrather than a typed default — the renderer treatsnullas "omit^MD"),isHidePOTotalCostEnabled(keyhide-po-total-cost, boolean, one row per factory seeded'false'by20260616100000_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 onlyfactoryIdis in scope (e.g., on a barePurchaseOrderrecord), look up the factory viaFactoryService.findUnique({ where: { factoryId } })and passfactory?.factoryCode ?? null. Avoid addinginclude: { factory: true }to upstream loads just for this — the targetedfindUniqueis cheaper and keeps the caller chain unchanged. Example:_makeWorkOrderEstimatedDatesinsrc/services/workorder/workorder.service.ts— callsgetLeadTimeDaysonly as a fallback whenpoRecord.c2sSla(a per-PO override field) is unset;validateShipmentSkuLimitsByCategoryinsrc/services/shipment/shipment.service.tsis the sole caller ofgetSkuLimit. -
Migration pattern for changing one factory's per-category config value (established by
20260615201713_lead_time_days_per_category, reused verbatim by20260714000001_seed_jewelry_factory_config): seed the key as an inactive/default row for every factory first (idempotent viaON CONFLICT ("factoryCode","configKey") DO NOTHING), then aDO $$ ... 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/trimmedfactoryNameEn(notfactoryCode) 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 theDO $$ UPDATE ... $$block scoped byfactoryNameEn. -
Gotcha — the Jewelry-category factory's
factoryNameEncodename is'Enigma', not'Jewelry'.20260714000001_seed_jewelry_factory_configoriginally matchedfactory."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) explicitlySET "isActive" = TRUEin 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 withisActive = FALSE(the name didn't match at insert time), so a value-only update has no runtime effect ongetLeadTimeDays/getSkuLimituntil the row is explicitly reactivated. Editing an already-applied migration's SQL also changes its_prisma_migrations.checksum— worth flagging ifprisma migrate deployever 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 migrations —
ON CONFLICT ("factoryCode","configKey") DO NOTHING(20260511193617,20260519170000,20260615201713) andWHERE NOT EXISTS (SELECT 1 FROM "FactoryConfiguration" fc WHERE fc."factoryCode" = … AND fc."configKey" = …)(20260616084818,20260616100000,20260714000001). Both work;ON CONFLICTis the shorter form and relies on the composite unique index.createdBy/updatedByis'Birdy Bot'in the earlier set (matchingfactoryConfigurationBeforeNewHook's system-create convention) and'migration'in the SKU-limit/jewelry set —'Birdy Bot'is the intended convention; prefer it for new migrations.descriptionis populated inconsistently (the ZPL keys got theirs retroactively in20260519180000_describe_hangtag_zpl_configs, scoped todescription 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 ranFROM "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.tsis the single source of truth: it exportsFACTORY_CONFIG_KEYS(as constliteral map) and aFactoryConfigKeyunion type, plus companionSet<string>allow-lists for per-value-shape rendering (today:BOOLEAN_FACTORY_CONFIG_KEYS). The model layer references the literal keys when buildingwhereclauses, 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.tsxis the dispatcher mounted on theconfigValueproperty of the FactoryConfiguration resource (src/routers/admin/resources/factoryConfiguration/factoryConfiguration.ts:32-36). It readsrecord.params.configKeyat render time, looks the key up in the relevant Set, and renders a<Select>for booleans or a plain<Input type="text">otherwise. Both thenewandeditforms share this component (AdminJS reuses theeditslot 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:
- Booleans →
parseBooleanConfigValue(value)returnsvalue?.toLowerCase() === 'true'. Case-insensitive on purpose (defensive against pre-dropdown / raw-SQL edits); strict on aliases ('1','yes'read asfalse). - Numerics → inline
Number(...)+Number.isFinite+ range check, falling back to a per-key hardcoded default. SeegetEstimatedDeliveryDays(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.