feat(part-models): detail page with fleet insights
Adds /part-models/:id mirroring host/part detail pattern: KPIs for units, spend, avg price, failure counts, and FMs implicating the model, a state-breakdown bar chart, and the parts-of-this-model table. New GET /part-models/:id/insights aggregates via part.groupBy + aggregate and repair/fm counts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,20 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInsights(
|
||||
req: Request<{ id: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
try {
|
||||
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
|
||||
if (!insights) throw errors.notFound('Part model');
|
||||
res.json(insights);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const input = req.validated!.body as CreatePartModelRequest;
|
||||
|
||||
@@ -12,6 +12,7 @@ const router = Router();
|
||||
|
||||
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
|
||||
router.get('/:id', requireAuth, ctrl.get);
|
||||
router.get('/:id/insights', requireAuth, ctrl.getInsights);
|
||||
router.post(
|
||||
'/',
|
||||
requireAuth,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Tx } from './types.js';
|
||||
import { getInsights } from './part-models.js';
|
||||
|
||||
// Minimal in-memory tx double exercising getInsights(). We only stub the
|
||||
// calls that getInsights actually makes.
|
||||
function makeTx(args: {
|
||||
modelExists: boolean;
|
||||
partCount: number;
|
||||
stateRows: { state: string; count: number; totalPrice: number }[];
|
||||
priceAgg: {
|
||||
sum: number | null;
|
||||
avg: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
count: number;
|
||||
};
|
||||
repairCount: number;
|
||||
distinctFailedBrokenPartIds: string[];
|
||||
fmCount: number;
|
||||
}): Tx {
|
||||
const tx = {
|
||||
partModel: {
|
||||
findUnique: async () => (args.modelExists ? { id: 'pm' } : null),
|
||||
},
|
||||
part: {
|
||||
count: async () => args.partCount,
|
||||
groupBy: async () =>
|
||||
args.stateRows.map((s) => ({
|
||||
state: s.state,
|
||||
_count: { _all: s.count },
|
||||
_sum: { price: s.totalPrice },
|
||||
})),
|
||||
aggregate: async () => ({
|
||||
_sum: { price: args.priceAgg.sum },
|
||||
_avg: { price: args.priceAgg.avg },
|
||||
_min: { price: args.priceAgg.min },
|
||||
_max: { price: args.priceAgg.max },
|
||||
_count: { _all: args.priceAgg.count },
|
||||
}),
|
||||
},
|
||||
repair: {
|
||||
count: async () => args.repairCount,
|
||||
findMany: async () =>
|
||||
args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })),
|
||||
},
|
||||
fm: {
|
||||
count: async () => args.fmCount,
|
||||
},
|
||||
};
|
||||
return tx as unknown as Tx;
|
||||
}
|
||||
|
||||
describe('part-models.getInsights', () => {
|
||||
it('returns null when model does not exist', async () => {
|
||||
const tx = makeTx({
|
||||
modelExists: false,
|
||||
partCount: 0,
|
||||
stateRows: [],
|
||||
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
|
||||
repairCount: 0,
|
||||
distinctFailedBrokenPartIds: [],
|
||||
fmCount: 0,
|
||||
});
|
||||
const r = await getInsights(tx, 'nope');
|
||||
expect(r).toBeNull();
|
||||
});
|
||||
|
||||
it('aggregates totalParts and groups by state', async () => {
|
||||
const tx = makeTx({
|
||||
modelExists: true,
|
||||
partCount: 3,
|
||||
stateRows: [
|
||||
{ state: 'SPARE', count: 1, totalPrice: 100 },
|
||||
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
|
||||
],
|
||||
priceAgg: { sum: 900, avg: 450, min: 100, max: 500, count: 2 },
|
||||
repairCount: 0,
|
||||
distinctFailedBrokenPartIds: [],
|
||||
fmCount: 0,
|
||||
});
|
||||
|
||||
const r = await getInsights(tx, 'pm');
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.totalParts).toBe(3);
|
||||
expect(r!.byState).toEqual([
|
||||
{ state: 'SPARE', count: 1, totalPrice: 100 },
|
||||
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('computes price stats from aggregate (countWithPrice drives average)', async () => {
|
||||
const tx = makeTx({
|
||||
modelExists: true,
|
||||
partCount: 3,
|
||||
stateRows: [],
|
||||
// 2 priced parts ($100 + $500), 1 null-priced part
|
||||
priceAgg: { sum: 600, avg: 300, min: 100, max: 500, count: 2 },
|
||||
repairCount: 0,
|
||||
distinctFailedBrokenPartIds: [],
|
||||
fmCount: 0,
|
||||
});
|
||||
|
||||
const r = await getInsights(tx, 'pm');
|
||||
expect(r!.priceStats).toEqual({
|
||||
total: 600,
|
||||
average: 300,
|
||||
min: 100,
|
||||
max: 500,
|
||||
countWithPrice: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('zeros price stats when no parts are priced', async () => {
|
||||
const tx = makeTx({
|
||||
modelExists: true,
|
||||
partCount: 2,
|
||||
stateRows: [],
|
||||
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
|
||||
repairCount: 0,
|
||||
distinctFailedBrokenPartIds: [],
|
||||
fmCount: 0,
|
||||
});
|
||||
|
||||
const r = await getInsights(tx, 'pm');
|
||||
expect(r!.priceStats).toEqual({
|
||||
total: 0,
|
||||
average: 0,
|
||||
min: null,
|
||||
max: null,
|
||||
countWithPrice: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts repairs, distinct failed parts, and FMs implicating the model', async () => {
|
||||
const tx = makeTx({
|
||||
modelExists: true,
|
||||
partCount: 5,
|
||||
stateRows: [],
|
||||
priceAgg: { sum: 0, avg: null, min: null, max: null, count: 0 },
|
||||
repairCount: 3,
|
||||
// one part failed twice → 2 distinct broken parts
|
||||
distinctFailedBrokenPartIds: ['part-a', 'part-b'],
|
||||
fmCount: 2,
|
||||
});
|
||||
|
||||
const r = await getInsights(tx, 'pm');
|
||||
expect(r!.failures).toEqual({
|
||||
repairs: 3,
|
||||
distinctFailedParts: 2,
|
||||
fmsImplicating: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma } from '@vector/db';
|
||||
import type {
|
||||
CreatePartModelRequest,
|
||||
PartModelInsights,
|
||||
PartModelListQuery,
|
||||
UpdatePartModelRequest,
|
||||
} from '@vector/shared';
|
||||
@@ -38,6 +39,59 @@ export function get(tx: Tx, id: string) {
|
||||
return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
|
||||
}
|
||||
|
||||
export async function getInsights(tx: Tx, id: string): Promise<PartModelInsights | null> {
|
||||
const model = await tx.partModel.findUnique({ where: { id }, select: { id: true } });
|
||||
if (!model) return null;
|
||||
|
||||
const [totalParts, stateRows, priceAgg, repairs, failedParts, fmsImplicating] = await Promise.all([
|
||||
tx.part.count({ where: { partModelId: id } }),
|
||||
tx.part.groupBy({
|
||||
by: ['state'],
|
||||
where: { partModelId: id },
|
||||
_count: { _all: true },
|
||||
_sum: { price: true },
|
||||
}),
|
||||
tx.part.aggregate({
|
||||
where: { partModelId: id, price: { not: null } },
|
||||
_sum: { price: true },
|
||||
_avg: { price: true },
|
||||
_min: { price: true },
|
||||
_max: { price: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
tx.repair.count({ where: { brokenPart: { partModelId: id } } }),
|
||||
tx.repair.findMany({
|
||||
where: { brokenPart: { partModelId: id } },
|
||||
select: { brokenPartId: true },
|
||||
distinct: ['brokenPartId'],
|
||||
}),
|
||||
tx.fm.count({ where: { problemParts: { some: { part: { partModelId: id } } } } }),
|
||||
]);
|
||||
|
||||
const byState = stateRows.map((row) => ({
|
||||
state: row.state as PartModelInsights['byState'][number]['state'],
|
||||
count: row._count._all,
|
||||
totalPrice: row._sum.price ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
totalParts,
|
||||
byState,
|
||||
priceStats: {
|
||||
total: priceAgg._sum.price ?? 0,
|
||||
average: priceAgg._avg.price ?? 0,
|
||||
min: priceAgg._min.price,
|
||||
max: priceAgg._max.price,
|
||||
countWithPrice: priceAgg._count._all,
|
||||
},
|
||||
failures: {
|
||||
repairs,
|
||||
distinctFailedParts: failedParts.length,
|
||||
fmsImplicating,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function create(tx: Tx, input: CreatePartModelRequest) {
|
||||
try {
|
||||
return await tx.partModel.create({
|
||||
|
||||
Reference in New Issue
Block a user