Skip to main content

Style Detail, Measurements & Sizes

Style Show/Edit/New UI, garment measurements, XLSX round-trip, size ordering, Measurement library.

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.

Size Ordering (Size.sortOrder)

The Size table has a nullable sortOrder Int? column (INFRA-497) that drives apparel-order display (XXS, XS, S, … 5X) on the Style Measurement UI and in the measurement XLSX export. Before it, every size list ordered by sizeId (insertion order), so a later-added size like XXS rendered last.

  • Single source of truth: src/constants/sizeOrder.ts — exports CANONICAL_SIZE_ORDER (the ordered apparel-size list), SIZE_SORT_ORDER_GAP = 10, and computeSizeSortOrder(size, currentMaxSortOrder). Known sizes map to (index + 1) * 10 (XXS=10 … 5X=130); an unknown size is appended at currentMaxSortOrder + 10. The gap of 10 lets a new size be inserted between two existing ones without renumbering. This one helper is consumed by all four write paths so ordering logic never drifts:
    1. Backfill scriptscripts/backfill-size-sort-order.ts (one-off, --dry-run/--help; uses SizeModel.findMany + SizeModel.update + disconnectPrisma). Run once per environment after the migration. Realigns known sizes to their canonical slot and appends null-valued unknowns after the highest existing value (in sizeId order). Safe to re-run: it preserves an unknown size an admin has already positioned (non-null sortOrder), so a re-run never clobbers a manual reposition.
    2. Airtable syncSizeModel.upsertByAirtableRecordId (src/models/size/size.model.ts) sets sortOrder only on the create branch: it queries tx.size.aggregate({ _max: { sortOrder } }) and calls computeSizeSortOrder(data.size, max ?? 0). The update branch and the orphan-link update omit sortOrder, so an admin/backfilled value is preserved across every re-sync. A new size from Airtable lands in canonical position (if known) or appended (if unknown).
    3. Seedprisma/seed/seed-sizes.ts precomputes each sortOrder sequentially before the (concurrent) create batch, seeding the running max from the DB's current max so an unknown size in sizes.json appends instead of colliding at the canonical XXS slot. sizes.json carries only sizeCode + size — it does not own ordering (deliberately decoupled so the seed file never drifts from the canonical list).
    4. Manual AdminJS create/edit — the Size resource (extracted to src/routers/admin/resources/size/size.ts, mirroring measurement.ts) registers before hooks sizeBeforeNewHook/sizeBeforeEditHook that set audit fields (createdBy on create, updatedBy always, via a private setAuditFields) and call sizeAutoSortOrderBeforeHook — assigns sortOrder via the helper when the form omits it (respecting an explicit, numeric typed value incl. 0; a non-numeric value is Number.isFinite-guarded and falls through to the helper rather than writing NaN). Needed because @adminjs/prisma's default create bypasses SizeModel, so without the hook a manual "New Size" saved null sortOrder/createdBy.
  • Read path: the four size queries in src/services/style/style.service.ts (findAddStylePrepareDatas, makeStyleMeasurementsExcel, updateByMeasurementExcelRows, tranMeasurementExcelRows2StyleMeasurementInfos) use orderBy: { sortOrder: { sort: 'asc', nulls: 'last' } }. These feed the Show/Edit/New measurement tables (record.params.sizeRecords) and the XLSX export columns; there is no separate frontend size sort. nulls: 'last' keeps a row created outside the four paths (still null) at the end rather than hidden.
  • Admin list: the Size resource sets options.sort: { sortBy: 'sortOrder', direction: 'asc' }; sortOrder auto-renders as an editable field for manually repositioning an unknown new size.
  • Nullable on purpose: every create path now assigns it, but the column stays nullable as a safety net. Migration 20260528000000_add_size_sort_order is ADD COLUMN only; value backfill is the script's job, never SQL.

