feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s

The old Repairs module had grown ticketing-system features (status lifecycle,
comments, assignee, notes) that duplicate what the external ticketing tool
already owns. Vector only needs to track whether maintenance is open or closed.

- Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes
- New Repair table: persistent log of physical part swaps, with ingest on
  unknown broken MPN via partModels.upsertByMpn
- New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
  states + Part.custodianId, with a "My Custody" page for drop-off
- PartModel.destroyOnFail routes broken parts to the destruction path
- Host lookup on /fms and /repairs accepts hostId XOR assetId
- Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged
- Single fresh Prisma migration (dev DB was wiped, no backfill)

Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts,
repairs.test.ts, custody.test.ts covering happy paths, validation failures,
webhook emissions, and ingest-on-unknown-MPN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 12:22:56 -04:00
parent 6690d8a5dd
commit 3d77f2846d
54 changed files with 3304 additions and 1287 deletions
+9 -9
View File
@@ -14,7 +14,7 @@ function makeTx(args: {
createdAt: Date;
partModelId: string;
}[];
openRepairs: number;
openFms: number;
eolPartModels: {
id: string;
mpn: string;
@@ -35,8 +35,8 @@ function makeTx(args: {
})),
findMany: async () => args.parts,
},
repairJob: {
count: async () => args.openRepairs,
fm: {
count: async () => args.openFms,
},
partModel: {
findMany: async () => args.eolPartModels,
@@ -52,7 +52,7 @@ const now = new Date('2026-04-16T00:00:00.000Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
describe('analytics.dashboard', () => {
it('aggregates totals, state counts and open repairs', async () => {
it('aggregates totals, state counts and open FMs', async () => {
const tx = makeTx({
partCount: 5,
stateRows: [
@@ -60,14 +60,14 @@ describe('analytics.dashboard', () => {
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
],
parts: [],
openRepairs: 4,
openFms: 4,
eolPartModels: [],
bins: [],
});
const r = await dashboard(tx);
expect(r.totalParts).toBe(5);
expect(r.openRepairs).toBe(4);
expect(r.openFms).toBe(4);
expect(r.byState).toEqual([
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
@@ -84,7 +84,7 @@ describe('analytics.dashboard', () => {
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
],
openRepairs: 0,
openFms: 0,
eolPartModels: [],
bins: [],
});
@@ -109,7 +109,7 @@ describe('analytics.dashboard', () => {
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
],
openRepairs: 0,
openFms: 0,
eolPartModels: [],
bins: [
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
@@ -133,7 +133,7 @@ describe('analytics.dashboard', () => {
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
],
openRepairs: 0,
openFms: 0,
eolPartModels: [
{
id: 'pm1',