feat: rework EOL, repairs, and hosts for real workflow
Four domain-model changes driven by exercising the deployed 2.0 build: - EOL moves from manufacturer to MPN via new PartModel catalog table, so alerts fire on the thing that actually ages. - Repairs re-home to Host (required hostId + problem text) with an optional RepairJobPart join for affected parts; drop Part.replacementPartId. - New /repairs/:id detail page with editable problem, part list, and a RepairComment thread (REPAIR_COMMENTED events fan out to each problem part's timeline). - Host.assetId (required, unique) surfaces prominently on the repair page so techs can confirm they're touching the right box. Single destructive migration reshapes existing dev data. All 7 packages typecheck clean; 30 API tests pass (9 new covering host membership, upsertByMpn idempotency + race, assetId 409, comment userId stamping). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,16 @@ function makeTx(args: {
|
||||
state: string;
|
||||
binId: string | null;
|
||||
createdAt: Date;
|
||||
manufacturerId: string;
|
||||
partModelId: string;
|
||||
}[];
|
||||
openRepairs: number;
|
||||
eolManufacturers: { id: string; name: string; eolDate: Date | null }[];
|
||||
eolPartModels: {
|
||||
id: string;
|
||||
mpn: string;
|
||||
eolDate: Date | null;
|
||||
manufacturerId: string;
|
||||
manufacturer: { name: string };
|
||||
}[];
|
||||
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
|
||||
}): Tx {
|
||||
const tx = {
|
||||
@@ -32,8 +38,8 @@ function makeTx(args: {
|
||||
repairJob: {
|
||||
count: async () => args.openRepairs,
|
||||
},
|
||||
manufacturer: {
|
||||
findMany: async () => args.eolManufacturers,
|
||||
partModel: {
|
||||
findMany: async () => args.eolPartModels,
|
||||
},
|
||||
bin: {
|
||||
findMany: async () => args.bins,
|
||||
@@ -55,7 +61,7 @@ describe('analytics.dashboard', () => {
|
||||
],
|
||||
parts: [],
|
||||
openRepairs: 4,
|
||||
eolManufacturers: [],
|
||||
eolPartModels: [],
|
||||
bins: [],
|
||||
});
|
||||
|
||||
@@ -73,13 +79,13 @@ describe('analytics.dashboard', () => {
|
||||
partCount: 4,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' },
|
||||
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' },
|
||||
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' },
|
||||
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' },
|
||||
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' },
|
||||
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' },
|
||||
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
|
||||
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
|
||||
],
|
||||
openRepairs: 0,
|
||||
eolManufacturers: [],
|
||||
eolPartModels: [],
|
||||
bins: [],
|
||||
});
|
||||
|
||||
@@ -98,13 +104,13 @@ describe('analytics.dashboard', () => {
|
||||
partCount: 4,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
],
|
||||
openRepairs: 0,
|
||||
eolManufacturers: [],
|
||||
eolPartModels: [],
|
||||
bins: [
|
||||
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
|
||||
{ id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } },
|
||||
@@ -118,27 +124,53 @@ describe('analytics.dashboard', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('flags manufacturers whose EOL has passed and have deployed parts', async () => {
|
||||
it('flags part models whose EOL has passed and have deployed parts', async () => {
|
||||
const tx = makeTx({
|
||||
partCount: 3,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
|
||||
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
|
||||
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' },
|
||||
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
|
||||
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
|
||||
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
|
||||
],
|
||||
openRepairs: 0,
|
||||
eolManufacturers: [
|
||||
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) },
|
||||
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) },
|
||||
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) },
|
||||
eolPartModels: [
|
||||
{
|
||||
id: 'pm1',
|
||||
mpn: 'ACM-100',
|
||||
eolDate: daysAgo(30),
|
||||
manufacturerId: 'm1',
|
||||
manufacturer: { name: 'Acme' },
|
||||
},
|
||||
{
|
||||
id: 'pm2',
|
||||
mpn: 'BET-200',
|
||||
eolDate: daysAgo(10),
|
||||
manufacturerId: 'm2',
|
||||
manufacturer: { name: 'Beta' },
|
||||
},
|
||||
{
|
||||
id: 'pm3',
|
||||
mpn: 'GAM-300',
|
||||
eolDate: daysAgo(5),
|
||||
manufacturerId: 'm3',
|
||||
manufacturer: { name: 'Gamma' },
|
||||
},
|
||||
],
|
||||
bins: [],
|
||||
});
|
||||
|
||||
const r = await dashboard(tx);
|
||||
expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']);
|
||||
expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 });
|
||||
expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 });
|
||||
expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']);
|
||||
expect(r.deployedPastEol[0]).toMatchObject({
|
||||
partModelId: 'pm1',
|
||||
manufacturerName: 'Acme',
|
||||
deployedCount: 2,
|
||||
});
|
||||
expect(r.deployedPastEol[1]).toMatchObject({
|
||||
partModelId: 'pm2',
|
||||
manufacturerName: 'Beta',
|
||||
deployedCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user