Gotcha — a fifth insert path bypassed all of this (INFRA-584). scripts/backfill-airtable-sizes.ts (the standalone Size backfill/resync script, distinct from the live webhook's syncAirtableSizeSizeModel.upsertByAirtableRecordId path) has its own "genuinely new Airtable record" branch in processSizeRecord that, before INFRA-584, called a bare SizeModel.create({ data: { airtableRecordId, sizeCode, size, airtableSyncedAt } }) — silently omitting sortOrder, since SizeModel.create is a plain Prisma passthrough with none of upsertByAirtableRecordId's canonical-lookup/max-aggregate logic. Fixed by routing that branch through SizeModel.upsertByAirtableRecordId({ airtableRecordId, data: { sizeCode, size } }) instead — identical to the pattern the same file's orphan-link branch already used a few lines above — rather than duplicating the sortOrder computation inline. SizeModel.create itself is intentionally left as-is (still used by scripts/insert-ambiguous-legacy-sizes.ts, which computes sortOrder inline for its own reasons — see INFRA-571). Why Staging never caught this: Staging's reset-stale-airtable-record-ids.ts had nulled the legacy pre-Airtable Size rows' airtableRecordId, so every duplicate-labeled Airtable record synced afterward found an orphan and got funneled into the (unaffected) ambiguous-detection / orphan-link branches instead of the buggy bare-create branch. Production never runs that reset, so its legacy rows stayed linked — meaning genuinely-distinct same-labeled Airtable records (multiple Size Ranges sharing a label like "XS") had no orphan to match and fell straight into the buggy branch. Lesson: this script's insert path must always go through a model method that assigns sortOrder, never a bare create, or a future path added here will reproduce the same gap.

Style Detail Page (Show / Edit / New + Garment Measurements)

The Style resource (src/routers/admin/resources/style/style.ts) renders a custom React component per action rather than AdminJS defaults:

  • showComponents.StyleShow (handler viewActionHandler), editComponents.StyleEdit (handler editActionHandler), newComponents.StyleNew (handler newActionHandler). viewConstructionNotes / editConstructionNotes reuse the view/edit handlers but render the MaterialConstructions table instead of measurements (toggled by action.name inside the component).
  • Visibility split: show is adminEditorOrBgusAuth (visible) / adminEditorViewerRoleAuth (accessible); edit/new are adminEditorRoleAuth. So viewers/BGUS can open Show but only admin/editor reach Edit.

Server-side data assembly — getStyleRecord (src/routers/admin/resources/style/utils.ts). On GET for show/edit/delete handlers, it calls StyleService.findStyleWithRelationLogError and folds the result into record.params:

  • ...result.styleRecord — includes garmentMeasurement (only rows attached to this style, isDeleted:false, ordered by measurement.sortOrder) and materialConstruction.
  • measurementRecords — the full global Measurement library, not just this style's points. Built by findAddStylePrepareDatas (src/services/style/style.service.ts) via MeasurementService.findMany({ take: 100, orderBy: { sortOrder: 'asc' } }) (no where). take: 100 is a latent cap — if the library ever exceeds 100 rows it silently truncates (relevant after INFRA-478 lifted the measurement-count cap).
  • materialRecords (full Material library, by materialId), sizeRecords (size list, by sizeId).

after.ts (showAfter/editAfter/newAfter) is a separate path that populates response.record.populated (AdminJS's default relation rendering) and, in newAfter, writes garmentMeasurement/materialConstruction rows from request.payload. The custom React components read record.params (from getStyleRecord), not populated — don't confuse the two when changing what the UI sees.

Frontend merge — useStyleDisplayInfo (src/components/admin/Style/hooks.ts). Produces displayMeasurements by mapping over measurementRecords (the full library) into DisplayMeasurement rows (tolerance: null, sizes: {}), then merging each style's garmentMeasurement values keyed by measurementId. Consequence: every library measurement is already a row in both Show and Edit; points not yet attached to the style render with empty tolerance/sizes. displayConstructions mirrors this for the Material library + materialConstruction. The new-style flow (useAddStylePrepareDatas) calls the addStylePrepareDatas resource action for the same library sets with empty garmentMeasurement/materialConstruction.

StyleGarmentMeasurements.tsx. Receives measurements: DisplayMeasurement[], sizes, editable, lang. Keeps a local copy in useState, initialized once via useEffect guarded by initialized when measurements.length > 0 (handles async-populated new-style data). editable only toggles input enablement + styling today (disabled in Show; editable={!isSaving} in Edit, so it momentarily flips false during save). Exposes a ref (StyleGarmentMeasurementsRef: getLocalGarmentMeasurements, updateLocalGarmentMeasurements) — the Excel-upload flow (useStyleMeasurementsUpload) calls updateLocalGarmentMeasurements to bulk-apply parsed rows. Input validation regex: /^[0-9\/\.\-\%\s]*$/ (rejects letters); on failure a per-cell error tooltip keyed row-${idx}-${field} / row-${idx}-size-${size} shows. idx in the updateField/updateSize handlers indexes into local — any view-mode row filtering must preserve that index mapping (skip rows during .map, don't pre-filter the array).

Save path — makeAddUpdateParams (src/components/admin/Style/utils.ts). StyleEdit.handleSubmit reads the ref's local rows, diffs them against the original garmentMeasurement keyed ${measurementId}-${size}: a (measurementId,size) pair absent from the original map → new create (garmentMeasurementId: null); changed tolerance/measurementValue → update. Special case: a row with a string tolerance but no size entries gets all sizes initialized. Result is serialized as garmentMeasurementsJson / materialConstructionsJson and sent via submit(...). Server: editActionHandlerprepareDataForUpdateStyleService.updateStyleWithRelationLogError, which upserts via GarmentMeasurementModel.upsertMany (raw INSERT ... ON CONFLICT ("styleId","measurementId","size") DO UPDATE). So filling a value on a previously-unattached library point already creates a new GarmentMeasurement on save — no extra wiring needed for that.

INFRA-479 view-mode filtering. In Show, StyleGarmentMeasurements hides rows where every size value is empty/null AND tolerance is empty; in Edit it renders the full merged set. The filter is gated on a dedicated viewMode?: boolean prop, not on editable — important because editable oscillates during save (editable={!isSaving} in StyleEdit) and would briefly hide rows the user just cleared. StyleShow passes viewMode; StyleEdit lets it default to false. Implemented as a memoized visibleRows = useMemo(() => local.map((m, idx) => ({ m, idx })).filter(({ m }) => !viewMode || isRowPopulated(m)), [local, viewMode]) projection — each entry carries its original local index, so the updateField/updateSize handlers (which use that idx to write back into local) keep working unchanged. The empty-state row keys off visibleRows.length, not local.length, and uses the Style.measurementNoData locale key (separate from the styles-list noResults). The isRowPopulated helper lives at the top of the same file: Boolean(m.tolerance?.trim()) || Object.values(m.sizes ?? {}).some(s => Boolean(s?.measurementValue?.trim())). Edit-mode behavior and save path are unchanged — the loader already supplies the full library, so this is a pure presentational filter.

Style Measurement XLSX Export / Import

Style-scoped measurement spreadsheet round-trip, separate from the in-page Edit form (which it feeds via preview):

  • Routes (src/routers/api/style/v1/style.router.ts): GET /style/v1/exportMeasurementExcel?style=<styleNumber> and POST /style/v1/uploadMeasurementExcel (multipart). Session-authed like the rest of the style API.
  • Export — StyleService.makeStyleMeasurementsExcel(styleNumber) (src/services/style/style.service.ts:280): loads the full Measurement library via MeasurementService.findMany({ orderBy: { sortOrder: 'asc' } })no take: 100 cap (that cap lives only in findAddStylePrepareDatas); sizes ordered sortOrder asc, nulls last. Builds one row per library measurement (a Map<measurementId, info>), then folds the style's GarmentMeasurement rows in — unattached points keep blank tolerance/size cells. Tolerance is taken from the first garment row that has one (hasTolerance flag), i.e. tolerance is per-measurement, not per-size. Stale garment rows whose measurementId no longer exists in the library are silently skipped. Columns built with makeExcelColumn(key, width) (header === key, numFmt: '@' text format). Cell locking: sheet.protect('', {...}) (empty password) is applied, then every body cell is set protection: { locked: false } + UNLOCKED_FILL — so all columns (including measurementId) are editable. Only the header row is locked (re-styled bold + HEADER_FILL last). (Per INFRA-493 follow-up the measurementId column is no longer locked; the LOCKED_FILL/LOCKED_FONT constants remain only because the construction-notes export still uses them.)
  • File name is generated in the controller (exportMeasurementExcel, src/controllers/style/style.controller.ts): measurements_${styleNumber}_${formatDateForInput(new Date())}.xlsx, sent via sendExcelFile. The frontend (fetchExportMeasurementExcel in src/components/utils/fetchUtils.ts) prefers the Content-Disposition filename and falls back to building the same name client-side.
  • Import — uploadMeasurementExcel controller: multer memory storage, .xlsx ext + xlsx MIME only, 10MB cap, wrapped in a promisified handleExelFile so multer errors become makeBadRequestError. Pipeline: tranFile2MeasurementExcelRows (parse) → Zod (StyleUploadMeasurementExcelSchemaOpts) → in-cell style check (req.body.styleNumber === rows[0].styleNumber) → tranMeasurementExcelRows2StyleMeasurementInfos → JSON response. Preview-only: the endpoint never writes to the DB. The frontend (fetchUploadMeasurementExceluseStyleMeasurementsUploadupdateLocalGarmentMeasurements ref call) applies parsed rows to the Edit form's local state; persistence happens only when the user saves the Style edit form (the garmentMeasurementsJson path above).
  • Header-keyed parsing: excelFile2List (src/utils/excelUtils.ts) maps cells to object keys by header-row string, not column position — so reordering export columns can't break import. A formatter map allows per-column coercion (measurementId: (c) => Number(c.value)); all other numeric cells are stringified post-parse. Numeric cells respect numFmt via the numfmt package.
  • Upload schema (src/schemas/style/index.ts): array of row objects — styleNumber (via shared styleValidator()), developmentId required, styleNameCn/styleNameEn optional, measurementId z.coerce.number().positive().int(), tolerance optional, measurement names z.any() (reference-only), and .catchall(z.string().max(25)) for the dynamic size columns.
  • Dead code (as of INFRA-493 exploration): updateByMeasurementExcelRows (style.service.ts:434) — a full diff-and-persist path (upsert + delete inside a $transaction, with a judgeGarmentAction state-machine actionMap comparing file vs DB cell states) — has no callers; the upload endpoint is preview-only and the save goes through the Style edit form instead. The judgeGarmentAction/actionMap machinery at the bottom of the file exists solely for it.
  • The multer upload field is file; the on-screen style is passed alongside as form-data styleNumber (fetchUploadMeasurementExcel appends file.name as originalname, so the original client file name reaches req.file.originalname).
  • style.service.spec.ts exists (covers makeStyleMeasurementsExcel, tranMeasurementExcelRows2StyleMeasurementInfos, updateStyleWithRelationLogError, findStyleWithRelationLogError, exportConstructionNotesAsExcel) — a prior architecture.md pass claimed no spec existed for this file; that was stale even before INFRA-565. No spec exists for style.controller.ts (or the style router) — tests for those must still be created from scratch following the controller/router spec conventions used elsewhere. Before trusting an architecture.md claim that a spec file is missing, ls the directory — Write will refuse to overwrite an unread existing file, which is the fastest way to catch a stale claim like this one.

Style Name Max Length — Three Independent Enforcement Points (INFRA-575)

Style.styleNameCn/styleNameEn length is enforced in three separate places with no shared constant — a change to one silently leaves the others out of sync:

  1. DB column (prisma/schema.prisma, Style.styleNameCn/styleNameEn @db.VarChar(100) as of INFRA-575, previously VarChar(50)) — the ultimate ceiling; a value that gets past both Zod schemas below but exceeds this still fails at the DB.
  2. Airtable Style webhook schema (src/schemas/webhooks/index.ts, AirtableStyleWebhookBodySchema.styleName) — validates the incoming Airtable sync payload. This is what surfaced the INFRA-575 bug: a real Airtable style name over 50 characters (a jewelry-category product name, "North South Emerald Cut Diamond 14K Solid White Gold", 52 chars) got rejected with a 400 before ever reaching the DB.
  3. Style Measurement Excel-upload row schema (src/schemas/style/index.ts, StyleUploadMeasurementExcelSchemaOpts) — a different, easily-confused schema: it validates styleNameCn/styleNameEn as reference-only display columns in each parsed Excel row (see § "Style Measurement XLSX Export / Import" above), not the Style record itself — the upload endpoint is preview-only and never writes these fields anywhere. Before INFRA-575 this was max(35), already stricter than the DB column's VarChar(50), unrelated to and inconsistent with the webhook schema's max(50).

Not a validation surface at all: the AdminJS New/Edit Style form (src/components/admin/Style/{StyleNew,StyleEdit}.tsx, resource config in src/routers/admin/resources/style/style.ts) has no Zod schema and no maxLength/length property on styleNameCn/styleNameEn — it relies entirely on the DB column ceiling. So widening the DB column alone (without touching any admin-side code) is sufficient to let the admin form accept longer names; only the two Zod schemas above need an explicit code change to match.

When changing this limit again: update all three (DB column via migration, webhook schema, Excel-upload schema) together, and don't assume src/schemas/style/index.ts's limit governs the admin create/edit form — it doesn't.

GarmentMeasurement ↔ Size: sizeId FK + POM Size Range scoping (INFRA-565)

GarmentMeasurement.sizeId (Int?, FK → Size.sizeId, ON DELETE SET NULL ON UPDATE CASCADE) is the authoritative link — free-text label matching (the pre-INFRA-565 GarmentMeasurement.size VarChar scalar) is gone from the Prisma model. @@unique([styleId, measurementId, sizeId]) replaces the old ..., size] constraint; @@index([styleId, sizeId]) replaces [styleId, size]. This was needed because INFRA-562 dropped Size.size's uniqueness (the same label can belong to multiple Size Range–scoped Size rows), so label-based joins against the global Size library were a latent collision risk. Consumers, post-migration:

  • src/services/style/style.service.ts — all 4 read/write paths now key by sizeId and scope the size list to the style's own resolved sizeRangeId via the relational filter below, instead of reading the unscoped global library: findAddStylePrepareDatas(sizeRangeId) (feeds Show/Edit via findStyleWithRelation and the New-style form — sizeRangeId === null short-circuits to [], no query), makeStyleMeasurementsExcel (export — builds a sizeId2LabelMap to render the Excel size-label column headers, skips a GarmentMeasurement row whose sizeId doesn't resolve to a column in the style's current range rather than deleting it), tranMeasurementExcelRows2StyleMeasurementInfos (import preview — resolves the style's sizeRangeId from measurementExcelRows[0].styleNumber first). The save path (updateStyleWithRelation) diffs/upserts by sizeId via GarmentMeasurementModel.upsertMany's raw ON CONFLICT ("styleId","measurementId","sizeId"); the delete branch's OR clause is { measurementId, sizeId } pairs.
  • src/components/admin/Style/{hooks.ts,utils.ts,StyleGarmentMeasurements.tsx} — frontend re-keyed in lockstep: DisplayMeasurement.sizes is Record<sizeId, {...}> (not label), the save-path diff key in utils.ts#makeAddUpdateParams is ${measurementId}-${sizeId}, and the rendered table's per-column key={sizeId} in StyleGarmentMeasurements.tsx (the size label is still what's displayed in the header text — only the React key and the data-lookup index changed). A style with no sizeRangeId yields an empty sizes array from the backend; StyleGarmentMeasurements renders an amber banner (Style.noSizeRangeAssigned locale key) instead of an empty table in that case, in both Show and Edit.
  • src/services/changelog/changelog.service.ts (fetchMeasurementChanges) — select: { size: true } became select: { size: { select: { size: true } } } (the relation, selecting the label off it) since the raw scalar no longer exists; falls back to 'Unknown' if the relation is null (unresolved/legacy row).
  • src/services/workorder/workorder.service.ts (_makeWorkOrderDatas) — the composite join key changed from ${styleId}-${size-label} (via a label lookup even though wO.sizeId was already in hand) to ${styleId}-${wO.sizeId} directly — no more label detour.
  • src/models/workorder/workorder.model.ts (findWithRelationsById) filters the loaded style's garmentMeasurement array down to the work order's own size via gM.sizeId === record.sizeId (was a label comparison).
  • admin.router.ts also registers a separate, raw AdminJS CRUD resource for GarmentMeasurement directly against the Prisma model (adminRoleAuth-gated, under productParent) — independent of the custom Style Show/Edit UI. It now renders/edits sizeId as whatever @adminjs/prisma does with a plain FK Int column (no custom dropdown was added). Any future scalar→relation column change on this model must be checked against this resource's default rendering, not just the custom Style components.
  • Backfill: scripts/backfill-garment-measurement-size-id.ts resolves sizeId for any pre-migration row (Style → sizeRangeIdSizeRangeToSize → label match), reading the now-model-absent legacy size column via $queryRaw (the escape hatch — see Soft Deletes section below for the general pattern of reading columns the Prisma model no longer declares). Unresolvable rows (no sizeRangeId, no matching label in range) are reported, not errored, and left for manual review; safe to re-run since only sizeId IS NULL rows are touched. The physical size column drop is a deferred follow-up ticket (expand-contract: this migration only added sizeId and relaxed size to nullable, so prisma migrate deploy running unattended in CI can't destroy the backfill's only data source before the script runs).

Relational scoping pattern: Size carries a reverse relation sizeRangeToSize SizeRangeToSize[] (from INFRA-563), so scoping a Size query to one Size Range doesn't need a new model method — a plain Prisma relation filter works: SizeService.findMany({ where: { sizeRangeToSize: { some: { sizeRangeId } } }, orderBy: {...} }). This is the pattern all 4 style.service.ts read paths now use in place of an unscoped findMany.

Measurement Resource & Limited-User Pattern

The global Measurement library (src/routers/admin/resources/measurement/measurement.ts, a Prisma-backed resource under the "Reference Tables" parent) restricts a set of "limited users" to a guard-railed create/edit experience. The pattern spans three layers:

  1. Identitysrc/constants/measurementUserRestrictions.ts exports MEASUREMENT_LIMITED_EMAILS (a Set of birdygreychina@birdygrey.com (BGC) + bgus@birdygrey.com (BGUS)) and isMeasurementLimitedUser(email). This is the single source of truth for "is this a constrained library editor" and is consumed by both backend hooks and frontend components.
  2. Backend before hooks (in measurement.ts): measurementBeforeNewHook / measurementBeforeEditHook always set audit fields (createdBy/updatedBy) via setAuditFields, then — only for isMeasurementLimitedUser(email) — run measurementAutoSortOrderBeforeHook (assigns sortOrder = max+1 when omitted) and validateMeasurement (requires non-empty EN+CN names; case-insensitive duplicate check against non-deleted rows via prisma.measurement.findFirst, throwing AdminJS ValidationError keyed per-field). Admin/editor users skip this validation and enter sortOrder manually (it's a @unique Int column). measurementBeforeNewHook historically also enforced a hard cap of 25 measurements for limited users (inline literal, not a named constant).
  3. Frontend field hiding — the resource uses globally-overridden action components DefaultNewAction / DefaultEditAction / DefaultShowAction (src/components/common/). Each calls filterFormProperties(resource, action, isRestrictedEmail, TARGET_RESOURCE_ID, HIDDEN_FOR_VIEWER, REQUIRED_FOR_VIEWER) from src/components/utils/. For limited users on the Measurement resource only, this hides createdBy, updatedBy, isDeleted, deletedAt, deletedBy, sortOrder (the HIDDEN_FOR_VIEWER set in measurementsViewerFields.ts) and marks required measurementNameCn/En, unitOfMeasureCn/En (REQUIRED_FOR_VIEWER). AdminJS reuses editProperties for both new and edit, so the create and edit forms hide the same fields. Field visibility for limited users is therefore enforced by these custom components + Sets, not by properties.<field>.isVisible in the resource options (the resource only declares unitOfMeasure{Cn,En}: { isDisabled: true }).

Gotcha — the cap had a frontend mirror. Beyond the backend count >= 25 throw, the "Create new" button on the Measurement list was also hidden client-side in ActionHeader.tsx (shouldHideNew = isMeasurement && isListAction && isLimitedUser && totalRecords >= 25) — so a limited user at 25 rows saw the list but no Create-new button even if the backend allowed it. When changing the measurement cap or limited-user reach, both the backend hook and this ActionHeader gate must move together. INFRA-478 removed both; ActionHeader.tsx no longer imports isMeasurementLimitedUser.

Delete is gated by adminRoleAuth (visible) / adminEditorRoleAuth (accessible), so BGC/BGUS (neither admin nor editor) cannot soft-delete.

Unit-of-measure defaults (two layers). unitOfMeasureCn/En are isDisabled in resource options (read-only in forms) — so no role can type them via the UI. Every measurement in the library is in inches (英寸 / Inch, matching the seeded prisma/seed/data/measurements.json). The defaults live in src/constants/measurementUserRestrictions.ts (DEFAULT_UNIT_OF_MEASURE_CN/EN + DEFAULT_UNIT_OF_MEASURE_BY_PROPERTY), shared by both layers so they can't drift:

  1. Server-side backfillmeasurementBeforeNewHook / measurementBeforeEditHook call setDefaultUnitsOfMeasure(payload), filling empty/whitespace unit fields and preserving any non-empty value. Runs for all roles (the disabled field never carries a user value). This is the authoritative safety net.
  2. Form displaysrc/components/properties/MeasurementUnitInput/MeasurementUnitInput.tsx is a custom edit property component (registered as Components.MeasurementUnitInput in componentLoader.ts, mounted on both unit properties via properties.unitOfMeasure{Cn,En}.components.edit). AdminJS shares the edit component between the new and edit forms. It renders a disabled <Input> showing the current value or the default, and on mount seeds an empty record via onChange(propertyName, default) so the default both displays in the greyed box on the create form and is submitted. Without this component the disabled field rendered blank on new until first save.

This is the codebase pattern for "read-only field with a fixed default that must still display + submit": a custom edit property component (MeasurementUnitInput, like ConfigValueInput) + a server-side hook backfill, both reading defaults from one shared constants module.

Reference Seed (prisma/seed/seed-reference.ts)

Seeds the global Material and Measurement libraries from prisma/seed/data/{materials,measurements}.json (run via run-reference.ts). Purely additive, never deletes/updates: it findManys existing rows keyed by sortOrder (measurements) / materialCategoryEn_sortOrder (materials), then createManys only the JSON entries whose key is absent. Consequences:

  • Removing an entry from measurements.json only stops it being seeded fresh — existing DB rows (in any already-seeded env) are untouched; clean those via AdminJS soft-delete or a script.
  • sortOrder is the natural key for measurement idempotency, so JSON sortOrder values must stay unique and stable; reusing a value silently skips the new row.
  • unitOfMeasure{Cn,En} in the JSON are all 英寸/Inch, matching the read-only UI defaults (above). No spec covers this seed.
  • Placeholder rows (Measurement Name N / 尺寸名 N) were temporary fillers; once BGC can author real measurements via the MES UI (INFRA-478), they're removed from the JSON (INFRA-504).