feat(categories): detail page with fleet insights
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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user