60255f20bb
Seven bundled improvements: - PartModel combobox on Add Part + Log Repair (known MPN auto-fills; unknown reveals manufacturer picker for catalog upsert). - Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack (PRODUCTION/VETTING) fields, driven by external clients via the API. - Locations page redesigned as a 2-pane tree + bin grid with breadcrumb. - PENDING_REPAIR custody state: tech takes a SPARE into custody for a future swap; resolves to DEPLOYED via Repair or back to SPARE via a bin-required drop-off. - Move Category from Part to PartModel; seed common categories (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category column and filter sourced from the model. - Fix Deployed Value 100x bug on the Dashboard (price is stored as dollars, not cents). - PartModels table shows "No" instead of "--" when destroyOnFail=false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
213 lines
6.7 KiB
TypeScript
213 lines
6.7 KiB
TypeScript
import { Prisma } from '@vector/db';
|
|
import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
|
import { errors } from '../lib/http-error.js';
|
|
import { emit } from '../lib/webhook-emitter.js';
|
|
import * as partsSvc from './parts.js';
|
|
import * as partModelsSvc from './part-models.js';
|
|
import { resolveHost } from './fms.js';
|
|
import type { Actor, Tx } from './types.js';
|
|
|
|
// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken
|
|
// serial + mpn + replacement serial; if the broken part isn't in the catalog we ingest it. The
|
|
// broken part is placed into the tech's custody (dropped in a bin later via the custody flow).
|
|
const repairInclude = {
|
|
host: true,
|
|
brokenPart: { include: { partModel: true, manufacturer: true } },
|
|
replacement: { include: { partModel: true, manufacturer: true } },
|
|
performedBy: { select: { id: true, username: true } },
|
|
fm: { select: { id: true, status: true } },
|
|
} satisfies Prisma.RepairInclude;
|
|
|
|
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
|
|
|
function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput {
|
|
const where: Prisma.RepairWhereInput = {};
|
|
if (q.hostId) where.hostId = q.hostId;
|
|
if (q.performedById) where.performedById = q.performedById;
|
|
if (q.fmId) where.fmId = q.fmId;
|
|
if (q.since) where.performedAt = { gte: new Date(q.since) };
|
|
return where;
|
|
}
|
|
|
|
export async function list(tx: Tx, q: RepairListQuery) {
|
|
const { page, pageSize } = q;
|
|
const where = buildWhere(q);
|
|
const [data, total] = await Promise.all([
|
|
tx.repair.findMany({
|
|
where,
|
|
orderBy: { performedAt: 'desc' },
|
|
include: repairInclude,
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
}),
|
|
tx.repair.count({ where }),
|
|
]);
|
|
return { data, page, pageSize, total };
|
|
}
|
|
|
|
export function get(tx: Tx, id: string) {
|
|
return tx.repair.findUnique({ where: { id }, include: repairInclude });
|
|
}
|
|
|
|
function repairPayload(r: RepairWithRelations) {
|
|
return {
|
|
id: r.id,
|
|
host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name },
|
|
brokenPart: {
|
|
id: r.brokenPart.id,
|
|
serialNumber: r.brokenPart.serialNumber,
|
|
mpn: r.brokenPart.partModel.mpn,
|
|
state: r.brokenPart.state,
|
|
},
|
|
replacement: {
|
|
id: r.replacement.id,
|
|
serialNumber: r.replacement.serialNumber,
|
|
mpn: r.replacement.partModel.mpn,
|
|
state: r.replacement.state,
|
|
},
|
|
performedBy: r.performedBy,
|
|
performedAt: r.performedAt.toISOString(),
|
|
fmId: r.fmId,
|
|
};
|
|
}
|
|
|
|
export async function log(
|
|
tx: Tx,
|
|
input: LogRepairRequest,
|
|
actor: Actor,
|
|
): Promise<RepairWithRelations> {
|
|
const host = await resolveHost(tx, input);
|
|
|
|
// 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor.
|
|
const replacement = await tx.part.findUnique({
|
|
where: { serialNumber: input.replacementSerial },
|
|
include: { partModel: true },
|
|
});
|
|
if (!replacement) {
|
|
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
|
|
}
|
|
const heldForRepairByActor =
|
|
replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id;
|
|
if (replacement.state !== 'SPARE' && !heldForRepairByActor) {
|
|
throw errors.badRequest(
|
|
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
|
|
);
|
|
}
|
|
|
|
// 2. Resolve broken — reuse if found, else ingest.
|
|
let broken = await tx.part.findUnique({
|
|
where: { serialNumber: input.brokenSerial },
|
|
include: { partModel: true },
|
|
});
|
|
if (broken) {
|
|
if (broken.hostId && broken.hostId !== host.id) {
|
|
throw errors.badRequest(
|
|
`Broken part ${input.brokenSerial} is currently on a different host`,
|
|
);
|
|
}
|
|
} else {
|
|
let pm: { id: string; manufacturerId: string };
|
|
if (input.brokenPartModelId) {
|
|
const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } });
|
|
if (!existing) throw errors.badRequest('Broken part model does not exist');
|
|
pm = { id: existing.id, manufacturerId: existing.manufacturerId };
|
|
} else {
|
|
if (!input.brokenMpn || !input.brokenManufacturerId) {
|
|
throw errors.badRequest(
|
|
'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
|
|
);
|
|
}
|
|
pm = await partModelsSvc.upsertByMpn(tx, {
|
|
manufacturerId: input.brokenManufacturerId,
|
|
mpn: input.brokenMpn,
|
|
});
|
|
}
|
|
const created = await tx.part.create({
|
|
data: {
|
|
serialNumber: input.brokenSerial,
|
|
partModelId: pm.id,
|
|
manufacturerId: pm.manufacturerId,
|
|
state: 'DEPLOYED',
|
|
hostId: host.id,
|
|
},
|
|
include: { partModel: true },
|
|
});
|
|
await tx.partEvent.create({
|
|
data: {
|
|
partId: created.id,
|
|
userId: actor.id,
|
|
type: 'CREATED',
|
|
newValue: created.serialNumber,
|
|
},
|
|
});
|
|
broken = created;
|
|
}
|
|
|
|
// 3. Optional FM link — must belong to the same host; we do NOT auto-close it.
|
|
if (input.fmId) {
|
|
const fm = await tx.fm.findUnique({ where: { id: input.fmId } });
|
|
if (!fm) throw errors.badRequest('FM does not exist');
|
|
if (fm.hostId !== host.id) {
|
|
throw errors.badRequest('FM is on a different host than the repair');
|
|
}
|
|
}
|
|
|
|
// 4. Custody state is driven by the broken model's destroyOnFail flag.
|
|
const custodyState = broken.partModel.destroyOnFail
|
|
? 'PENDING_DESTRUCTION_IN_CUSTODY'
|
|
: 'PENDING_DROP_IN_CUSTODY';
|
|
|
|
// 5. Transition both parts through the standard parts.update machinery so every state
|
|
// and location change emits the usual PartEvents. The resolver clears host/bin
|
|
// automatically when entering custody / DEPLOYED.
|
|
await partsSvc.update(
|
|
tx,
|
|
broken.id,
|
|
{ state: custodyState, custodianId: actor.id },
|
|
actor,
|
|
);
|
|
await partsSvc.update(
|
|
tx,
|
|
replacement.id,
|
|
{ state: 'DEPLOYED', hostId: host.id },
|
|
actor,
|
|
);
|
|
|
|
// 6. Persist the Repair row.
|
|
const repair = await tx.repair.create({
|
|
data: {
|
|
hostId: host.id,
|
|
brokenPartId: broken.id,
|
|
replacementPartId: replacement.id,
|
|
performedById: actor.id,
|
|
fmId: input.fmId ?? null,
|
|
},
|
|
include: repairInclude,
|
|
});
|
|
|
|
// 7. Swap event on each part — so the part timeline shows the repair link.
|
|
await tx.partEvent.createMany({
|
|
data: [
|
|
{
|
|
partId: broken.id,
|
|
userId: actor.id,
|
|
type: 'PART_SWAPPED',
|
|
field: 'role',
|
|
oldValue: 'DEPLOYED',
|
|
newValue: repair.id,
|
|
},
|
|
{
|
|
partId: replacement.id,
|
|
userId: actor.id,
|
|
type: 'PART_SWAPPED',
|
|
field: 'role',
|
|
oldValue: 'SPARE',
|
|
newValue: repair.id,
|
|
},
|
|
],
|
|
});
|
|
|
|
void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } });
|
|
return repair;
|
|
}
|