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
+5 -1
View File
@@ -166,7 +166,11 @@ export default function PartDetail() {
<DetailRow
label="Location"
value={
part.bin?.fullPath ? (
part.host ? (
<span className="font-mono text-xs">
{part.host.assetId} / {part.host.name}
</span>
) : part.bin?.fullPath ? (
<span className="font-mono text-xs">{part.bin.fullPath}</span>
) : (
<span className="text-muted-foreground italic">Unassigned</span>