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— exportsCANONICAL_SIZE_ORDER(the ordered apparel-size list),SIZE_SORT_ORDER_GAP = 10, andcomputeSizeSortOrder(size, currentMaxSortOrder). Known sizes map to(index + 1) * 10(XXS=10 … 5X=130); an unknown size is appended atcurrentMaxSortOrder + 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:- Backfill script —
scripts/backfill-size-sort-order.ts(one-off,--dry-run/--help; usesSizeModel.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 (insizeIdorder). Safe to re-run: it preserves an unknown size an admin has already positioned (non-nullsortOrder), so a re-run never clobbers a manual reposition. - Airtable sync —
SizeModel.upsertByAirtableRecordId(src/models/size/size.model.ts) setssortOrderonly on the create branch: it queriestx.size.aggregate({ _max: { sortOrder } })and callscomputeSizeSortOrder(data.size, max ?? 0). Theupdatebranch and the orphan-linkupdateomitsortOrder, 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). - Seed —
prisma/seed/seed-sizes.tsprecomputes eachsortOrdersequentially before the (concurrent) create batch, seeding the running max from the DB's current max so an unknown size insizes.jsonappends instead of colliding at the canonical XXS slot.sizes.jsoncarries onlysizeCode+size— it does not own ordering (deliberately decoupled so the seed file never drifts from the canonical list). - Manual AdminJS create/edit — the
Sizeresource (extracted tosrc/routers/admin/resources/size/size.ts, mirroringmeasurement.ts) registersbeforehookssizeBeforeNewHook/sizeBeforeEditHookthat set audit fields (createdByon create,updatedByalways, via a privatesetAuditFields) and callsizeAutoSortOrderBeforeHook— assignssortOrdervia the helper when the form omits it (respecting an explicit, numeric typed value incl.0; a non-numeric value isNumber.isFinite-guarded and falls through to the helper rather than writingNaN). Needed because@adminjs/prisma's default create bypassesSizeModel, so without the hook a manual "New Size" saved nullsortOrder/createdBy.
- Backfill script —
- 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 usesorderBy: { 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
Sizeresource setsoptions.sort: { sortBy: 'sortOrder', direction: 'asc' };sortOrderauto-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_orderisADD COLUMNonly; 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(handlerviewActionHandler),edit→Components.StyleEdit(handlereditActionHandler),new→Components.StyleNew(handlernewActionHandler).viewConstructionNotes/editConstructionNotesreuse the view/edit handlers but render the MaterialConstructions table instead of measurements (toggled byaction.nameinside the component).- Visibility split:
showisadminEditorOrBgusAuth(visible) /adminEditorViewerRoleAuth(accessible);edit/newareadminEditorRoleAuth. 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— includesgarmentMeasurement(only rows attached to this style,isDeleted:false, ordered bymeasurement.sortOrder) andmaterialConstruction.measurementRecords— the full global Measurement library, not just this style's points. Built byfindAddStylePrepareDatas(src/services/style/style.service.ts) viaMeasurementService.findMany({ take: 100, orderBy: { sortOrder: 'asc' } })(nowhere).take: 100is 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, bymaterialId),sizeRecords(size list, bysizeId).
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>andPOST /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 viaMeasurementService.findMany({ orderBy: { sortOrder: 'asc' } })— notake: 100cap (that cap lives only infindAddStylePrepareDatas); sizes orderedsortOrder asc, nulls last. Builds one row per library measurement (aMap<measurementId, info>), then folds the style'sGarmentMeasurementrows in — unattached points keep blank tolerance/size cells. Tolerance is taken from the first garment row that has one (hasToleranceflag), i.e. tolerance is per-measurement, not per-size. Stale garment rows whosemeasurementIdno longer exists in the library are silently skipped. Columns built withmakeExcelColumn(key, width)(header === key,numFmt: '@'text format). Cell locking:sheet.protect('', {...})(empty password) is applied, then every body cell is setprotection: { locked: false }+UNLOCKED_FILL— so all columns (includingmeasurementId) are editable. Only the header row is locked (re-styled bold +HEADER_FILLlast). (Per INFRA-493 follow-up themeasurementIdcolumn is no longer locked; theLOCKED_FILL/LOCKED_FONTconstants 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 viasendExcelFile. The frontend (fetchExportMeasurementExcelinsrc/components/utils/fetchUtils.ts) prefers theContent-Dispositionfilename and falls back to building the same name client-side. - Import —
uploadMeasurementExcelcontroller: multer memory storage,.xlsxext + xlsx MIME only, 10MB cap, wrapped in a promisifiedmakeHandleImportExcelFile()(the shared factory insrc/utils/excelUtils.ts) so multer errors becomemakeBadRequestError. 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→updateLocalGarmentMeasurementsref call) applies parsed rows to the Edit form's local state; persistence happens only when the user saves the Style edit form (thegarmentMeasurementsJsonpath 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. Aformattermap allows per-column coercion (measurementId: (c) => Number(c.value)); all other numeric cells are stringified post-parse. Numeric cells respectnumFmtvia thenumfmtpackage. - Upload schema (
src/schemas/style/index.ts): array of row objects —styleNumber(via sharedstyleValidator()),developmentIdrequired,styleNameCn/styleNameEnoptional,measurementIdz.coerce.number().positive().int(),toleranceoptional, measurement namesz.any()(reference-only), and.catchall(z.string().max(25))for the dynamic size columns. - The
multerupload field isfile; the on-screen style is passed alongside as form-datastyleNumber(fetchUploadMeasurementExcelappendsfile.nameas originalname, so the original client file name reachesreq.file.originalname). style.service.spec.tsexists (coversmakeStyleMeasurementsExcel,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.tsandstyle.router.spec.tsalso 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,lsthe directory —Writewill 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 explicitsessionAuth()):GET /color/v1/exportExcel?data=<encoded>andPOST /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 onlycolorCode/colorNameEn/colorNameCn(the working set). Filtering is two-gate: server-side params stripping (theshowafter hook + customlistActionHandlerdelete admin-only fields fromrecord.params, becauseBaseRecord.toJSON()ignores property-levelisVisible) plus UI field hiding viacustom.showByRoleEmails: [{ type: 'role', val: 'admin' }]on the admin-only fields (set byadminOnlyProperty()incolor.ts), consumed byfilterListPropertiesByRole(list) andfilterShowEditPropertiesByRoleAccess(show/edit), both gated by each action'sjudgePropertiesAccessByRoleEmails: true. - Export —
ColorService.makeColorsExcel(q)(src/services/color/color.service.ts): 3 columns (colorCode/colorNameEn/colorNameCn), onlycolorNameCncells unlocked,sheet.protect(''). Honors the current filtered/sorted list view — the toolbar action'sexportColorsExcelActionHandlerreturns flat{colorCode?, colorNameEn?, colorNameCn?, missingChineseName?, sortBy, direction}and the frontend (ExportColors) opensGET ?data=<encoded>(the latency-report two-step flow). Cell locking is advisory only; the import ignores every column exceptcolorCode/colorNameCn. - List query single-sourced —
ColorService.queryColorList(q)is used by both the admin list handler and the export. pg_trgm branch (when acolorNameEnsearch is present) lives inColorModel.searchByTrigram/countByTrigram, which now threadsortBy/directioninto the rawORDER BYbehind a column allowlist (SORT_COLUMN_SQLmaps the three exposed columns toPrisma.sqlfragments — an untrustedsortBycan never reach the raw SQL as a bare identifier).colorNameCnsearch is a plainILIKE(no trigram index, poor fit for Chinese); "Missing Chinese name" is a virtual filterOR [{ colorNameCn: null }, { colorNameCn: '' }](both states exist in prod). - Import —
ColorService.importColorNamesCn(rows)(src/services/color/color.service.ts): matches rows oncolorCode, writes onlycolorNameCn, never inserts. Oneprisma.$transactionfor the whole upload, thencacheInvalidate('color:')exactly once after commit —ColorModel.updategained an optionalPrisma.TransactionClientparam 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
importExceltoolbar action's serverhandleris a no-op (async () => null).ActionHeader.handleActionClickrunsfrontendActionHandlers()(src/components/common/ActionHeader/frontendActionHandlers.ts), aResourceId → ActionName → handlermap (Color.importExcel→_colorImportExcelHandler) that toggles the sharedExcelUploadDropZone(src/components/common/ExcelUploadDropZone/, exposed via atoggle()ref). On file selection,useExcelUpload(inActionHeader.tsx) callsfetchImportColorsExcel(file)→POST /color/v1/importExcel, then opens a summary modal andnavigate(appendForceRefresh(...)).ExcelUploadDropZoneis now shared with the Style measurement upload — it replaced the Style-specificStyleUploadDropZoneRef(deleted fromsrc/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:
- DB column (
prisma/schema.prisma,Style.styleNameCn/styleNameEn@db.VarChar(100)as of INFRA-575, previouslyVarChar(50)) — the ultimate ceiling; a value that gets past both Zod schemas below but exceeds this still fails at the DB. - 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. - Style Measurement Excel-upload row schema (
src/schemas/style/index.ts,StyleUploadMeasurementExcelSchemaOpts) — a different, easily-confused schema: it validatesstyleNameCn/styleNameEnas 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 wasmax(35), already stricter than the DB column'sVarChar(50), unrelated to and inconsistent with the webhook schema'smax(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 bysizeIdand scope the size list to the style's own resolvedsizeRangeIdvia the relational filter below, instead of reading the unscoped global library:findAddStylePrepareDatas(sizeRangeId)(feeds Show/Edit viafindStyleWithRelationand the New-style form —sizeRangeId === nullshort-circuits to[], no query),makeStyleMeasurementsExcel(export — builds asizeId2LabelMapto render the Excel size-label column headers, skips aGarmentMeasurementrow whosesizeIddoesn't resolve to a column in the style's current range rather than deleting it),tranMeasurementExcelRows2StyleMeasurementInfos(import preview — resolves the style'ssizeRangeIdfrommeasurementExcelRows[0].styleNumberfirst). The save path (updateStyleWithRelation) diffs/upserts bysizeIdviaGarmentMeasurementModel.upsertMany's rawON CONFLICT ("styleId","measurementId","sizeId"); the delete branch'sORclause is{ measurementId, sizeId }pairs.src/components/admin/Style/{hooks.ts,utils.ts,StyleGarmentMeasurements.tsx}— frontend re-keyed in lockstep:DisplayMeasurement.sizesisRecord<sizeId, {...}>(not label), the save-path diff key inutils.ts#makeAddUpdateParamsis${measurementId}-${sizeId}, and the rendered table's per-columnkey={sizeId}inStyleGarmentMeasurements.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 nosizeRangeIdyields an emptysizesarray from the backend;StyleGarmentMeasurementsrenders an amber banner (Style.noSizeRangeAssignedlocale key) instead of an empty table in that case, in both Show and Edit.src/services/changelog/changelog.service.ts(fetchMeasurementChanges) —select: { size: true }becameselect: { 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 thoughwO.sizeIdwas already in hand) to${styleId}-${wO.sizeId}directly — no more label detour.src/models/workorder/workorder.model.ts(findWithRelationsById) filters the loaded style'sgarmentMeasurementarray down to the work order's own size viagM.sizeId === record.sizeId(was a label comparison).admin.router.tsalso registers a separate, raw AdminJS CRUD resource forGarmentMeasurementdirectly against the Prisma model (adminRoleAuth-gated, underproductParent) — independent of the custom Style Show/Edit UI. It now renders/editssizeIdas whatever@adminjs/prismadoes 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.tsresolvessizeIdfor any pre-migration row (Style →sizeRangeId→SizeRangeToSize→ label match), reading the now-model-absent legacysizecolumn 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 (nosizeRangeId, no matching label in range) are reported, not errored, and left for manual review; safe to re-run since onlysizeId IS NULLrows are touched. The physicalsizecolumn drop is a deferred follow-up ticket (expand-contract: this migration only addedsizeIdand relaxedsizeto nullable, soprisma migrate deployrunning 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:
- Identity —
src/constants/measurementUserRestrictions.tsexportsMEASUREMENT_LIMITED_ROLES(aSetcontainingeditor) andisMeasurementLimitedUser(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 withcreatedBy/updatedBy/isDeleted/deletedAt/deletedByall editable. Note the resource is reachable only by admin + editor, soadminis simply the role not in the Set: it keeps the full form. - Backend
beforehooks (inmeasurement.ts):measurementBeforeNewHook/measurementBeforeEditHookalways set audit fields (createdBy/updatedBy) viasetAuditFields, backfill units viasetDefaultUnitsOfMeasure, then runmeasurementAutoSortOrderBeforeHook(assignssortOrder = max+1when omitted) andvalidateMeasurement(requires non-empty EN+CN names; case-insensitive duplicate check against non-deleted rows viaprisma.measurement.findFirst, throwing AdminJSValidationErrorkeyed 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").sortOrderis still@unique, so an admin may supply an explicit value — the auto-assign short-circuits whenpayload.sortOrder != null. The old hard 25-measurement cap is gone (INFRA-478). - Frontend field hiding — the resource uses globally-overridden action components
DefaultNewAction/DefaultEditAction/DefaultShowAction(src/components/common/). Each callsfilterFormProperties(resource, action, isLimitedUser, TARGET_RESOURCE_ID, HIDDEN_FOR_VIEWER, REQUIRED_FOR_VIEWER)fromsrc/components/utils/. Gotcha:MeasurementEdit.tsxreplacesDefaultEditActionfor 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. TheHIDDEN_FOR_VIEWER/REQUIRED_FOR_VIEWERnames predate the role vocabulary and mean "the limited form", not theviewerrole — a viewer has no Measurement access at all. For limited users on theMeasurementresource only, this hidescreatedBy,updatedBy,isDeleted,deletedAt,deletedBy,sortOrder(theHIDDEN_FOR_VIEWERset inmeasurementsViewerFields.ts) and marks requiredmeasurementNameCn/En,unitOfMeasureCn/En(REQUIRED_FOR_VIEWER). AdminJS reuseseditPropertiesfor bothnewandedit, so the create and edit forms hide the same fields. Field visibility for limited users is therefore enforced by these custom components + Sets, not byproperties.<field>.isVisiblein the resource options (the resource only declaresunitOfMeasure{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:
- Server-side backfill —
measurementBeforeNewHook/measurementBeforeEditHookcallsetDefaultUnitsOfMeasure(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. - Form display —
src/components/properties/MeasurementUnitInput/MeasurementUnitInput.tsxis a customeditproperty component (registered asComponents.MeasurementUnitInputincomponentLoader.ts, mounted on both unit properties viaproperties.unitOfMeasure{Cn,En}.components.edit). AdminJS shares theeditcomponent between thenewandeditforms. It renders a disabled<Input>showing the current value or the default, and on mount seeds an empty record viaonChange(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 onnewuntil 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_VIEWERSets above.altLengthscarriescustom.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 theedit/showactions setcustom.judgePropertiesAccessByRoleEmails: truesofilterShowEditPropertiesByRoleAccess(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 — includingbgus@, 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 shapematerial.tsuses so the flag stays gated independently if access widens. Pattern copied frommaterial.ts's_makeEditProperty('bgc'). Deliberately not added toHIDDEN_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).isVisiblehas nonewkey: AdminJS shareseditPropertiesbetween the new/edit forms, and the globalDefaultNewActiondoes not run role filtering, so a dedicated property componentAltLengthsCheckbox(src/components/properties/AltLengthsCheckbox/) hides the field on the create form viaif (!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) replacesDefaultEditActionfor the Measurementeditaction only (the global override is untouched). It mirrorsDefaultEditAction's rendering (filterFormProperties+filterShowEditPropertiesByRoleAccess+BasePropertyComponent) and adds the save guard: whenaltLengthschanges between initial and current record, submit is deferred into a confirmation modal (AltLengthConfirmationModal, shaped afterStyleConfirmationModal); Cancel reverts viahandleChange('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) deletespayload.altLengthswhenadminEditorRoleAuth(context)is false (INFRA-667; wasadminBgcAuth) — the disabled checkbox never submits it, but a crafted request could.- The untick guard in
measurementBeforeEditHookruns only whenadminEditorRoleAuth(context)passes. This ordering matters: after the strip, a non-privileged editor's payload hasaltLengths: 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 returningnulluntil INFRA-650 implemented it — see the INFRA-650 section below. measurementBeforeDeleteHookblocks 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 insidemeasurementBeforeEditHook(INFRA-650) covers the edit-form route: a payload carryingisDeletedwhile the record's flag is on throwsForbiddenError('measurementDeleteWithAltLength')(locale key, resolved to the same message).
Pre-check action— removed in INFRA-650. The record action (checkAltLengthsUntickisVisible: false, called fromMeasurementEditviaApiClient.recordActionthe moment the flag was unticked, so a refusal surfaced instead of the confirmation dialog) was dropped along with theMeasurementEditrecordActioncall; the edit hook is now the only gate — the frontend shows the confirmation modal unconditionally and a refusal arrives as aValidationErroron 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
GarmentMeasurementrows on confirm. - Locales: field label
properties.altLengths("Alt Lengths"/"可选长度"); confirmation copy undercomponents.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:
GarmentMeasurementService.findMany({ where: { measurementId }, distinct: ['sizeId'] })— every size that has anyGarmentMeasurementrow for this measurement, across all styles. A measurement used nowhere can always be unticked.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.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 whenmeasurementValueis null — the pre-implementation spec draft filteredmeasurementValue: { 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.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 asValidationError({ 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.jsononly stops it being seeded fresh — existing DB rows (in any already-seeded env) are untouched; clean those via AdminJS soft-delete or a script. sortOrderis the natural key for measurement idempotency, so JSONsortOrdervalues 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 nullableSizeLengthVariantenum (SHORT/LONG). Null means standard. Deliberately not a three-value enum with aSTANDARDmember: 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 toSize,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:
- Manual AdminJS create/edit —
sizeAutoSortOrderBeforeHook(src/routers/admin/resources/size/size.ts) readslengthVariantoff 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'ssortOrderwith_findBaseSortOrder(payload.baseSizeId). The existing "explicit numericsortOrderwins" 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 resolvesbaseSizeId. - Airtable sync create branch —
_computeSyncSortOrderinsrc/models/size/size.model.ts, called only from thecreateside ofupsertByAirtableRecordId(theupdateand orphan-link branches still omitsortOrderentirely, so a re-sync never disturbs order). It is effectively inert right now:syncAirtableSizedoes not populatebaseSizeId, because a brand-new variant has noSizeRangeToSizemembership at the moment its own Size webhook fires, so the base cannot be found by scoping to a shared Size Range (the ordering trap inairtable-reference-sync.md). WithbaseSizeIdabsent the helper falls back tocurrentMax + 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:
- Pick a style with a populated range — e.g.
BG158,sizeRangeId5 (1. ALPHA DRESSES XXS-3X, 11 sizes, 150 garment measurements). BaseXSissizeId16,sortOrder20. prisma.size.createtwo rows withlengthVariant,baseSizeId, and acomputeVariantSizeSortOrdervalue, usingsizeCodes above the current max (247 locally), then add aSizeRangeToSizerow for each so they join the range.- Assert
findStandardSizesInRangestill returns 11 whileSizeRangeToSizefor that range shows 13; XLSX export headers are byte-identical before/after; andtranMeasurementExcelRows2StyleMeasurementInfosdiscards anXS-SHORTcolumn 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. - Assert
parseSkuInfo/parseSkuInfosresolve a variantsizeCodeto the variant row: this needs no production change, since the variant has its own code andfindSizeByCodeInRangeis 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.altLengthsis on; inviewMode(Show) additionally only when the matchinghas*SizeValsflag 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/longlocale keys; sub-rows get tinted backgrounds (#f2e0c2short /#c6d6f7long) 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.