feat(categories): detail page with fleet insights
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m6s

Clicking a category anywhere in the app now opens /categories/:id with
MPN breakdown, manufacturer mix, failures by MPN, and past-EOL exposure
— a dual of the manufacturer detail page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 15:41:47 -04:00
parent 62a3d615f4
commit a2b088463d
15 changed files with 1016 additions and 7 deletions
+25
View File
@@ -6,6 +6,7 @@ import type {
UpdateCategoryRequest,
} from '@vector/shared';
import * as svc from '../services/categories.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
@@ -17,6 +18,30 @@ export async function list(req: Request, res: Response, next: NextFunction) {
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const category = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!category) throw errors.notFound('Category');
res.json(category);
} catch (err) {
next(err);
}
}
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('Category');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateCategoryRequest;
+2
View File
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+217
View File
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './categories.js';
// Minimal in-memory tx double exercising categories.getInsights().
// Only the calls the function makes are stubbed.
interface FakeArgs {
categoryExists: boolean;
totalPartModels: number;
totalParts: number;
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
fmCount: number;
modelStateGroups: { partModelId: string; state: string; count: number }[];
allModels: {
id: string;
mpn: string;
manufacturer: { id: string; name: string };
}[];
eolModels: { id: string; mpn: string; eolDate: Date | null }[];
repairsWithModel: { brokenPart: { partModelId: string } }[];
}
function makeTx(args: FakeArgs): Tx {
const tx = {
category: {
findUnique: async () => (args.categoryExists ? { id: 'cat' } : null),
},
partModel: {
count: async () => args.totalPartModels,
findMany: async (opts: { where?: { eolDate?: unknown } }) => {
if (opts?.where && 'eolDate' in opts.where) return args.eolModels;
return args.allModels;
},
},
part: {
count: async () => args.totalParts,
groupBy: async () =>
args.modelStateGroups.map((g) => ({
partModelId: g.partModelId,
state: g.state,
_count: { _all: g.count },
})),
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 (opts: { select?: { brokenPartId?: boolean } }) => {
if (opts?.select && 'brokenPartId' in opts.select) {
return args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId }));
}
return args.repairsWithModel;
},
},
fm: {
count: async () => args.fmCount,
},
};
return tx as unknown as Tx;
}
const empty: FakeArgs = {
categoryExists: true,
totalPartModels: 0,
totalParts: 0,
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
fmCount: 0,
modelStateGroups: [],
allModels: [],
eolModels: [],
repairsWithModel: [],
};
describe('categories.getInsights', () => {
it('returns null when category does not exist', async () => {
const tx = makeTx({ ...empty, categoryExists: false });
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totals and price stats', async () => {
const tx = makeTx({
...empty,
totalPartModels: 3,
totalParts: 12,
priceAgg: { sum: 1200, avg: 300, min: 100, max: 500, count: 4 },
});
const r = await getInsights(tx, 'cat');
expect(r!.totalPartModels).toBe(3);
expect(r!.totalParts).toBe(12);
expect(r!.priceStats).toEqual({
total: 1200,
average: 300,
min: 100,
max: 500,
countWithPrice: 4,
});
});
it('zeros price stats when no parts priced', async () => {
const tx = makeTx({ ...empty, totalParts: 5 });
const r = await getInsights(tx, 'cat');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs, distinct failed parts, and FMs implicating the category', async () => {
const tx = makeTx({
...empty,
repairCount: 5,
distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'],
fmCount: 4,
});
const r = await getInsights(tx, 'cat');
expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3, fmsImplicating: 4 });
});
it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => {
const modelStateGroups = Array.from({ length: 9 }).map((_, i) => ({
partModelId: `pm${i + 1}`,
state: 'SPARE',
count: i + 1,
}));
const allModels = modelStateGroups.map((g) => ({
id: g.partModelId,
mpn: `MPN-${g.partModelId}`,
manufacturer: { id: 'mfr1', name: 'Acme' },
}));
const tx = makeTx({ ...empty, modelStateGroups, allModels });
const r = await getInsights(tx, 'cat');
expect(r!.topModelsByUnits).toHaveLength(8);
expect(r!.topModelsByUnits[0]).toEqual({ partModelId: 'pm9', mpn: 'MPN-pm9', count: 9 });
expect(r!.topModelsByUnits[7]).toEqual({ partModelId: 'pm2', mpn: 'MPN-pm2', count: 2 });
expect(r!.topModelsByUnits.find((m) => m.partModelId === 'pm1')).toBeUndefined();
});
it('groups failuresByModel, joins MPN, and sorts desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: 'pmA', mpn: 'AAA', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: 'pmB', mpn: 'BBB', manufacturer: { id: 'm1', name: 'Acme' } },
],
repairsWithModel: [
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmB' } },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.failuresByModel).toEqual([
{ partModelId: 'pmA', mpn: 'AAA', repairs: 3 },
{ partModelId: 'pmB', mpn: 'BBB', repairs: 1 },
]);
});
it('groups byManufacturer from allModels, sorted desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: '1', mpn: 'A', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: '2', mpn: 'B', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: '3', mpn: 'C', manufacturer: { id: 'm2', name: 'Beta' } },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.byManufacturer).toEqual([
{ manufacturerId: 'm1', manufacturerName: 'Acme', count: 2 },
{ manufacturerId: 'm2', manufacturerName: 'Beta', count: 1 },
]);
});
it('pastEolModels includes only models with deployedCount > 0', async () => {
const tx = makeTx({
...empty,
modelStateGroups: [
{ partModelId: 'pmEOL1', state: 'DEPLOYED', count: 2 },
{ partModelId: 'pmEOL1', state: 'SPARE', count: 1 },
{ partModelId: 'pmEOL2', state: 'SPARE', count: 5 },
],
eolModels: [
{ id: 'pmEOL1', mpn: 'OLD-1', eolDate: new Date('2024-01-01') },
{ id: 'pmEOL2', mpn: 'OLD-2', eolDate: new Date('2024-01-01') },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.pastEolModels).toEqual([
{
partModelId: 'pmEOL1',
mpn: 'OLD-1',
eolDate: new Date('2024-01-01').toISOString(),
deployedCount: 2,
},
]);
});
});
+147
View File
@@ -1,5 +1,6 @@
import { Prisma } from '@vector/db';
import type {
CategoryInsights,
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
@@ -7,6 +8,152 @@ import type {
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export function get(tx: Tx, id: string) {
return tx.category.findUnique({
where: { id },
include: { _count: { select: { partModels: true } } },
});
}
export async function getInsights(tx: Tx, id: string): Promise<CategoryInsights | null> {
const category = await tx.category.findUnique({ where: { id }, select: { id: true } });
if (!category) return null;
const now = new Date();
const modelWhere = { partModel: { categoryId: id } };
const [
totalPartModels,
totalParts,
priceAgg,
repairsCount,
distinctFailedParts,
fmsImplicating,
modelStateGroups,
allModels,
eolModels,
repairsWithModel,
] = await Promise.all([
tx.partModel.count({ where: { categoryId: id } }),
tx.part.count({ where: modelWhere }),
tx.part.aggregate({
where: { ...modelWhere, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: modelWhere } }),
tx.repair.findMany({
where: { brokenPart: modelWhere },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
tx.fm.count({
where: { problemParts: { some: { part: modelWhere } } },
}),
tx.part.groupBy({
by: ['partModelId', 'state'],
where: modelWhere,
_count: { _all: true },
}),
tx.partModel.findMany({
where: { categoryId: id },
select: {
id: true,
mpn: true,
manufacturer: { select: { id: true, name: true } },
},
}),
tx.partModel.findMany({
where: { categoryId: id, eolDate: { not: null, lte: now } },
select: { id: true, mpn: true, eolDate: true },
}),
tx.repair.findMany({
where: { brokenPart: modelWhere },
select: { brokenPart: { select: { partModelId: true } } },
}),
]);
const mpnById = new Map(allModels.map((m) => [m.id, m.mpn]));
const unitsByModel = new Map<string, number>();
const deployedByModel = new Map<string, number>();
for (const row of modelStateGroups) {
const n = row._count._all;
unitsByModel.set(row.partModelId, (unitsByModel.get(row.partModelId) ?? 0) + n);
if (row.state === 'DEPLOYED') {
deployedByModel.set(row.partModelId, (deployedByModel.get(row.partModelId) ?? 0) + n);
}
}
const topModelsByUnits = [...unitsByModel.entries()]
.map(([partModelId, count]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const failuresByModelMap = new Map<string, number>();
for (const r of repairsWithModel) {
const pmId = r.brokenPart.partModelId;
failuresByModelMap.set(pmId, (failuresByModelMap.get(pmId) ?? 0) + 1);
}
const failuresByModel = [...failuresByModelMap.entries()]
.map(([partModelId, repairs]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
repairs,
}))
.sort((a, b) => b.repairs - a.repairs)
.slice(0, 8);
const manufacturerCounts = new Map<string, { id: string; name: string; count: number }>();
for (const m of allModels) {
const key = m.manufacturer.id;
const entry = manufacturerCounts.get(key);
if (entry) entry.count += 1;
else manufacturerCounts.set(key, { id: key, name: m.manufacturer.name, count: 1 });
}
const byManufacturer = [...manufacturerCounts.values()]
.map((m) => ({ manufacturerId: m.id, manufacturerName: m.name, count: m.count }))
.sort((a, b) => b.count - a.count);
const pastEolModels = eolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return {
totalPartModels,
totalParts,
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: repairsCount,
distinctFailedParts: distinctFailedParts.length,
fmsImplicating,
},
byManufacturer,
topModelsByUnits,
failuresByModel,
pastEolModels,
};
}
export async function list(tx: Tx, q: CategoryListQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([