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
+49 -10
View File
@@ -3,6 +3,7 @@ import type {
CreatePartRequest,
PaginationQuery,
PartListQuery,
PartState as PartStateValue,
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
@@ -10,6 +11,29 @@ import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
// DEPLOYED parts live on a host; every other state lives in a bin (or is unassigned).
// This helper enforces the invariant on create/update and auto-clears the stale field on a
// state transition, so callers don't have to remember to null the opposite relation.
function resolveLocation(
state: PartStateValue,
input: { binId?: string | null; hostId?: string | null },
current: { binId: string | null; hostId: string | null } = { binId: null, hostId: null },
): { binId: string | null; hostId: string | null } {
if (state === 'DEPLOYED') {
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
if (input.binId) {
throw errors.badRequest('A deployed part cannot also be in a storage bin');
}
return { binId: null, hostId };
}
if (input.hostId) {
throw errors.badRequest('Only deployed parts can be assigned to a host');
}
const binId = input.binId !== undefined ? input.binId : current.binId;
return { binId, hostId: null };
}
const partInclude = {
manufacturer: true,
partModel: true,
@@ -123,6 +147,9 @@ export async function create(
throw errors.badRequest('manufacturerId does not match the selected part model');
}
const state = input.state ?? 'SPARE';
const location = resolveLocation(state, { binId: input.binId, hostId: input.hostId });
try {
const p = await tx.part.create({
data: {
@@ -130,9 +157,9 @@ export async function create(
partModelId,
manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
hostId: input.hostId ?? null,
state,
binId: location.binId,
hostId: location.hostId,
categoryId: input.categoryId ?? null,
notes: input.notes ?? null,
},
@@ -179,12 +206,24 @@ export async function update(
}
if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
}
if (input.hostId !== undefined) {
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
let nextBinId: string | null = current.binId;
let nextHostId: string | null = current.hostId;
const locationTouched =
input.state !== undefined || input.binId !== undefined || input.hostId !== undefined;
if (locationTouched) {
const nextState = input.state ?? (current.state as PartStateValue);
const resolved = resolveLocation(
nextState,
{ binId: input.binId, hostId: input.hostId },
{ binId: current.binId, hostId: current.hostId },
);
nextBinId = resolved.binId;
nextHostId = resolved.hostId;
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
}
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
@@ -216,7 +255,7 @@ export async function update(
newValue: input.state,
});
}
if (input.binId !== undefined && input.binId !== current.binId) {
if (nextBinId !== current.binId) {
events.push({
partId: part.id,
userId,
@@ -226,7 +265,7 @@ export async function update(
newValue: binPath(part.bin),
});
}
if (input.hostId !== undefined && input.hostId !== current.hostId) {
if (nextHostId !== current.hostId) {
events.push({
partId: part.id,
userId,