From c4d7706ff53dca4a17da0072ff7e815bdf17a739 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 27 May 2026 08:05:43 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(locations):=20finalize=20external=5Fke?= =?UTF-8?q?y=20auto-mint=20=E2=80=94=20LOC-NNN=20format=20+=20frontend=20(?= =?UTF-8?q?TRA-551)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend auto-mint plumbing (storage + model + handler + spec) shipped preemptively under TRA-665 / BB26 D3. This finishes the loop: * Format: LOC-%04d → LOC-%03d, per 2026-05-27 triage. Locations are typically named-and-known; auto-minted keys are the exception, so the width is intentionally narrower than ASSET-%04d. Sequence grows naturally past 999 (LOC-1000+) since the pattern-match query is digit-count-agnostic. * Integration test: tightened regex to ^LOC-\d{3,}$ and added an explicit first-key assertion (LOC-001 in a fresh org). * Frontend LocationForm: dropped the required asterisk, allowed blank submit through validateIdentifier, omits external_key from the POST body when blank on create — mirrors AssetForm.tsx (TRA-650 / BB23 F3). * Helper text updated: "Optional. Leave blank to auto-assign (e.g., LOC-001)." * Comments + swagger annotations + regenerated OpenAPI spec describe the new LOC-NNN format. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/handlers/locations/locations.go | 5 +++-- .../locations/patch_round_trip_integration_test.go | 5 ++++- backend/internal/models/location/location.go | 5 +++-- backend/internal/storage/locations.go | 12 ++++++++---- docs/api/openapi.public.json | 4 ++-- docs/api/openapi.public.yaml | 8 +++++--- frontend/src/components/locations/LocationForm.tsx | 11 ++++++++--- frontend/src/lib/location/validators.test.ts | 6 +++--- frontend/src/lib/location/validators.ts | 11 ++++++++--- 9 files changed, 44 insertions(+), 23 deletions(-) diff --git a/backend/internal/handlers/locations/locations.go b/backend/internal/handlers/locations/locations.go index 190e1404..84373e32 100644 --- a/backend/internal/handlers/locations/locations.go +++ b/backend/internal/handlers/locations/locations.go @@ -104,7 +104,8 @@ func (handler *Handler) resolveParent( // @Description // @Description The `external_key` field is optional. Provide a value from your system of record // @Description (ERP, WMS, layout/plan) for natural-key joins, or omit it to receive a -// @Description server-assigned external_key in the format `LOC-NNNN` (per-organization sequence). +// @Description server-assigned external_key in the format `LOC-NNN` (per-organization +// @Description sequence, 3-digit zero-pad, grows naturally beyond 999). // @Description A caller-supplied external_key that collides with an existing location returns 409. // @Tags locations,public // @ID locations.create @@ -173,7 +174,7 @@ func (handler *Handler) Create(w http.ResponseWriter, r *http.Request) { } // TRA-665 / BB26 D3: external_key is optional only by *omission* — an - // absent key triggers the server auto-mint of LOC-NNNN. When the caller + // absent key triggers the server auto-mint of LOC-NNN. When the caller // sends `external_key` explicitly, it must validate (min=1 + pattern) // to match the PATCH validator on RenameLocationRequest.external_key. // An explicit empty string returns 400 too_short; whitespace-only fails diff --git a/backend/internal/handlers/locations/patch_round_trip_integration_test.go b/backend/internal/handlers/locations/patch_round_trip_integration_test.go index 27d14f0f..823349c2 100644 --- a/backend/internal/handlers/locations/patch_round_trip_integration_test.go +++ b/backend/internal/handlers/locations/patch_round_trip_integration_test.go @@ -428,7 +428,10 @@ func TestPostLocation_OmittedExternalKey_AutoMints(t *testing.T) { } `json:"data"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) - assert.Regexp(t, `^LOC-\d+$`, resp.Data.ExternalKey) + // TRA-551 (2026-05-27 triage): width is 3-digit zero-pad (LOC-001..LOC-999), + // growing naturally beyond. Intentionally narrower than ASSET-%04d. + assert.Regexp(t, `^LOC-\d{3,}$`, resp.Data.ExternalKey) + assert.Equal(t, "LOC-001", resp.Data.ExternalKey, "first auto-mint in a fresh org starts at LOC-001") _ = pool } diff --git a/backend/internal/models/location/location.go b/backend/internal/models/location/location.go index 480b68fb..a16e54cc 100644 --- a/backend/internal/models/location/location.go +++ b/backend/internal/models/location/location.go @@ -36,8 +36,9 @@ type LocationWithRelations struct { type CreateLocationRequest struct { Name string `json:"name" validate:"required,min=1,max=255,display_name" example:"Warehouse 1"` // external_key is optional. Omit to receive a server-assigned key in the - // format LOC-NNNN (per-organization sequence), parallel to assets' - // ASSET-NNNN behavior. When supplied, must satisfy the external_key_pattern. + // format LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551), + // parallel to (but narrower than) assets' ASSET-NNNN behavior. When + // supplied, must satisfy the external_key_pattern. ExternalKey string `json:"external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern" example:"wh1"` ParentID *int `json:"parent_id,omitempty" validate:"omitempty,min=1" example:"42"` ParentExternalKey *string `json:"parent_external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern" example:"wh1"` diff --git a/backend/internal/storage/locations.go b/backend/internal/storage/locations.go index 8c14a455..aaa809cb 100644 --- a/backend/internal/storage/locations.go +++ b/backend/internal/storage/locations.go @@ -72,11 +72,15 @@ func (s *Storage) GetNextLocationSequence(ctx context.Context, orgID int) (int, return int(maxSeq.Int64) + 1, nil } -// GenerateLocationExternalKey creates an external_key in format LOC-NNNN. -// Zero-pads to 4 digits minimum, grows naturally beyond 9999. Parallels -// GenerateAssetExternalKey. +// GenerateLocationExternalKey creates an external_key in format LOC-NNN. +// Zero-pads to 3 digits minimum, grows naturally beyond 999 (LOC-1000+). +// 3-digit width is intentionally narrower than ASSET-%04d (TRA-551 triage +// 2026-05-27): locations are typically named-and-known artifacts, so auto- +// minted keys are the exception and >999 per-org locations is rare. The +// pattern-match query in GetNextLocationSequence is digit-count-agnostic, +// so any pre-existing LOC-NNNN rows continue to increment correctly. func GenerateLocationExternalKey(seq int) string { - return fmt.Sprintf("LOC-%04d", seq) + return fmt.Sprintf("LOC-%03d", seq) } func (s *Storage) UpdateLocation(ctx context.Context, orgID, id int, request location.UpdateLocationRequest) (*location.LocationWithParent, error) { diff --git a/docs/api/openapi.public.json b/docs/api/openapi.public.json index ba9e7ac5..c71916ce 100644 --- a/docs/api/openapi.public.json +++ b/docs/api/openapi.public.json @@ -479,7 +479,7 @@ "type": "string" }, "external_key": { - "description": "external_key is optional. Omit to receive a server-assigned key in the\nformat LOC-NNNN (per-organization sequence), parallel to assets'\nASSET-NNNN behavior. When supplied, must satisfy the external_key_pattern.", + "description": "external_key is optional. Omit to receive a server-assigned key in the\nformat LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551),\nparallel to (but narrower than) assets' ASSET-NNNN behavior. When\nsupplied, must satisfy the external_key_pattern.", "example": "wh1", "maxLength": 255, "minLength": 1, @@ -4279,7 +4279,7 @@ ] }, "post": { - "description": "**Required scope:** `locations:write`\n\nCreate a new location in the hierarchy, optionally with one or more tags.\nSet parent_id (canonical) or parent_external_key (alternate) to nest under an existing parent.\nBoth forms may be supplied together when they name the same parent (silently normalized to a single re-parent operation); a disagreement returns 400 `ambiguous_fields`.\n\nThe `external_key` field is optional. Provide a value from your system of record\n(ERP, WMS, layout/plan) for natural-key joins, or omit it to receive a\nserver-assigned external_key in the format `LOC-NNNN` (per-organization sequence).\nA caller-supplied external_key that collides with an existing location returns 409.", + "description": "**Required scope:** `locations:write`\n\nCreate a new location in the hierarchy, optionally with one or more tags.\nSet parent_id (canonical) or parent_external_key (alternate) to nest under an existing parent.\nBoth forms may be supplied together when they name the same parent (silently normalized to a single re-parent operation); a disagreement returns 400 `ambiguous_fields`.\n\nThe `external_key` field is optional. Provide a value from your system of record\n(ERP, WMS, layout/plan) for natural-key joins, or omit it to receive a\nserver-assigned external_key in the format `LOC-NNN` (per-organization\nsequence, 3-digit zero-pad, grows naturally beyond 999).\nA caller-supplied external_key that collides with an existing location returns 409.", "operationId": "createLocation", "requestBody": { "content": { diff --git a/docs/api/openapi.public.yaml b/docs/api/openapi.public.yaml index bb5d88d0..5f6cdcd2 100644 --- a/docs/api/openapi.public.yaml +++ b/docs/api/openapi.public.yaml @@ -365,8 +365,9 @@ components: external_key: description: |- external_key is optional. Omit to receive a server-assigned key in the - format LOC-NNNN (per-organization sequence), parallel to assets' - ASSET-NNNN behavior. When supplied, must satisfy the external_key_pattern. + format LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551), + parallel to (but narrower than) assets' ASSET-NNNN behavior. When + supplied, must satisfy the external_key_pattern. example: wh1 maxLength: 255 minLength: 1 @@ -2932,7 +2933,8 @@ paths: The `external_key` field is optional. Provide a value from your system of record (ERP, WMS, layout/plan) for natural-key joins, or omit it to receive a - server-assigned external_key in the format `LOC-NNNN` (per-organization sequence). + server-assigned external_key in the format `LOC-NNN` (per-organization + sequence, 3-digit zero-pad, grows naturally beyond 999). A caller-supplied external_key that collides with an existing location returns 409. operationId: createLocation requestBody: diff --git a/frontend/src/components/locations/LocationForm.tsx b/frontend/src/components/locations/LocationForm.tsx index a86c6ba4..3798a758 100644 --- a/frontend/src/components/locations/LocationForm.tsx +++ b/frontend/src/components/locations/LocationForm.tsx @@ -257,11 +257,16 @@ export function LocationForm({ // TRA-664 / BB26 D7: external_key is immutable on PATCH. Strip it from // the edit-mode body; the dedicated rename operation is the only path // for mutating the natural key. + // + // TRA-551: external_key is optional on create — omit when blank so the + // backend auto-mints LOC-NNN. An explicit empty string would be rejected + // with 400 too_short. Parallels AssetForm.tsx (TRA-650 / BB23 F3). const { valid_from: _vf, valid_to: _vt, external_key: _ek, description: _desc, ...rest } = formData; + const includeExternalKey = mode === 'create' && _ek.trim() !== ''; const submitData = { ...rest, tags: validTags, - ...(mode === 'create' ? { external_key: _ek } : {}), + ...(includeExternalKey ? { external_key: _ek } : {}), // description: nullable + minLength:1 server-side. Send null when the // form is blank — POST accepts null (no description) and PATCH treats // null as "clear the column," matching user intent on both paths. @@ -290,7 +295,7 @@ export function LocationForm({ htmlFor="external_key" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" > - Identifier * + Identifier - Letters, numbers, hyphens, and underscores only (no spaces) + Optional. Leave blank to auto-assign (e.g., LOC-001). Otherwise: letters, numbers, hyphens, and underscores only (no spaces).

)} diff --git a/frontend/src/lib/location/validators.test.ts b/frontend/src/lib/location/validators.test.ts index 46af7619..915f229e 100644 --- a/frontend/src/lib/location/validators.test.ts +++ b/frontend/src/lib/location/validators.test.ts @@ -39,9 +39,9 @@ describe('Validators', () => { expect(validateIdentifier('Section_B-2')).toBeNull(); }); - it('should reject empty identifiers', () => { - expect(validateIdentifier('')).toBe('Identifier is required'); - expect(validateIdentifier(' ')).toBe('Identifier is required'); + it('should accept empty identifiers (TRA-551: backend auto-mints LOC-NNN on omit)', () => { + expect(validateIdentifier('')).toBeNull(); + expect(validateIdentifier(' ')).toBeNull(); }); it('should reject spaces', () => { diff --git a/frontend/src/lib/location/validators.ts b/frontend/src/lib/location/validators.ts index fcdf5f32..689769c4 100644 --- a/frontend/src/lib/location/validators.ts +++ b/frontend/src/lib/location/validators.ts @@ -9,14 +9,19 @@ import type { Location } from '@/types/locations'; /** * Validates identifier format - * Rule: letters (upper/lower), numbers, hyphens, underscores only (no spaces) + * Rule: letters (upper/lower), numbers, hyphens, underscores only (no spaces). + * + * TRA-551: blank input is valid — the backend auto-mints LOC-NNN when + * external_key is omitted on create. The caller (LocationForm) is + * responsible for omitting the field from the payload on blank submit. + * Format validation only runs when the user typed something. * * @param identifier - Location identifier to validate - * @returns Error message or null if valid + * @returns Error message or null if valid (including when blank) */ export function validateIdentifier(identifier: string): string | null { if (!identifier || identifier.trim().length === 0) { - return 'Identifier is required'; + return null; } if (identifier.length > 255) { From 1f04ce6cabab05fe4cc0ae60bc902d43b03ced67 Mon Sep 17 00:00:00 2001 From: Mike Stankavich Date: Wed, 27 May 2026 09:09:38 -0500 Subject: [PATCH 2/2] fix(locations): strip TRA-551 reference from public spec description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectral rule trakrf-no-internal-references-in-descriptions (BB28 S1) caught the ticket ref I left in CreateLocationRequest.ExternalKey's doc comment — it flows through swaggo into the public OpenAPI description, where internal references aren't allowed. Stripped "— TRA-551" from the model comment; regen'd spec. The ticket attribution stays in storage/locations.go and the integration test where it doesn't reach the public surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/internal/models/location/location.go | 6 +++--- docs/api/openapi.public.json | 2 +- docs/api/openapi.public.yaml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/internal/models/location/location.go b/backend/internal/models/location/location.go index a16e54cc..aa7219e2 100644 --- a/backend/internal/models/location/location.go +++ b/backend/internal/models/location/location.go @@ -36,9 +36,9 @@ type LocationWithRelations struct { type CreateLocationRequest struct { Name string `json:"name" validate:"required,min=1,max=255,display_name" example:"Warehouse 1"` // external_key is optional. Omit to receive a server-assigned key in the - // format LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551), - // parallel to (but narrower than) assets' ASSET-NNNN behavior. When - // supplied, must satisfy the external_key_pattern. + // format LOC-NNN (per-organization sequence, 3-digit zero-pad), parallel + // to (but narrower than) assets' ASSET-NNNN behavior. When supplied, + // must satisfy the external_key_pattern. ExternalKey string `json:"external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern" example:"wh1"` ParentID *int `json:"parent_id,omitempty" validate:"omitempty,min=1" example:"42"` ParentExternalKey *string `json:"parent_external_key,omitempty" validate:"omitempty,min=1,max=255,external_key_pattern" example:"wh1"` diff --git a/docs/api/openapi.public.json b/docs/api/openapi.public.json index c71916ce..d5f2a50a 100644 --- a/docs/api/openapi.public.json +++ b/docs/api/openapi.public.json @@ -479,7 +479,7 @@ "type": "string" }, "external_key": { - "description": "external_key is optional. Omit to receive a server-assigned key in the\nformat LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551),\nparallel to (but narrower than) assets' ASSET-NNNN behavior. When\nsupplied, must satisfy the external_key_pattern.", + "description": "external_key is optional. Omit to receive a server-assigned key in the\nformat LOC-NNN (per-organization sequence, 3-digit zero-pad), parallel\nto (but narrower than) assets' ASSET-NNNN behavior. When supplied,\nmust satisfy the external_key_pattern.", "example": "wh1", "maxLength": 255, "minLength": 1, diff --git a/docs/api/openapi.public.yaml b/docs/api/openapi.public.yaml index 5f6cdcd2..1d741d4e 100644 --- a/docs/api/openapi.public.yaml +++ b/docs/api/openapi.public.yaml @@ -365,9 +365,9 @@ components: external_key: description: |- external_key is optional. Omit to receive a server-assigned key in the - format LOC-NNN (per-organization sequence, 3-digit zero-pad — TRA-551), - parallel to (but narrower than) assets' ASSET-NNNN behavior. When - supplied, must satisfy the external_key_pattern. + format LOC-NNN (per-organization sequence, 3-digit zero-pad), parallel + to (but narrower than) assets' ASSET-NNNN behavior. When supplied, + must satisfy the external_key_pattern. example: wh1 maxLength: 255 minLength: 1