feat(parts): couple state and location (host vs bin)
CI / Lint · Typecheck · Test · Build (push) Successful in 45s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m23s

DEPLOYED parts live on a host; every other state lives in a bin (or
unassigned). Previously binId and hostId were independent nullable
fields with no validation, so the Edit Part dialog could leave a
DEPLOYED part with only a bin and no host — which silently dropped
it from the repair problem-part picker.

- Service: resolveLocation() helper enforces the invariant on create
  and update. On a state transition, update auto-clears the stale
  relation and emits LOCATION_CHANGED for the cleared side.
- Zod: CreatePartRequest.superRefine rejects mismatched state/location
  up front; UpdatePartRequest rejects both-fields-set.
- Web: PartFormDialog swaps a single Location field between Host
  combobox (DEPLOYED) and Bin combobox (others); switching State
  clears the opposite field. Parts list + detail render host first,
  then bin path, then Unassigned.
- Tests: 9 new cases covering the invariant including the no-op guard
  so an unrelated PATCH on a DEPLOYED part doesn't touch hostId/binId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 10:43:02 -04:00
parent 0f952d6c1b
commit 6690d8a5dd
6 changed files with 432 additions and 52 deletions
+29 -1
View File
@@ -42,6 +42,30 @@ export const CreatePartRequest = z
path: ['partModelId'],
});
}
// State/location coupling: DEPLOYED parts live on a host; every other state lives in a bin.
const state = v.state ?? 'SPARE';
if (state === 'DEPLOYED') {
if (!v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part must be assigned to a host',
path: ['hostId'],
});
}
if (v.binId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part cannot also be in a storage bin',
path: ['binId'],
});
}
} else if (v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only deployed parts can be assigned to a host',
path: ['hostId'],
});
}
});
export type CreatePartRequest = z.infer<typeof CreatePartRequest>;
@@ -57,7 +81,11 @@ export const UpdatePartRequest = z
categoryId: z.string().uuid().nullable().optional(),
tagIds: z.array(z.string().uuid()).max(32).optional(),
})
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' });
.refine((v) => Object.keys(v).length > 0, { message: 'At least one field required' })
.refine((v) => !(v.binId && v.hostId), {
message: 'A part cannot be assigned to both a host and a bin',
path: ['binId'],
});
export type UpdatePartRequest = z.infer<typeof UpdatePartRequest>;
export const PartListQuery = PaginationQuery.extend({