Files
mrp-qrcode/lib/reports.ts
T
jason e0dfac2d48
Build and Push Docker Image / build (push) Successful in 1m4s
step 9 and cleanup
2026-04-22 09:27:01 -05:00

404 lines
13 KiB
TypeScript

// Admin-dashboard data-access helpers. Built as plain async functions so admin
// server components can await them directly (no HTTP round-trip) and we can
// still expose them under /api/v1 later if a client UI needs live refresh.
//
// All of these are read-only. Write paths stay in the existing route handlers.
import { prisma } from "./prisma";
// ---------------------------------------------------------------------------
// Work-in-progress
// ---------------------------------------------------------------------------
/**
* Operations currently being worked on the shop floor: status is either
* `in_progress` (an operator holds the claim) or `partial` (unclaimed but has
* unitsCompleted logged — resumable). Ordered newest-claim first so the most
* recent activity surfaces at the top of the dashboard.
*/
export async function getWipOperations(limit = 50) {
const rows = await prisma.operation.findMany({
where: { status: { in: ["in_progress", "partial"] } },
orderBy: [{ status: "asc" }, { claimedAt: "desc" }, { updatedAt: "desc" }],
take: limit,
select: {
id: true,
sequence: true,
name: true,
status: true,
claimedAt: true,
unitsCompleted: true,
plannedMinutes: true,
plannedUnits: true,
machine: { select: { name: true, kind: true } },
claimedBy: { select: { id: true, name: true } },
part: {
select: {
id: true,
code: true,
name: true,
qty: true,
assembly: {
select: {
id: true,
code: true,
name: true,
qty: true,
project: { select: { id: true, code: true, name: true } },
},
},
},
},
},
});
return rows;
}
// ---------------------------------------------------------------------------
// QC failures (blocked steps)
// ---------------------------------------------------------------------------
/**
* Operations currently in `qc_failed` — an operator stamped a failing QC
* record on close and the step is blocked until an admin hits qc-reset.
* We surface the most recent failing QCRecord alongside so the dashboard
* can show the failing note / who did the inspection without needing a
* second round-trip.
*/
export async function getQcFailures(limit = 25) {
const rows = await prisma.operation.findMany({
where: { status: "qc_failed" },
orderBy: { updatedAt: "desc" },
take: limit,
select: {
id: true,
sequence: true,
name: true,
kind: true,
updatedAt: true,
part: {
select: {
id: true,
code: true,
name: true,
assembly: {
select: {
id: true,
code: true,
project: { select: { id: true, code: true, name: true } },
},
},
},
},
qcRecords: {
where: { passed: false },
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
createdAt: true,
notes: true,
operator: { select: { id: true, name: true } },
},
},
},
});
return rows;
}
// ---------------------------------------------------------------------------
// Overdue projects
// ---------------------------------------------------------------------------
/**
* Projects whose `dueDate` has passed and which are not yet `completed` /
* `cancelled`. We also compute a completion percentage (completed ops /
* total ops) so the dashboard can show "40% through, 3 days late" at a
* glance. Project is the only entity in the schema with a due-date field;
* there's no per-operation deadline.
*/
export async function getOverdueProjects(now: Date = new Date(), limit = 50) {
const projects = await prisma.project.findMany({
where: {
dueDate: { lt: now },
status: { notIn: ["completed", "cancelled"] },
},
orderBy: { dueDate: "asc" },
take: limit,
select: {
id: true,
code: true,
name: true,
status: true,
dueDate: true,
assemblies: {
select: {
parts: {
select: {
_count: { select: { operations: true } },
operations: { select: { status: true } },
},
},
},
},
},
});
return projects.map((p) => {
let total = 0;
let completed = 0;
for (const a of p.assemblies) {
for (const part of a.parts) {
for (const op of part.operations) {
total += 1;
if (op.status === "completed") completed += 1;
}
}
}
return {
id: p.id,
code: p.code,
name: p.name,
status: p.status,
dueDate: p.dueDate!, // guaranteed non-null by the where clause
totalOps: total,
completedOps: completed,
progressPct: total === 0 ? 0 : Math.round((completed / total) * 100),
daysLate: Math.max(0, Math.floor((now.getTime() - p.dueDate!.getTime()) / 86_400_000)),
};
});
}
// ---------------------------------------------------------------------------
// Hours: plan vs actual
// ---------------------------------------------------------------------------
//
// We fetch every closed TimeLog in the window (endedAt IS NOT NULL, inside
// since..until) and sum durations ourselves rather than using SQL GROUP BY —
// Prisma's groupBy doesn't support computed duration columns, and a shop
// floor's TimeLog volume (tens/day) makes in-memory aggregation trivial.
//
// "Plan" is the sum of Operation.plannedMinutes scoped the same way: ops
// that have ANY TimeLog overlapping the window. That keeps plan and actual
// comparable — we don't count ops the team never touched.
export interface HoursRow {
id: string; // machine or operator id
name: string;
kind: "machine" | "operator";
actualMinutes: number;
plannedMinutes: number | null; // null when no ops in the window had plans
operations: number; // distinct ops touched
}
export interface HoursReport {
sinceIso: string;
untilIso: string;
byMachine: HoursRow[];
byOperator: HoursRow[];
unassignedMachineMinutes: number; // ops with machineId null
totalActualMinutes: number;
totalPlannedMinutes: number;
}
export async function getHoursReport(since: Date, until: Date = new Date()): Promise<HoursReport> {
const logs = await prisma.timeLog.findMany({
where: {
endedAt: { not: null, gte: since, lte: until },
},
select: {
startedAt: true,
endedAt: true,
operatorId: true,
operator: { select: { name: true } },
operation: {
select: {
id: true,
plannedMinutes: true,
machineId: true,
machine: { select: { name: true } },
},
},
},
});
const byMachine = new Map<string, HoursRow>();
const byOperator = new Map<string, HoursRow>();
// Ops we've already counted a plan for (plannedMinutes is per-op, not per-log)
const planCountedOps = new Set<string>();
// Distinct ops seen per bucket, so `operations` isn't double-counted across logs
const opsPerMachine = new Map<string, Set<string>>();
const opsPerOperator = new Map<string, Set<string>>();
let unassignedMachineMinutes = 0;
let totalActual = 0;
let totalPlanned = 0;
for (const log of logs) {
const minutes = Math.max(
0,
Math.round((log.endedAt!.getTime() - log.startedAt.getTime()) / 60_000),
);
if (minutes === 0) continue;
totalActual += minutes;
// --- by machine ---
const mId = log.operation.machineId;
if (mId) {
const row = byMachine.get(mId) ?? {
id: mId,
name: log.operation.machine?.name ?? "(unknown)",
kind: "machine" as const,
actualMinutes: 0,
plannedMinutes: 0,
operations: 0,
};
row.actualMinutes += minutes;
byMachine.set(mId, row);
const ops = opsPerMachine.get(mId) ?? new Set<string>();
ops.add(log.operation.id);
opsPerMachine.set(mId, ops);
} else {
unassignedMachineMinutes += minutes;
}
// --- by operator ---
const opRow = byOperator.get(log.operatorId) ?? {
id: log.operatorId,
name: log.operator.name,
kind: "operator" as const,
actualMinutes: 0,
plannedMinutes: 0,
operations: 0,
};
opRow.actualMinutes += minutes;
byOperator.set(log.operatorId, opRow);
const ops = opsPerOperator.get(log.operatorId) ?? new Set<string>();
ops.add(log.operation.id);
opsPerOperator.set(log.operatorId, ops);
// --- planned total (per-op, once) ---
if (log.operation.plannedMinutes != null && !planCountedOps.has(log.operation.id)) {
planCountedOps.add(log.operation.id);
totalPlanned += log.operation.plannedMinutes;
if (mId) {
const row = byMachine.get(mId)!;
row.plannedMinutes = (row.plannedMinutes ?? 0) + log.operation.plannedMinutes;
}
// Operator plans don't really make sense (one op, many operators) —
// leave byOperator.plannedMinutes at 0 and only show actual in that table.
}
}
// Resolve distinct op counts
for (const [id, ops] of opsPerMachine) byMachine.get(id)!.operations = ops.size;
for (const [id, ops] of opsPerOperator) byOperator.get(id)!.operations = ops.size;
// Machines without plans → display null so UI shows "—"
for (const row of byMachine.values()) {
if (row.plannedMinutes === 0) row.plannedMinutes = null;
}
for (const row of byOperator.values()) row.plannedMinutes = null;
return {
sinceIso: since.toISOString(),
untilIso: until.toISOString(),
byMachine: [...byMachine.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
byOperator: [...byOperator.values()].sort((a, b) => b.actualMinutes - a.actualMinutes),
unassignedMachineMinutes,
totalActualMinutes: totalActual,
totalPlannedMinutes: totalPlanned,
};
}
// ---------------------------------------------------------------------------
// Audit log
// ---------------------------------------------------------------------------
export interface AuditQuery {
limit?: number;
cursor?: string | null; // AuditLog.id — we order by at desc, so cursor = last id on the page
action?: string | null;
entity?: string | null;
actorId?: string | null;
since?: Date | null;
until?: Date | null;
}
export async function getAuditLog(q: AuditQuery = {}) {
const limit = Math.min(200, Math.max(1, q.limit ?? 50));
const rows = await prisma.auditLog.findMany({
where: {
...(q.action ? { action: q.action } : {}),
...(q.entity ? { entity: q.entity } : {}),
...(q.actorId ? { actorId: q.actorId } : {}),
...(q.since || q.until
? { at: { ...(q.since ? { gte: q.since } : {}), ...(q.until ? { lte: q.until } : {}) } }
: {}),
},
orderBy: { at: "desc" },
take: limit + 1, // over-fetch by one so we can tell if there's a next page
...(q.cursor ? { cursor: { id: q.cursor }, skip: 1 } : {}),
select: {
id: true,
actorId: true,
action: true,
entity: true,
entityId: true,
before: true,
after: true,
ipAddress: true,
at: true,
actor: { select: { id: true, name: true, role: true } },
},
});
const hasMore = rows.length > limit;
const page = hasMore ? rows.slice(0, limit) : rows;
return { rows: page, nextCursor: hasMore ? page[page.length - 1]?.id ?? null : null };
}
/**
* Distinct facet values so the admin filter dropdowns know what to offer
* (instead of making the user guess action/entity names).
*/
export async function getAuditFacets() {
const [actions, entities, actors] = await Promise.all([
prisma.auditLog.findMany({ select: { action: true }, distinct: ["action"], orderBy: { action: "asc" } }),
prisma.auditLog.findMany({ select: { entity: true }, distinct: ["entity"], orderBy: { entity: "asc" } }),
prisma.user.findMany({
where: { auditLogs: { some: {} } },
select: { id: true, name: true, role: true },
orderBy: { name: "asc" },
}),
]);
return {
actions: actions.map((a) => a.action),
entities: entities.map((e) => e.entity),
actors,
};
}
// ---------------------------------------------------------------------------
// Formatters (shared across admin pages)
// ---------------------------------------------------------------------------
export function formatMinutes(m: number): string {
if (m < 60) return `${m}m`;
const hours = Math.floor(m / 60);
const mins = m % 60;
return mins === 0 ? `${hours}h` : `${hours}h ${mins}m`;
}
export function formatRelative(d: Date, now: Date = new Date()): string {
const sec = Math.round((now.getTime() - d.getTime()) / 1000);
if (sec < 45) return "just now";
if (sec < 90) return "a minute ago";
const min = Math.round(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.round(hr / 24);
if (day < 30) return `${day}d ago`;
return d.toLocaleDateString();
}