feat: laundry-list polish pass
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>
This commit is contained in:
@@ -31,3 +31,19 @@ export async function dropOff(
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function takeForRepair(
|
||||
req: Request<{ partId: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
if (!req.user) throw errors.unauthorized();
|
||||
const part = await prisma.$transaction((tx) =>
|
||||
svc.takeForRepair(tx, req.params.partId, req.user!),
|
||||
);
|
||||
res.json(part);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ router.post(
|
||||
validate('body', DropOffRequest),
|
||||
ctrl.dropOff,
|
||||
);
|
||||
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx, Actor } from './types.js';
|
||||
import { dropOff } from './custody.js';
|
||||
import { dropOff, takeForRepair } from './custody.js';
|
||||
|
||||
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
|
||||
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
|
||||
@@ -166,4 +166,60 @@ describe('custody.dropOff', () => {
|
||||
dropOff(tx, 'p-missing', { binId: null }, custodian),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it('PENDING_REPAIR → SPARE when returned with a bin; custodian cleared', async () => {
|
||||
const { tx, current } = buildTx(
|
||||
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
|
||||
);
|
||||
|
||||
await dropOff(tx, 'p-1', { binId: 'bin-7' }, custodian);
|
||||
|
||||
expect(current.state).toBe('SPARE');
|
||||
expect(current.binId).toBe('bin-7');
|
||||
expect(current.custodianId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects PENDING_REPAIR drop-off without a bin with 400', async () => {
|
||||
const { tx, partUpdate } = buildTx(
|
||||
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
dropOff(tx, 'p-1', { binId: null }, custodian),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custody.takeForRepair', () => {
|
||||
it('SPARE → PENDING_REPAIR with the actor as custodian', async () => {
|
||||
const { tx, current } = buildTx(
|
||||
custodyPart({ state: 'SPARE', custodianId: null, custodian: null, binId: 'bin-1' }),
|
||||
);
|
||||
|
||||
await takeForRepair(tx, 'p-1', custodian);
|
||||
|
||||
expect(current.state).toBe('PENDING_REPAIR');
|
||||
expect(current.custodianId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('rejects take-for-repair on a non-SPARE part with 400', async () => {
|
||||
const { tx, partUpdate } = buildTx(custodyPart({ state: 'DEPLOYED' }));
|
||||
|
||||
await expect(takeForRepair(tx, 'p-1', custodian)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
expect(partUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects take-for-repair on a missing part with 404', async () => {
|
||||
const tx = {
|
||||
part: { findUnique: async () => null, update: vi.fn() },
|
||||
partEvent: { createMany: vi.fn() },
|
||||
} as unknown as Tx;
|
||||
|
||||
await expect(takeForRepair(tx, 'p-missing', custodian)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,16 @@ export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
// Map of custody-state → state the part returns to when dropped off.
|
||||
// PENDING_REPAIR is a spare the tech picked up for a future swap; on return it goes back to
|
||||
// SPARE and must land in a bin (no useful "in-limbo" state for spares). The broken-part
|
||||
// paths continue to allow binId=null since techs sometimes hold broken parts without a bin.
|
||||
const DROP_OFF_TARGET: Record<string, { next: 'BROKEN' | 'PENDING_DESTRUCTION' | 'SPARE'; binRequired: boolean }> = {
|
||||
PENDING_DROP_IN_CUSTODY: { next: 'BROKEN', binRequired: false },
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: { next: 'PENDING_DESTRUCTION', binRequired: false },
|
||||
PENDING_REPAIR: { next: 'SPARE', binRequired: true },
|
||||
};
|
||||
|
||||
export async function dropOff(
|
||||
tx: Tx,
|
||||
partId: string,
|
||||
@@ -36,23 +46,35 @@ export async function dropOff(
|
||||
const part = await tx.part.findUnique({ where: { id: partId } });
|
||||
if (!part) throw errors.notFound('Part');
|
||||
|
||||
if (
|
||||
part.state !== 'PENDING_DROP_IN_CUSTODY' &&
|
||||
part.state !== 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||
) {
|
||||
throw errors.badRequest('Part is not in custody');
|
||||
}
|
||||
const target = DROP_OFF_TARGET[part.state];
|
||||
if (!target) throw errors.badRequest('Part is not in custody');
|
||||
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
|
||||
throw errors.forbidden('Only the current custodian can drop off this part');
|
||||
}
|
||||
|
||||
const nextState =
|
||||
part.state === 'PENDING_DROP_IN_CUSTODY' ? 'BROKEN' : 'PENDING_DESTRUCTION';
|
||||
if (target.binRequired && !input.binId) {
|
||||
throw errors.badRequest('A bin is required when returning a spare to inventory');
|
||||
}
|
||||
|
||||
return partsSvc.update(
|
||||
tx,
|
||||
partId,
|
||||
{ state: nextState, binId: input.binId ?? null, custodianId: null },
|
||||
{ state: target.next, binId: input.binId ?? null, custodianId: null },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
// A tech takes a SPARE into their custody for a future repair. The part waits in
|
||||
// PENDING_REPAIR until it's used in a Repair (→ DEPLOYED) or dropped back into a bin (→ SPARE).
|
||||
export async function takeForRepair(tx: Tx, partId: string, actor: Actor) {
|
||||
const part = await tx.part.findUnique({ where: { id: partId } });
|
||||
if (!part) throw errors.notFound('Part');
|
||||
if (part.state !== 'SPARE') {
|
||||
throw errors.badRequest('Only SPARE parts can be taken for a repair');
|
||||
}
|
||||
return partsSvc.update(
|
||||
tx,
|
||||
partId,
|
||||
{ state: 'PENDING_REPAIR', custodianId: actor.id },
|
||||
actor,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Tx } from './types.js';
|
||||
import { create, update } from './hosts.js';
|
||||
|
||||
interface HostRow {
|
||||
id: string;
|
||||
assetId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
state: string;
|
||||
stack: string;
|
||||
}
|
||||
|
||||
function buildTx(seed: HostRow[] = []) {
|
||||
const registry = new Map(seed.map((h) => [h.id, h]));
|
||||
|
||||
const tx = {
|
||||
host: {
|
||||
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
|
||||
const row: HostRow = {
|
||||
id: `host-${registry.size + 1}`,
|
||||
assetId: String(args.data.assetId),
|
||||
name: String(args.data.name),
|
||||
location: (args.data.location as string | null) ?? null,
|
||||
notes: (args.data.notes as string | null) ?? null,
|
||||
state: String(args.data.state ?? 'DEPLOYED'),
|
||||
stack: String(args.data.stack ?? 'PRODUCTION'),
|
||||
};
|
||||
registry.set(row.id, row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
|
||||
const current = registry.get(args.where.id);
|
||||
if (!current) throw new Error(`No host ${args.where.id}`);
|
||||
const d = args.data;
|
||||
if (d.assetId !== undefined) current.assetId = String(d.assetId);
|
||||
if (d.name !== undefined) current.name = String(d.name);
|
||||
if (d.location !== undefined) current.location = (d.location as string | null) ?? null;
|
||||
if (d.notes !== undefined) current.notes = (d.notes as string | null) ?? null;
|
||||
if (d.state !== undefined) current.state = String(d.state);
|
||||
if (d.stack !== undefined) current.stack = String(d.stack);
|
||||
return current;
|
||||
}),
|
||||
},
|
||||
} as unknown as Tx;
|
||||
|
||||
return { tx, registry };
|
||||
}
|
||||
|
||||
describe('hosts.create', () => {
|
||||
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' });
|
||||
|
||||
expect(host.state).toBe('DEPLOYED');
|
||||
expect(host.stack).toBe('PRODUCTION');
|
||||
});
|
||||
|
||||
it('persists explicit state and stack', async () => {
|
||||
const { tx } = buildTx();
|
||||
|
||||
const host = await create(tx, {
|
||||
assetId: 'A-2',
|
||||
name: 'rack-2',
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
});
|
||||
|
||||
expect(host.state).toBe('TESTING');
|
||||
expect(host.stack).toBe('VETTING');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hosts.update', () => {
|
||||
it('updates state and stack when provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
]);
|
||||
|
||||
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' });
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('DEGRADED');
|
||||
expect(row.stack).toBe('VETTING');
|
||||
});
|
||||
|
||||
it('leaves state/stack untouched when not provided', async () => {
|
||||
const { tx, registry } = buildTx([
|
||||
{
|
||||
id: 'host-1',
|
||||
assetId: 'A-1',
|
||||
name: 'rack-1',
|
||||
location: null,
|
||||
notes: null,
|
||||
state: 'TESTING',
|
||||
stack: 'VETTING',
|
||||
},
|
||||
]);
|
||||
|
||||
await update(tx, 'host-1', { name: 'rack-1-renamed' });
|
||||
|
||||
const row = registry.get('host-1')!;
|
||||
expect(row.state).toBe('TESTING');
|
||||
expect(row.stack).toBe('VETTING');
|
||||
expect(row.name).toBe('rack-1-renamed');
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,16 @@ function mapUniqueViolation(target: unknown): string {
|
||||
|
||||
export async function list(tx: Tx, q: HostListQuery) {
|
||||
const { page, pageSize, q: search } = q;
|
||||
const where: Prisma.HostWhereInput = search
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: search } },
|
||||
{ assetId: { contains: search } },
|
||||
{ location: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
const where: Prisma.HostWhereInput = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ assetId: { contains: search } },
|
||||
{ location: { contains: search } },
|
||||
];
|
||||
}
|
||||
if (q.state) where.state = q.state;
|
||||
if (q.stack) where.stack = q.stack;
|
||||
const [data, total] = await Promise.all([
|
||||
tx.host.findMany({
|
||||
where,
|
||||
@@ -55,6 +56,8 @@ export async function create(tx: Tx, input: CreateHostRequest) {
|
||||
name: input.name,
|
||||
location: input.location ?? null,
|
||||
notes: input.notes ?? null,
|
||||
state: input.state ?? 'DEPLOYED',
|
||||
stack: input.stack ?? 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -71,6 +74,8 @@ export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
|
||||
if (input.name !== undefined) data.name = input.name;
|
||||
if (input.location !== undefined) data.location = input.location;
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
if (input.state !== undefined) data.state = input.state;
|
||||
if (input.stack !== undefined) data.stack = input.stack;
|
||||
try {
|
||||
return await tx.host.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,13 +9,15 @@ import type { Tx } from './types.js';
|
||||
|
||||
const partModelInclude = {
|
||||
manufacturer: true,
|
||||
category: true,
|
||||
_count: { select: { parts: true } },
|
||||
} satisfies Prisma.PartModelInclude;
|
||||
|
||||
export async function list(tx: Tx, q: PartModelListQuery) {
|
||||
const { page, pageSize, manufacturerId, q: search, eolBefore } = q;
|
||||
const { page, pageSize, manufacturerId, categoryId, q: search, eolBefore } = q;
|
||||
const where: Prisma.PartModelWhereInput = {};
|
||||
if (manufacturerId) where.manufacturerId = manufacturerId;
|
||||
if (categoryId) where.categoryId = categoryId;
|
||||
if (search) where.mpn = { contains: search };
|
||||
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
|
||||
|
||||
@@ -42,6 +44,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||
data: {
|
||||
manufacturerId: input.manufacturerId,
|
||||
mpn: input.mpn,
|
||||
categoryId: input.categoryId ?? null,
|
||||
eolDate: input.eolDate ? new Date(input.eolDate) : null,
|
||||
destroyOnFail: input.destroyOnFail ?? false,
|
||||
notes: input.notes ?? null,
|
||||
@@ -51,7 +54,7 @@ export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer does not exist');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
@@ -63,6 +66,11 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
|
||||
data.manufacturer = { connect: { id: input.manufacturerId } };
|
||||
}
|
||||
if (input.mpn !== undefined) data.mpn = input.mpn;
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.eolDate !== undefined) {
|
||||
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
|
||||
}
|
||||
@@ -74,6 +82,7 @@ export async function update(tx: Tx, id: string, input: UpdatePartModelRequest)
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2025') throw errors.notFound('Part model');
|
||||
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
|
||||
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { Actor, Tx } from './types.js';
|
||||
// The matrix is:
|
||||
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
|
||||
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
|
||||
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
|
||||
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY / PENDING_REPAIR
|
||||
// — custodianId required, host + bin forbidden
|
||||
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
|
||||
function resolveLocation(
|
||||
@@ -43,7 +43,11 @@ function resolveLocation(
|
||||
return { binId: null, hostId, custodianId: null };
|
||||
}
|
||||
|
||||
if (state === 'PENDING_DROP_IN_CUSTODY' || state === 'PENDING_DESTRUCTION_IN_CUSTODY') {
|
||||
if (
|
||||
state === 'PENDING_DROP_IN_CUSTODY' ||
|
||||
state === 'PENDING_DESTRUCTION_IN_CUSTODY' ||
|
||||
state === 'PENDING_REPAIR'
|
||||
) {
|
||||
const custodianId =
|
||||
input.custodianId !== undefined ? input.custodianId : current.custodianId;
|
||||
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
|
||||
@@ -65,9 +69,8 @@ function resolveLocation(
|
||||
|
||||
const partInclude = {
|
||||
manufacturer: true,
|
||||
partModel: true,
|
||||
partModel: { include: { category: true } },
|
||||
bin: { include: { room: { include: { site: true } } } },
|
||||
category: true,
|
||||
host: true,
|
||||
custodian: { select: { id: true, username: true } },
|
||||
tags: { include: { tag: true } },
|
||||
@@ -127,7 +130,6 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
|
||||
if (q.custodianId) where.custodianId = q.custodianId;
|
||||
if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
|
||||
if (q.partModelId) where.partModelId = q.partModelId;
|
||||
if (q.categoryId) where.categoryId = q.categoryId;
|
||||
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
|
||||
if (q.q) {
|
||||
where.OR = [
|
||||
@@ -141,6 +143,7 @@ function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
|
||||
const partModelFilter: Prisma.PartModelWhereInput = {};
|
||||
if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
|
||||
if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
|
||||
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
|
||||
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
|
||||
|
||||
return where;
|
||||
@@ -196,7 +199,6 @@ export async function create(
|
||||
binId: location.binId,
|
||||
hostId: location.hostId,
|
||||
custodianId: location.custodianId,
|
||||
categoryId: input.categoryId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
include: partInclude,
|
||||
@@ -276,11 +278,6 @@ export async function update(
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
|
||||
let part: PartWithRelations;
|
||||
@@ -357,16 +354,6 @@ export async function update(
|
||||
newValue: input.serialNumber,
|
||||
});
|
||||
}
|
||||
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'category',
|
||||
oldValue: current.category?.name ?? null,
|
||||
newValue: part.category?.name ?? null,
|
||||
});
|
||||
}
|
||||
if (input.price !== undefined && input.price !== current.price) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
|
||||
@@ -514,6 +514,82 @@ describe('repairs.log — validation failures', () => {
|
||||
expect(events).toEqual(['repair.logged']);
|
||||
});
|
||||
|
||||
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
serialNumber: 'SN-BROKEN',
|
||||
partModelId: brokenModel.id,
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
host: host1,
|
||||
partModel: brokenModel,
|
||||
});
|
||||
const replacement = partRow({
|
||||
id: 'p-replacement',
|
||||
serialNumber: 'SN-REPLACE',
|
||||
partModelId: replacementModel.id,
|
||||
state: 'PENDING_REPAIR',
|
||||
custodianId: actor.id,
|
||||
custodian: { id: actor.id, username: actor.username },
|
||||
partModel: replacementModel,
|
||||
});
|
||||
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||
|
||||
const r = await log(
|
||||
tx,
|
||||
{
|
||||
hostId: 'host-1',
|
||||
brokenSerial: 'SN-BROKEN',
|
||||
brokenMpn: 'WD-BROKEN',
|
||||
brokenManufacturerId: 'mfr-1',
|
||||
replacementSerial: 'SN-REPLACE',
|
||||
},
|
||||
actor,
|
||||
);
|
||||
|
||||
expect(r.id).toBe('repair-1');
|
||||
const updatedReplacement = registry.get('p-replacement')!;
|
||||
expect(updatedReplacement.state).toBe('DEPLOYED');
|
||||
expect(updatedReplacement.hostId).toBe('host-1');
|
||||
expect(updatedReplacement.custodianId).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a PENDING_REPAIR replacement held by someone else with 400', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
serialNumber: 'SN-BROKEN',
|
||||
partModelId: brokenModel.id,
|
||||
state: 'DEPLOYED',
|
||||
hostId: 'host-1',
|
||||
host: host1,
|
||||
partModel: brokenModel,
|
||||
});
|
||||
const replacement = partRow({
|
||||
id: 'p-replacement',
|
||||
serialNumber: 'SN-REPLACE',
|
||||
partModelId: replacementModel.id,
|
||||
state: 'PENDING_REPAIR',
|
||||
custodianId: 'user-other',
|
||||
custodian: { id: 'user-other', username: 'someone' },
|
||||
partModel: replacementModel,
|
||||
});
|
||||
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
|
||||
|
||||
await expect(
|
||||
log(
|
||||
tx,
|
||||
{
|
||||
hostId: 'host-1',
|
||||
brokenSerial: 'SN-BROKEN',
|
||||
brokenMpn: 'WD-BROKEN',
|
||||
brokenManufacturerId: 'mfr-1',
|
||||
replacementSerial: 'SN-REPLACE',
|
||||
},
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 400 });
|
||||
});
|
||||
|
||||
it('rejects when broken part is on a different host than the repair', async () => {
|
||||
const broken = partRow({
|
||||
id: 'p-broken',
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function log(
|
||||
): Promise<RepairWithRelations> {
|
||||
const host = await resolveHost(tx, input);
|
||||
|
||||
// 1. Resolve replacement — must exist, must be SPARE.
|
||||
// 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 },
|
||||
@@ -86,9 +86,11 @@ export async function log(
|
||||
if (!replacement) {
|
||||
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
|
||||
}
|
||||
if (replacement.state !== 'SPARE') {
|
||||
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`,
|
||||
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,10 +106,22 @@ export async function log(
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const pm = await partModelsSvc.upsertByMpn(tx, {
|
||||
manufacturerId: input.brokenManufacturerId,
|
||||
mpn: input.brokenMpn,
|
||||
});
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user