feat(parts): couple state and location (host vs bin)
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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user