doc compare

This commit is contained in:
2026-03-15 21:07:28 -05:00
parent f3e421e9e3
commit a43374fe77
24 changed files with 1142 additions and 55 deletions

View File

@@ -37,6 +37,15 @@ const userSchema = z.object({
password: z.string().min(8).nullable(),
});
const supportLogQuerySchema = z.object({
level: z.enum(["INFO", "WARN", "ERROR"]).optional(),
source: z.string().trim().min(1).optional(),
query: z.string().trim().optional(),
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -50,11 +59,21 @@ adminRouter.get("/backup-guidance", requirePermissions([permissions.adminManage]
});
adminRouter.get("/support-snapshot", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, await getSupportSnapshot());
const parsed = supportLogQuerySchema.safeParse(_request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support snapshot filters are invalid.");
}
return ok(response, await getSupportSnapshot(parsed.data));
});
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (_request, response) => {
return ok(response, getSupportLogs());
adminRouter.get("/support-logs", requirePermissions([permissions.adminManage]), async (request, response) => {
const parsed = supportLogQuerySchema.safeParse(request.query);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Support log filters are invalid.");
}
return ok(response, getSupportLogs(parsed.data));
});
adminRouter.get("/permissions", requirePermissions([permissions.adminManage]), async (_request, response) => {

View File

@@ -10,6 +10,8 @@ import type {
SupportSnapshotDto,
AuditEventDto,
SupportLogEntryDto,
SupportLogFiltersDto,
SupportLogListDto,
} from "@mrp/shared";
import { env } from "../../config/env.js";
@@ -18,7 +20,7 @@ import { logAuditEvent } from "../../lib/audit.js";
import { hashPassword } from "../../lib/password.js";
import { prisma } from "../../lib/prisma.js";
import { getLatestStartupReport } from "../../lib/startup-state.js";
import { getSupportLogCount, listSupportLogs } from "../../lib/support-log.js";
import { getSupportLogCount, getSupportLogRetentionDays, listSupportLogs } from "../../lib/support-log.js";
function mapAuditEvent(record: {
id: string;
@@ -48,13 +50,15 @@ function mapAuditEvent(record: {
}
function mapSupportLogEntry(record: SupportLogEntryDto): SupportLogEntryDto {
return { ...record };
}
function mapSupportLogList(record: SupportLogListDto): SupportLogListDto {
return {
id: record.id,
level: record.level,
source: record.source,
message: record.message,
contextJson: record.contextJson,
createdAt: record.createdAt,
entries: record.entries.map(mapSupportLogEntry),
summary: record.summary,
availableSources: record.availableSources,
filters: record.filters,
};
}
@@ -657,7 +661,7 @@ export async function updateAdminUser(userId: string, payload: AdminUserInput, a
export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
const startupReport = getLatestStartupReport();
const recentSupportLogs = listSupportLogs(50);
const recentSupportLogs = listSupportLogs({ limit: 50 });
const now = new Date();
const reviewSessions = await listAdminAuthSessions();
const [
@@ -748,7 +752,7 @@ export async function getAdminDiagnostics(): Promise<AdminDiagnosticsDto> {
supportLogCount: getSupportLogCount(),
startup: startupReport,
recentAuditEvents: recentAuditEvents.map(mapAuditEvent),
recentSupportLogs: recentSupportLogs.map(mapSupportLogEntry),
recentSupportLogs: recentSupportLogs.entries.map(mapSupportLogEntry),
};
}
@@ -863,9 +867,10 @@ export function getBackupGuidance(): BackupGuidanceDto {
};
}
export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
export async function getSupportSnapshot(filters?: SupportLogFiltersDto): Promise<SupportSnapshotDto> {
const diagnostics = await getAdminDiagnostics();
const backupGuidance = getBackupGuidance();
const supportLogs = listSupportLogs({ limit: 200, ...filters });
const [users, roles] = await Promise.all([
prisma.user.findMany({
where: { isActive: true },
@@ -882,10 +887,16 @@ export async function getSupportSnapshot(): Promise<SupportSnapshotDto> {
roleCount: roles,
activeUserEmails: users.map((user) => user.email),
backupGuidance,
recentSupportLogs: diagnostics.recentSupportLogs,
supportLogs: mapSupportLogList(supportLogs),
};
}
export function getSupportLogs() {
return listSupportLogs(100).map(mapSupportLogEntry);
export function getSupportLogs(filters?: SupportLogFiltersDto) {
return mapSupportLogList(listSupportLogs(filters));
}
export function getSupportLogRetentionPolicy() {
return {
retentionDays: getSupportLogRetentionDays(),
};
}

View File

@@ -9,6 +9,7 @@ import {
createPurchaseReceipt,
createPurchaseOrder,
getPurchaseOrderById,
listPurchaseOrderRevisions,
listPurchaseOrders,
listPurchaseVendorOptions,
updatePurchaseOrder,
@@ -33,6 +34,7 @@ const purchaseOrderSchema = z.object({
taxPercent: z.number().min(0).max(100),
freightAmount: z.number().nonnegative(),
notes: z.string(),
revisionReason: z.string().optional(),
lines: z.array(purchaseLineSchema),
});
@@ -92,6 +94,20 @@ purchasingRouter.get("/orders/:orderId", requirePermissions(["purchasing.read"])
return ok(response, order);
});
purchasingRouter.get("/orders/:orderId/revisions", requirePermissions(["purchasing.read"]), async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const order = await getPurchaseOrderById(orderId);
if (!order) {
return fail(response, 404, "PURCHASE_ORDER_NOT_FOUND", "Purchase order was not found.");
}
return ok(response, await listPurchaseOrderRevisions(orderId));
});
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
const parsed = purchaseOrderSchema.safeParse(request.body);
if (!parsed.success) {

View File

@@ -1,5 +1,14 @@
import { Prisma } from "@prisma/client";
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
import type {
PurchaseLineInput,
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderRevisionDto,
PurchaseOrderRevisionSnapshotDto,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import { logAuditEvent } from "../../lib/audit.js";
@@ -102,6 +111,18 @@ type PurchaseReceiptRecord = {
lines: PurchaseReceiptLineRecord[];
};
type PurchaseOrderRevisionRecord = {
id: string;
revisionNumber: number;
reason: string;
snapshot: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type PurchaseOrderRecord = {
id: string;
documentNumber: string;
@@ -121,6 +142,7 @@ type PurchaseOrderRecord = {
};
lines: PurchaseLineRecord[];
receipts: PurchaseReceiptRecord[];
revisions: PurchaseOrderRevisionRecord[];
};
function roundMoney(value: number) {
@@ -147,6 +169,10 @@ function getCreatedByName(createdBy: PurchaseReceiptRecord["createdBy"]) {
return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System";
}
function getUserDisplayName(user: { firstName: string; lastName: string } | null) {
return user ? `${user.firstName} ${user.lastName}`.trim() : null;
}
function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto {
const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({
id: line.id,
@@ -177,6 +203,21 @@ function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: stri
};
}
function parseRevisionSnapshot(snapshot: string): PurchaseOrderRevisionSnapshotDto {
return JSON.parse(snapshot) as PurchaseOrderRevisionSnapshotDto;
}
function mapPurchaseOrderRevision(record: PurchaseOrderRevisionRecord): PurchaseOrderRevisionDto {
return {
id: record.id,
revisionNumber: record.revisionNumber,
reason: record.reason,
createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy),
snapshot: parseRevisionSnapshot(record.snapshot),
};
}
function normalizeLines(lines: PurchaseLineInput[]) {
return lines
.map((line, index) => ({
@@ -319,6 +360,10 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
.slice()
.sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime())
.map((receipt) => mapPurchaseReceipt(receipt, record.id));
const revisions = record.revisions
.slice()
.sort((left, right) => right.revisionNumber - left.revisionNumber)
.map(mapPurchaseOrderRevision);
return {
id: record.id,
@@ -341,9 +386,87 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
lineCount: lines.length,
lines,
receipts,
revisions,
};
}
function buildPurchaseOrderRevisionSnapshot(document: PurchaseOrderDetailDto) {
return JSON.stringify({
documentNumber: document.documentNumber,
vendorId: document.vendorId,
vendorName: document.vendorName,
status: document.status,
issueDate: document.issueDate,
taxPercent: document.taxPercent,
taxAmount: document.taxAmount,
freightAmount: document.freightAmount,
subtotal: document.subtotal,
total: document.total,
notes: document.notes,
paymentTerms: document.paymentTerms,
currencyCode: document.currencyCode,
lines: document.lines.map((line) => ({
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure,
unitCost: line.unitCost,
lineTotal: line.lineTotal,
receivedQuantity: line.receivedQuantity,
remainingQuantity: line.remainingQuantity,
salesOrderId: line.salesOrderId,
salesOrderLineId: line.salesOrderLineId,
salesOrderNumber: line.salesOrderNumber,
position: line.position,
})),
receipts: document.receipts.map((receipt) => ({
id: receipt.id,
receiptNumber: receipt.receiptNumber,
purchaseOrderId: receipt.purchaseOrderId,
receivedAt: receipt.receivedAt,
notes: receipt.notes,
createdAt: receipt.createdAt,
createdByName: receipt.createdByName,
warehouseId: receipt.warehouseId,
warehouseCode: receipt.warehouseCode,
warehouseName: receipt.warehouseName,
locationId: receipt.locationId,
locationCode: receipt.locationCode,
locationName: receipt.locationName,
totalQuantity: receipt.totalQuantity,
lineCount: receipt.lineCount,
lines: receipt.lines.map((line) => ({
id: line.id,
purchaseOrderLineId: line.purchaseOrderLineId,
itemId: line.itemId,
itemSku: line.itemSku,
itemName: line.itemName,
quantity: line.quantity,
})),
})),
});
}
async function createPurchaseOrderRevision(documentId: string, detail: PurchaseOrderDetailDto, reason: string, actorId?: string | null) {
const aggregate = await prisma.purchaseOrderRevision.aggregate({
where: { purchaseOrderId: documentId },
_max: { revisionNumber: true },
});
const nextRevisionNumber = (aggregate._max.revisionNumber ?? 0) + 1;
await prisma.purchaseOrderRevision.create({
data: {
purchaseOrderId: documentId,
revisionNumber: nextRevisionNumber,
reason,
snapshot: buildPurchaseOrderRevisionSnapshot(detail),
createdById: actorId ?? null,
},
});
}
async function nextDocumentNumber() {
const next = (await purchaseOrderModel.count()) + 1;
return `PO-${String(next).padStart(5, "0")}`;
@@ -423,6 +546,17 @@ const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
},
orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }],
},
revisions: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
},
});
function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) {
@@ -605,6 +739,23 @@ export async function getPurchaseOrderById(documentId: string) {
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
}
export async function listPurchaseOrderRevisions(documentId: string) {
const revisions = await prisma.purchaseOrderRevision.findMany({
where: { purchaseOrderId: documentId },
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ revisionNumber: "desc" }],
});
return revisions.map((revision) => mapPurchaseOrderRevision(revision as PurchaseOrderRevisionRecord));
}
export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?: string | null) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
@@ -640,6 +791,7 @@ export async function createPurchaseOrder(payload: PurchaseOrderInput, actorId?:
const detail = await getPurchaseOrderById(created.id);
if (detail) {
await createPurchaseOrderRevision(created.id, detail, payload.revisionReason?.trim() || "Initial issue", actorId);
await logAuditEvent({
actorId,
entityType: "purchase-order",
@@ -700,6 +852,7 @@ export async function updatePurchaseOrder(documentId: string, payload: PurchaseO
const detail = await getPurchaseOrderById(documentId);
if (detail) {
await createPurchaseOrderRevision(documentId, detail, payload.revisionReason?.trim() || "Document edited", actorId);
await logAuditEvent({
actorId,
entityType: "purchase-order",
@@ -735,6 +888,7 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
const detail = await getPurchaseOrderById(documentId);
if (detail) {
await createPurchaseOrderRevision(documentId, detail, `Status changed to ${status}`, actorId);
await logAuditEvent({
actorId,
entityType: "purchase-order",
@@ -796,6 +950,7 @@ export async function createPurchaseReceipt(orderId: string, payload: PurchaseRe
const detail = await getPurchaseOrderById(orderId);
if (detail) {
await createPurchaseOrderRevision(orderId, detail, `Receipt posted on ${payload.receivedAt}`, createdById);
await logAuditEvent({
actorId: createdById,
entityType: "purchase-order",

View File

@@ -3,6 +3,7 @@ import type {
SalesDocumentDetailDto,
SalesDocumentInput,
SalesDocumentRevisionDto,
SalesDocumentRevisionSnapshotDto,
SalesDocumentStatus,
SalesDocumentSummaryDto,
SalesDocumentType,
@@ -66,6 +67,7 @@ type RevisionRecord = {
id: string;
revisionNumber: number;
reason: string;
snapshot: string;
createdAt: Date;
createdBy: {
firstName: string;
@@ -172,6 +174,10 @@ function getUserDisplayName(user: { firstName: string; lastName: string } | null
return `${user.firstName} ${user.lastName}`.trim();
}
function parseRevisionSnapshot(snapshot: string): SalesDocumentRevisionSnapshotDto {
return JSON.parse(snapshot) as SalesDocumentRevisionSnapshotDto;
}
function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
return {
id: record.id,
@@ -179,6 +185,7 @@ function mapRevision(record: RevisionRecord): SalesDocumentRevisionDto {
reason: record.reason,
createdAt: record.createdAt.toISOString(),
createdByName: getUserDisplayName(record.createdBy),
snapshot: parseRevisionSnapshot(record.snapshot),
};
}