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 script — scripts/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 sync — SizeModel.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. Seed — prisma/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: all size queries in src/services/style/style.service.ts (findStyleWithRelation, findAddStylePrepareDatas, makeStyleMeasurementsExcel, tranMeasurementExcelRows2StyleMeasurementInfos) were consolidated by INFRA-639 into the single private helper _getSizesBySizeRangeId (style.service.ts:636), which uses 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 syncAirtableSize → SizeModel.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:

  • show → Components.StyleShow (handler viewActionHandler), edit → Components.StyleEdit (handler editActionHandler), new → Components.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: editActionHandler → prepareDataForUpdate → StyleService.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 makeHandleImportExcelFile() (the shared factory in src/utils/excelUtils.ts) 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 (fetchUploadMeasurementExcel → useStyleMeasurementsUpload → updateLocalGarmentMeasurements 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.
  • 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. style.controller.spec.ts and style.router.spec.ts also exist (both cover the measurement-excel export/upload paths) — an older claim that they were missing is likewise stale. 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.

Color Chinese-Name XLSX Round Trip (INFRA-672) — the first writing import​

The Color spreadsheet round-trip is the repo's first XLSX import that writes to the DB, unlike the Style measurement import above which is preview-only. It reuses the same plumbing (excelFile2List header-keyed parsing, list2Excel, sendExcelFile, the multer memory-storage + .xlsx ext/MIME + 10MB pattern in the makeHandleImportExcelFile() factory), but the write path, per-row skip reasons, and summary response are new.

  • Routes (src/routers/api/color/v1/color.router.ts, session-authed — unlike the style API routes, which still lack explicit sessionAuth()): GET /color/v1/exportExcel?data=<encoded> and POST /color/v1/importExcel (multipart).
  • Role gating: the whole Color resource is admin+editor only (INFRA-665 direction — no viewer, no email constants). Admin sees every field; editor sees only colorCode/colorNameEn/colorNameCn (the working set). Filtering is two-gate: server-side params stripping (the show after hook + custom listActionHandler delete admin-only fields from record.params, because BaseRecord.toJSON() ignores property-level isVisible) plus UI field hiding via custom.showByRoleEmails: [{ type: 'role', val: 'admin' }] on the admin-only fields (set by adminOnlyProperty() in color.ts), consumed by filterListPropertiesByRole (list) and filterShowEditPropertiesByRoleAccess (show/edit), both gated by each action's judgePropertiesAccessByRoleEmails: true.
  • Export — ColorService.makeColorsExcel(q) (src/services/color/color.service.ts): 3 columns (colorCode/colorNameEn/colorNameCn), only colorNameCn cells unlocked, sheet.protect(''). Honors the current filtered/sorted list view — the toolbar action's exportColorsExcelActionHandler returns flat {colorCode?, colorNameEn?, colorNameCn?, missingChineseName?, sortBy, direction} and the frontend (ExportColors) opens GET ?data=<encoded> (the latency-report two-step flow). Cell locking is advisory only; the import ignores every column except colorCode/colorNameCn.
  • List query single-sourced — ColorService.queryColorList(q) is used by both the admin list handler and the export. pg_trgm branch (when a colorNameEn search is present) lives in ColorModel.searchByTrigram/countByTrigram, which now thread sortBy/direction into the raw ORDER BY behind a column allowlist (SORT_COLUMN_SQL maps the three exposed columns to Prisma.sql fragments — an untrusted sortBy can never reach the raw SQL as a bare identifier). colorNameCn search is a plain ILIKE (no trigram index, poor fit for Chinese); "Missing Chinese name" is a virtual filter OR [{ colorNameCn: null }, { colorNameCn: '' }] (both states exist in prod).
  • Import — ColorService.importColorNamesCn(rows) (src/services/color/color.service.ts): matches rows on colorCode, writes only colorNameCn, never inserts. One prisma.$transaction for the whole upload, then cacheInvalidate('color:') exactly once after commit — ColorModel.update gained an optional Prisma.TransactionClient param and skips its own cache invalidation when a client is passed. Per-row skip reasons (COLOR_CODE_NOT_FOUND, COLOR_NAME_CN_TOO_LONG >50 chars) and the {updated, unchanged, skipped[]} summary are new; blank cell = "leave unchanged" (clearing is done through the edit form). The 50-char limit is enforced per-row in the service, not in Zod, so a long value becomes a skipped row instead of failing the whole upload.
  • Frontend import is a client-only action interception (the repo's first, reusable pattern): the importExcel toolbar action's server handler is a no-op (async () => null). ActionHeader.handleActionClick runs frontendActionHandlers() (src/components/common/ActionHeader/frontendActionHandlers.ts), a ResourceId → ActionName → handler map (Color.importExcel → _colorImportExcelHandler) that toggles the shared ExcelUploadDropZone (src/components/common/ExcelUploadDropZone/, exposed via a toggle() ref). On file selection, useExcelUpload (in ActionHeader.tsx) calls fetchImportColorsExcel(file) → POST /color/v1/importExcel, then opens a summary modal and navigate(appendForceRefresh(...)). ExcelUploadDropZone is now shared with the Style measurement upload — it replaced the Style-specific StyleUploadDropZoneRef (deleted from src/components/admin/Style/types.d.ts), so both Style and Color drive the same drop zone component.
  • Files: src/services/color/color.service.ts, src/models/color/color.model.ts, src/schemas/color/index.ts, src/controllers/color/color.controller.ts, src/routers/api/color/v1/*, src/routers/admin/resources/color/{color.ts,colorFields.ts,listActionHandler.ts,queryUtils.ts,exportColorsExcelActionHandler.ts}, src/components/actions/{ExportColors}, src/components/common/ActionHeader/frontendActionHandlers.ts, src/components/common/ExcelUploadDropZone/*, plus locale keys (properties.missingChineseName, actions.importExcel, resources.Color.messages).

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 → sizeRangeId → SizeRangeToSize → 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. Identity — src/constants/measurementUserRestrictions.ts exports MEASUREMENT_LIMITED_ROLES (a Set containing editor) and isMeasurementLimitedUser(role). This is the single source of truth for "is this a constrained library editor" and is consumed by the frontend action components. INFRA-667 replaced an email allowlist here (MEASUREMENT_LIMITED_EMAILS, holding BGC + BGUS) with the role: users are created self-service through the INFRA-646 User CRUD with arbitrary addresses, so a hardcoded list could never admit a newly created Editor — they fell through to the raw AdminJS form with createdBy/updatedBy/isDeleted/deletedAt/deletedBy all editable. Note the resource is reachable only by admin + editor, so admin is simply the role not in the Set: it keeps the full form.
  2. Backend before hooks (in measurement.ts): measurementBeforeNewHook / measurementBeforeEditHook always set audit fields (createdBy/updatedBy) via setAuditFields, backfill units via setDefaultUnitsOfMeasure, then 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). Both now run for all roles, not just limited users — a post-INFRA-478 change made validation + auto-sort universal (see the spec's "admin user also runs the duplicate check" / "admin user without sortOrder gets one auto-assigned"). sortOrder is still @unique, so an admin may supply an explicit value — the auto-assign short-circuits when payload.sortOrder != null. The old hard 25-measurement cap is gone (INFRA-478).
  3. Frontend field hiding — the resource uses globally-overridden action components DefaultNewAction / DefaultEditAction / DefaultShowAction (src/components/common/). Each calls filterFormProperties(resource, action, isLimitedUser, TARGET_RESOURCE_ID, HIDDEN_FOR_VIEWER, REQUIRED_FOR_VIEWER) from src/components/utils/. Gotcha: MeasurementEdit.tsx replaces DefaultEditAction for this resource and used to re-declare the allowlist verbatim instead of importing it, so the constant was not the single source of truth it claimed to be; INFRA-667 deleted the copy. The HIDDEN_FOR_VIEWER / REQUIRED_FOR_VIEWER names predate the role vocabulary and mean "the limited form", not the viewer role — a viewer has no Measurement access at all. 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 for both isVisible and isAccessible — INFRA-637 tightened isAccessible from adminEditorRoleAuth, because the button was already hidden from editors but the endpoint stayed reachable. So no editor can soft-delete. The other four actions (list/show/edit/new) moved from adminEditorOrBgcBgusAuth to adminEditorRoleAuth in INFRA-667: the viewer role has no Measurement access, which cost bgus@birdygrey.com the page until it was promoted from viewer to editor. Sequence that promotion before any deploy that removes an email gate — the account's access moves from address to role at that instant.

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 backfill — measurementBeforeNewHook / 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 display — src/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.

Alt Lengths Flag (INFRA-637)​

Measurement.altLengths Boolean @default(false) is the whole Alt Length epic's on/off switch: ticking it makes Short/Long sub-rows available on every style using that measurement. The PRD's child-row model (parent_measurement_id + length_variant) was rejected in favour of this single boolean — it avoids generated child names, rename cascades, and sortOrder renumbering (the column is @unique). Column declared after unitOfMeasureEn so AdminJS field order falls out for free; migration 20260814000000_add_alt_lengths_to_measurement (hand-written ALTER TABLE ... ADD COLUMN because the local migrations dir diverged from the dev DB, which would have forced a destructive migrate reset).

  • Role gating uses a different mechanism from the limited-user HIDDEN_FOR_VIEWER/REQUIRED_FOR_VIEWER Sets above. altLengths carries custom.showByRoleEmails + custom.editByRoleEmails (both [admin, editor] since INFRA-667 — previously admin plus the BGUS/BGC addresses, which hid the control entirely from any other Editor), and the edit/show actions set custom.judgePropertiesAccessByRoleEmails: true so filterShowEditPropertiesByRoleAccess (src/components/utils/filterPropertiesByRole.ts) drops or disables the field. This was a capability expansion, not just a restructuring: the edit list went from [admin, BGC] to [admin, editor], so every Editor can now toggle Alt Lengths — including bgus@, which previously saw the flag read-only and gains write access on promotion to Editor. Both lists match the same set as the page gate, so they gate nothing beyond it; they are kept in the shape material.ts uses so the flag stays gated independently if access widens. Pattern copied from material.ts's _makeEditProperty('bgc'). Deliberately not added to HIDDEN_FOR_VIEWER/REQUIRED_FOR_VIEWER — an Editor must still see it (that Set is what trims the Editor's form down to the four core fields). isVisible has no new key: AdminJS shares editProperties between the new/edit forms, and the global DefaultNewAction does not run role filtering, so a dedicated property component AltLengthsCheckbox (src/components/properties/AltLengthsCheckbox/) hides the field on the create form via if (!record?.id) return null (a fresh measurement always starts with the flag off).
  • Custom edit action component Components.MeasurementEdit (src/components/admin/Measurement/MeasurementEdit.tsx) replaces DefaultEditAction for the Measurement edit action only (the global override is untouched). It mirrors DefaultEditAction's rendering (filterFormProperties + filterShowEditPropertiesByRoleAccess + BasePropertyComponent) and adds the save guard: when altLengths changes between initial and current record, submit is deferred into a confirmation modal (AltLengthConfirmationModal, shaped after StyleConfirmationModal); Cancel reverts via handleChange('altLengths', initialValue); an unchanged flag saves straight through with no dialog.
  • Server-side gate (defense in depth) in measurement.ts:
    • _stripAltLengthsIfNotEditable(payload, context) (both new + edit hooks) deletes payload.altLengths when adminEditorRoleAuth(context) is false (INFRA-667; was adminBgcAuth) — the disabled checkbox never submits it, but a crafted request could.
    • The untick guard in measurementBeforeEditHook runs only when adminEditorRoleAuth(context) passes. This ordering matters: after the strip, a non-privileged editor's payload has altLengths: undefined, which would otherwise be misread as an untick (true → falsy) and trip the guard on a routine name edit. The refusal itself (_getAltLengthUntickRefusalMessage) was a stub returning null until INFRA-650 implemented it — see the INFRA-650 section below.
    • measurementBeforeDeleteHook blocks deleting a flagged measurement with "Measurement cannot be deleted, it contains the Alt Length Short/Long", enforcing the delete order: clear Short/Long values → untick → delete. A second guard inside measurementBeforeEditHook (INFRA-650) covers the edit-form route: a payload carrying isDeleted while the record's flag is on throws ForbiddenError('measurementDeleteWithAltLength') (locale key, resolved to the same message).
  • Pre-check action checkAltLengthsUntick — removed in INFRA-650. The record action (isVisible: false, called from MeasurementEdit via ApiClient.recordAction the moment the flag was unticked, so a refusal surfaced instead of the confirmation dialog) was dropped along with the MeasurementEdit recordAction call; the edit hook is now the only gate — the frontend shows the confirmation modal unconditionally and a refusal arrives as a ValidationError on save.
  • Parked epic work — partially landed in INFRA-650: the style dependency check shipped (see below). Still not implemented: refusing the untick while an open work order uses that length (dropped from the shipped scope), and deleting the Short/Long GarmentMeasurement rows on confirm.
  • Locales: field label properties.altLengths ("Alt Lengths" / "可选长度"); confirmation copy under components.MeasurementEdit (confirmTitle, confirmSelectBody, confirmRemoveBody, short/long). Confirmation bodies are English-only for now (Chinese follow-up), per the ticket.

Alt Lengths Untick Refusal (INFRA-650)​

_getAltLengthUntickRefusalMessage (src/routers/admin/resources/measurement/measurement.ts) is the shipped implementation of the dependency check deferred from INFRA-637. It runs four top-level service queries, each short-circuiting to "allow" (return null) when its result is empty:

  1. GarmentMeasurementService.findMany({ where: { measurementId }, distinct: ['sizeId'] }) — every size that has any GarmentMeasurement row for this measurement, across all styles. A measurement used nowhere can always be unticked.
  2. SizeService.findMany({ where: { sizeId: { in: <step 1> }, baseSizeId: { not: null }, lengthVariant: { not: null } } }) — which of those sizes are Short/Long variants (INFRA-638 columns). No variants in use → nothing to protect.
  3. GarmentMeasurementService.findMany({ where: { measurementId, sizeId: { in: <step 2> } }, distinct: ['styleId'] }) — styles holding a row for this measurement on a variant size. Row existence alone blocks the untick, even when measurementValue is null — the pre-implementation spec draft filtered measurementValue: { not: null }; the shipped check deliberately doesn't, so a Short/Long row that was saved and then cleared still forces the user to untick via the "remove the values" path first.
  4. StyleService.findMany({ where: { styleId: { in: <step 3> } }, select: { styleId, styleNumber } }) — resolves the styleNumbers interpolated into the refusal: Alt Length cannot be removed, N styles contain Short/Long measurement values: \r\n<comma-separated styleNumbers>. Thrown as ValidationError({ altLengths: { message } }), so it renders on the checkbox field.

Why four flat queries instead of one nested-relation query: every step is a top-level findMany, so softDelExtension auto-applies isDeleted: false at each step — soft-deleted garment measurements and soft-deleted styles never block an untick (step 4 returning [] for all-soft-deleted styles allows it). The earlier single-query draft (size: { lengthVariant: { in: [...] } } nested filter) needed an explicit nested isDeleted: false because nested relation filters are not auto-filtered; the flat pipeline sidesteps that trap entirely.

Also shipped in INFRA-650: removal of the checkAltLengthsUntick pre-check record action (see the struck-through bullet above), and the edit-hook soft-delete guard (ForbiddenError('measurementDeleteWithAltLength')). The planned open-work-order refusal was not shipped — an open work order using a Short/Long size does not block the untick today. Specs: measurement.spec.ts mocks the three services (services/garmentMeasurement, services/size, services/style) and asserts each pipeline step's query shape plus the four short-circuit paths.

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).

Alternate Length size variants (Size.lengthVariant / Size.baseSizeId, INFRA-638)​

Short and long garments are ordered as their own sizes (XS-SHORT, XS-LONG), each arriving from Airtable as its own Size record with its own sizeCode. Two columns on Size record the relationship:

  • lengthVariant — a nullable SizeLengthVariant enum (SHORT / LONG). Null means standard. Deliberately not a three-value enum with a STANDARD member: null needs no data migration for the 300+ existing rows, and { lengthVariant: null } becomes the natural "standard sizes only" filter. Values are uppercase because they print verbatim as the work order / hangtag Length field, so no case translation exists anywhere.
  • baseSizeId — nullable self-FK to Size, ON DELETE SET NULL (not CASCADE: deleting a base must not silently delete its variants; Size deletes are soft in normal operation, so this only governs a hard delete).

Stored, never parsed from the label. The label is not a safe key: the same size/sizeCode legitimately appears on several rows scoped to different Size Ranges (INFRA-562). In the live Airtable base, XS/001 exists as 3+ distinct records. Any code needing "which length is this / what is its base" must read the columns.

SizeModel.findStandardSizesInRange(sizeRangeId) — superseded by INFRA-639, one INFRA-640 caller remains​

INFRA-638 consolidated four inline "in-range standard sizes" queries (findStyleWithRelation, findAddStylePrepareDatas, makeStyleMeasurementsExcel, tranMeasurementExcelRows2StyleMeasurementInfos) into this model method (where: { sizeRangeToSize: { some: { sizeRangeId } }, lengthVariant: null }). INFRA-639 then replaced all four call sites with the service-local _getSizesBySizeRangeId (see the INFRA-639 section below), because the grid now needs the variant rows to build the Short/Long sub-row map — a query-level lengthVariant: null filter would throw away the very rows the map is built from. The method survives (size.model.ts:187, still covered by its own spec and the global mock). It had no production callers after INFRA-639, but INFRA-640 re-introduced one: tranMeasurementExcelRows2StyleMeasurementInfos (style.service.ts:580) calls it to re-derive the accepted base-size columns on upload — variants are never columns on import. Variant exclusion for grid columns still happens in-memory in findStyleWithRelation.

The null-Size-Range guard also moved back out of the model into the service helper (typeof sizeRangeId !== 'number' → [] before any query). Spec consequences flipped accordingly: the INFRA-565 empty-column tests now assert SizeModel.findMany was not called, and a flat mockResolvedValue(rows) on SizeModel.findMany is fine again — the INFRA-638-era requirement for an argument-aware findStandardSizesInRange mock died with the model-level guard. The method still normalizes the result to [] (result ?? []), which is now type-level dead weight: handleDbErr declares its result let result!: T (definite assignment, no | undefined) since it throws on every error path — there is no swallowed-undefined return.

Variant sortOrder (computeVariantSizeSortOrder)​

A variant label is not in CANONICAL_SIZE_ORDER, so computeSizeSortOrder would append every variant at the table's end in Airtable sync order (XS-SHORT could land after 5X-LONG). computeVariantSizeSortOrder(variant, baseSortOrder, currentMax) instead seats a variant inside its base's gap: XS=20 → XS-SHORT=21, XS-LONG=22, so the Size admin list reads XXS, XS, XS-SHORT, XS-LONG, S, …. Safe because SIZE_SORT_ORDER_GAP leaves 9 free slots per canonical size and Size.sortOrder carries no unique constraint (unlike Measurement.sortOrder, which is @unique — that difference is why Alt Length could avoid renumbering entirely). Falls back to the currentMax + GAP append rule when the base is unresolvable.

Both sortOrder write paths dispatch on lengthVariant. The helper is not self-installing — the two places that assign Size.sortOrder each had to branch explicitly, and a third path added later must do the same or it will silently reintroduce the append-at-the-end behaviour:

  1. Manual AdminJS create/edit — sizeAutoSortOrderBeforeHook (src/routers/admin/resources/size/size.ts) reads lengthVariant off the form payload via _parseLengthVariant (every AdminJS value is a string; an untouched select arrives as '', and anything not matching the two enum members takes the standard path), then resolves the base's sortOrder with _findBaseSortOrder(payload.baseSizeId). The existing "explicit numeric sortOrder wins" short-circuit still runs first, so an admin can override either computation. This is the path that matters today — manual creation is the only way a variant can exist until the Airtable sync resolves baseSizeId.
  2. Airtable sync create branch — _computeSyncSortOrder in src/models/size/size.model.ts, called only from the create side of upsertByAirtableRecordId (the update and orphan-link branches still omit sortOrder entirely, so a re-sync never disturbs order). It is effectively inert right now: syncAirtableSize does not populate baseSizeId, because a brand-new variant has no SizeRangeToSize membership at the moment its own Size webhook fires, so the base cannot be found by scoping to a shared Size Range (the ordering trap in airtable-reference-sync.md). With baseSizeId absent the helper falls back to currentMax + GAP — identical to the old behaviour — and starts seating variants correctly the moment that resolution lands, with no further change here.

Spec gotcha. tx.size.findUnique now serves two lookups in upsertByAirtableRecordId — the already-linked check by airtableRecordId and the base-size read by sizeId. A blanket mockResolvedValue makes the airtableRecordId check return a row and diverts the whole call into the update branch; route the mock on 'sizeId' in where instead (see makeVariantTx in size.model.spec.ts).

No AdminJS crash from the self-FK​

The INFRA-565 trap (a new to-one FK whose target model has no registered AdminJS resource 500s the whole action) does not apply here: baseSizeId's target is Size itself, already registered at src/routers/admin/resources/size/size.ts. Note the flip side — baseSize/baseSizeId will render on the Size resource's list/show/edit as raw Prisma CRUD, including an editable numeric box for a value meant to come from Airtable. Whitelist them out if that's unwanted.

Validating Alt Length locally before the Airtable sync exists​

The sync half (populating the two columns from Airtable) is a separate step, so nothing fills them yet. To exercise the rest end-to-end, seed the rows the sync will eventually create and drive the real services:

  1. Pick a style with a populated range — e.g. BG158, sizeRangeId 5 (1. ALPHA DRESSES XXS-3X, 11 sizes, 150 garment measurements). Base XS is sizeId 16, sortOrder 20.
  2. prisma.size.create two rows with lengthVariant, baseSizeId, and a computeVariantSizeSortOrder value, using sizeCodes above the current max (247 locally), then add a SizeRangeToSize row for each so they join the range.
  3. Assert findStandardSizesInRange still returns 11 while SizeRangeToSize for that range shows 13; XLSX export headers are byte-identical before/after; and tranMeasurementExcelRows2StyleMeasurementInfos discards an XS-SHORT column injected into an uploaded sheet (the highest-value check — that's the site the ticket omits). These step-3 assertions describe the pre-INFRA-639 state — after the helper swap the export grows Short/Long columns and the upload accepts them (see the INFRA-639 section's XLSX warning), so re-validating today means asserting the grid shows sub-rows instead.
  4. Assert parseSkuInfo / parseSkuInfos resolve a variant sizeCode to the variant row: this needs no production change, since the variant has its own code and findSizeByCodeInRange is already range-scoped.

Clean up by deleting the SizeRangeToSize rows before the Size rows. Verified 16/16 on the local DB this way.

Alt Length Short/Long Sub-Rows on the Style Measurement Grid (INFRA-639)​

A measurement flagged altLengths (INFRA-637) renders as up to three rows on the Style Show/Edit grid: the normal row plus Short/Long sub-rows. Variants are rows, not columns — an 11-size range stays 11 columns.

Server side — one fetch, in-memory split (findStyleWithRelation, style.service.ts:82). All four former findStandardSizesInRange call sites now go through the private helper _getSizesBySizeRangeId(sizeRangeId) (style.service.ts:636), which returns all in-range sizes, variants included, because the grid needs the variant rows to build the lookup map. findStyleWithRelation then filters grid columns down to normal sizes in memory (sizeRecords.filter((r) => !r.lengthVariant), style.service.ts:122) and builds normalSizeId2AltLengthSizeId: Record<normalSizeId, { short: sizeId | null, long: sizeId | null }> from Size.baseSizeId + Size.lengthVariant (style.service.ts:124-138). A variant whose baseSizeId doesn't resolve to an in-range normal size is silently dropped from the map. getStyleRecord (src/routers/admin/resources/style/utils.ts) folds the map into record.params; StyleEdit's recordExcludeKeys lists it so it isn't submitted back with the form.

Frontend derivation (useStyleDisplayInfo, hooks.ts:22). Flattens the map into global short/long sizeId Sets, then — while merging garmentMeasurement rows into display rows — sets hasShortSizeVals/hasLongSizeVals on a measurement when one of its rows has a variant sizeId and a non-empty measurementValue. These two flags exist solely to gate sub-row visibility in view mode.

Rendering (StyleGarmentMeasurements.tsx). Each visible measurement renders <MeasurementInputRow type="normal"> plus conditional type="short" / type="long" sub-rows:

  • Sub-rows render only when m.altLengths is on; in viewMode (Show) additionally only when the matching has*SizeVals flag is set — an empty Short row is hidden in Show but always shown in Edit.
  • A sub-row cell resolves its sizeId per column via normalSizeId2AltLengthSizeId[normalSizeId].short/.long; a missing variant renders a disabled input with a fallback React key (row-${idx}-size-${type}${normalSizeId}).
  • Tolerance is editable only on the normal row (toleranceEditable = editable && type === 'normal') — tolerance stays per-measurement, shared across all lengths.
  • Sub-row labels reuse the MeasurementEdit.short/long locale keys; sub-rows get tinted backgrounds (#f2e0c2 short / #c6d6f7 long) via Tailwind classes.

The save path needed zero changes. Sub-row inputs write into the same local[idx].sizes[variantSizeId] map as normal cells, so makeAddUpdateParams's ${measurementId}-${sizeId} diff and GarmentMeasurementModel.upsertMany's ON CONFLICT ("styleId","measurementId","sizeId") create/update variant rows through the pre-INFRA-639 machinery — the payoff of INFRA-565's sizeId re-keying. Two corollaries: isRowPopulated (view-mode empty-row hiding) also sees variant values, because variant entries live in the same m.sizes map; and StyleNew passes normalSizeId2AltLengthSizeId={{}} — no sub-rows on the New form (moot anyway: addStylePrepareDatasActionHandler calls findAddStylePrepareDatas(null), so the New grid has no size columns at all). The unused handleChange prop was dropped from the component in the same commit.

Save-path follow-up (INFRA-650): alt-size entries key on measurementValue alone. makeAddUpdateParams copies the measurement-level tolerance onto every size entry, including Short/Long sub-row cells (src/components/admin/Style/utils.ts:49,59) — even ones the user never touched. updateStyleWithRelation (style.service.ts) therefore first resolves which of the submitted sizeIds are variants (SizeService.findMany({ where: { sizeId: { in }, lengthVariant: { not: null }, baseSizeId: { not: null } } })), and for those entries uses measurementValue as the sole upsert/delete criterion: a Short/Long entry carrying only the inherited tolerance is deleted, not upserted. Without this, every style save with a tolerance would leave valueless GarmentMeasurement rows on variant sizes, permanently blocking the altLengths untick guard (which counts row existence regardless of value — see the INFRA-650 section). Normal-size entries keep the original measurementValue || tolerance rule.

XLSX pair warning carried over from INFRA-638 — resolved by INFRA-640. The export/upload pair no longer grows Short/Long columns; it round-trips alternates as a length column with three rows per flagged measurement. See the INFRA-640 section below.

Alt Length XLSX Round Trip with a Length Column (INFRA-640)​

The measurements XLSX mirrors the grid: a measurement flagged altLengths exports as three consecutive rows — standard, SHORT, LONG — sharing one measurementId, told apart by a length column (blank = standard). Size columns stay base sizes only; variants are never columns.

Export (makeStyleMeasurementsExcel, style.service.ts). Sizes come from _getSizesBySizeRangeId (all in-range sizes, variants included) and are split in memory: base sizes become columns, and every sizeId — standard or variant — is routed through sizeId2CellRouteMap: sizeId -> { baseLabel, variant }. A stored alternate value (its sizeId pointing at a variant Size row) therefore lands in its base size's column on the matching Length row instead of being silently dropped. Rows emit in sortOrder order, standard → SHORT → LONG within a measurement. Tolerance stays measurement-level: only the standard row carries it. On variant rows, id/names/length/tolerance cells are locked; only size cells stay editable (VARIANT_ROW_LOCKED_KEYS). The length column is locked on every row — standard included — because it is a row discriminator, not an editable field; tolerance is locked only on variant rows (the standard row still owns it).

Upload (tranFile2MeasurementExcelRows + tranMeasurementExcelRows2StyleMeasurementInfos). The Length cell is normalized (blank → standard, case-insensitive SHORT/LONG) and a row with an unrecognized Length is dropped rather than treated as standard — silently downgrading it would overwrite standard values with alternate ones. Rows then map to StyleMeasurementInfo keyed on (measurementId, length); a SHORT/LONG row for a measurement without altLengths is ignored server-side. Accepted size columns are still re-derived server-side from the style's Size Range (findStandardSizesInRange), so a stale sheet's extra columns drop out instead of failing. sizeMeasurements stays keyed by base size label even on variant rows — the variant sizeId resolution happens in the grid component, which owns normalSizeId2AltLengthSizeId.

Grid apply (updateLocalGarmentMeasurements, StyleGarmentMeasurements.tsx). Rows are matched on (measurementId, length) — measurementId alone is no longer unique per row, and the pre-INFRA-640 Map<measurementId, row> kept only the last row (Long silently overwrote standard and Short). SHORT/LONG rows route each cell through normalSizeId2AltLengthSizeId[normalSizeId].short/.long — the same resolution as the sub-row renderer. A missing variant row leaves stored alternates untouched; an empty cell on a present variant row clears that alternate (same as inheriting).

The upload Zod schema declares length: '' | 'SHORT' | 'LONG' explicitly (the pre-existing .catchall(z.string().max(25)) would have passed any string through). Excel headers stay hardcoded English — no locale key for the length column.