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