This commit is contained in:
2026-03-18 11:24:59 -05:00
parent 02e14319ac
commit f85563ce99
17 changed files with 1578 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ import { authRouter } from "./modules/auth/router.js";
import { crmRouter } from "./modules/crm/router.js";
import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { financeRouter } from "./modules/finance/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { manufacturingRouter } from "./modules/manufacturing/router.js";
@@ -97,6 +98,7 @@ export function createApp() {
app.use("/api/v1/admin", adminRouter);
app.use("/api/v1", settingsRouter);
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/finance", financeRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/manufacturing", manufacturingRouter);

View File

@@ -17,7 +17,9 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.manufacturingWrite]: "Manage manufacturing work orders and execution data",
[permissions.filesRead]: "View attached files",
[permissions.filesWrite]: "Upload and manage attached files",
[permissions.ganttRead]: "View planning workbench",
[permissions.financeRead]: "View finance rollups, payments, and capital plans",
[permissions.financeWrite]: "Manage finance rollups, payments, and capital plans",
[permissions.ganttRead]: "View planning workbench",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
[permissions.projectsRead]: "View projects and program records",
@@ -122,4 +124,11 @@ export async function bootstrapAppData() {
},
});
}
const existingFinanceProfile = await prisma.financeProfile.findFirst();
if (!existingFinanceProfile) {
await prisma.financeProfile.create({
data: {},
});
}
}

View File

@@ -0,0 +1,104 @@
import { permissions } from "@mrp/shared";
import { capexCategories, capexStatuses, financePaymentMethods, financePaymentTypes } from "@mrp/shared/dist/finance/types.js";
import { Router } from "express";
import { z } from "zod";
import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import { createCapexEntry, createCustomerPayment, getFinanceDashboard, updateCapexEntry, updateFinanceProfile } from "./service.js";
const financeProfileSchema = z.object({
currencyCode: z.string().trim().min(3).max(8),
standardLaborRatePerHour: z.number().nonnegative(),
overheadRatePerHour: z.number().nonnegative(),
});
const financePaymentSchema = z.object({
salesOrderId: z.string().trim().min(1),
paymentType: z.enum(financePaymentTypes),
paymentMethod: z.enum(financePaymentMethods),
paymentDate: z.string().datetime(),
amount: z.number().positive(),
reference: z.string(),
notes: z.string(),
});
const capexSchema = z.object({
title: z.string().trim().min(1),
category: z.enum(capexCategories),
status: z.enum(capexStatuses),
vendorId: z.string().trim().min(1).nullable(),
purchaseOrderId: z.string().trim().min(1).nullable(),
plannedAmount: z.number().nonnegative(),
actualAmount: z.number().nonnegative(),
requestDate: z.string().datetime(),
targetInServiceDate: z.string().datetime().nullable(),
purchasedAt: z.string().datetime().nullable(),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
export const financeRouter = Router();
financeRouter.get("/overview", requirePermissions([permissions.financeRead]), async (_request, response) => {
return ok(response, await getFinanceDashboard());
});
financeRouter.put("/profile", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = financeProfileSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Finance profile payload is invalid.");
}
return ok(response, await updateFinanceProfile(parsed.data, request.authUser?.id));
});
financeRouter.post("/payments", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = financePaymentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Finance payment payload is invalid.");
}
const result = await createCustomerPayment(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.payment, 201);
});
financeRouter.post("/capex", requirePermissions([permissions.financeWrite]), async (request, response) => {
const parsed = capexSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
}
const result = await createCapexEntry(parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.capex, 201);
});
financeRouter.put("/capex/:capexId", requirePermissions([permissions.financeWrite]), async (request, response) => {
const capexId = getRouteParam(request.params.capexId);
if (!capexId) {
return fail(response, 400, "INVALID_INPUT", "CapEx id is invalid.");
}
const parsed = capexSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "CapEx payload is invalid.");
}
const result = await updateCapexEntry(capexId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.capex);
});

View File

@@ -0,0 +1,619 @@
import type {
FinanceCapexDto,
FinanceCapexInput,
FinanceCustomerPaymentDto,
FinanceCustomerPaymentInput,
FinanceDashboardDto,
FinanceProfileDto,
FinanceProfileInput,
FinanceSalesOrderLedgerDto,
FinanceSummaryDto,
} from "@mrp/shared";
import { logAuditEvent } from "../../lib/audit.js";
import { prisma } from "../../lib/prisma.js";
function iso(value: Date | null) {
return value ? value.toISOString() : null;
}
function mapProfile(record: {
id: string;
currencyCode: string;
standardLaborRatePerHour: number;
overheadRatePerHour: number;
createdAt: Date;
updatedAt: Date;
}): FinanceProfileDto {
return {
id: record.id,
currencyCode: record.currencyCode,
standardLaborRatePerHour: record.standardLaborRatePerHour,
overheadRatePerHour: record.overheadRatePerHour,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
function mapPayment(record: {
id: string;
paymentType: string;
paymentMethod: string;
paymentDate: Date;
amount: number;
reference: string;
notes: string;
createdAt: Date;
salesOrder: {
id: string;
documentNumber: string;
customer: {
id: string;
name: string;
};
};
createdBy: {
firstName: string;
lastName: string;
} | null;
}): FinanceCustomerPaymentDto {
return {
id: record.id,
salesOrderId: record.salesOrder.id,
salesOrderNumber: record.salesOrder.documentNumber,
customerId: record.salesOrder.customer.id,
customerName: record.salesOrder.customer.name,
paymentType: record.paymentType as FinanceCustomerPaymentDto["paymentType"],
paymentMethod: record.paymentMethod as FinanceCustomerPaymentDto["paymentMethod"],
paymentDate: record.paymentDate.toISOString(),
amount: record.amount,
reference: record.reference,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
};
}
function mapCapex(record: {
id: string;
title: string;
category: string;
status: string;
plannedAmount: number;
actualAmount: number;
requestDate: Date;
targetInServiceDate: Date | null;
purchasedAt: Date | null;
notes: string;
createdAt: Date;
updatedAt: Date;
vendor: {
id: string;
name: string;
} | null;
purchaseOrder: {
id: string;
documentNumber: string;
} | null;
}): FinanceCapexDto {
return {
id: record.id,
title: record.title,
category: record.category as FinanceCapexDto["category"],
status: record.status as FinanceCapexDto["status"],
vendorId: record.vendor?.id ?? null,
vendorName: record.vendor?.name ?? null,
purchaseOrderId: record.purchaseOrder?.id ?? null,
purchaseOrderNumber: record.purchaseOrder?.documentNumber ?? null,
plannedAmount: record.plannedAmount,
actualAmount: record.actualAmount,
requestDate: record.requestDate.toISOString(),
targetInServiceDate: iso(record.targetInServiceDate),
purchasedAt: iso(record.purchasedAt),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
async function getOrCreateProfile() {
const existing = await prisma.financeProfile.findFirst({
orderBy: { createdAt: "asc" },
});
if (existing) {
return existing;
}
return prisma.financeProfile.create({
data: {},
});
}
async function computeWorkOrderCostSnapshot(
workOrder: {
id: string;
materialIssues: Array<{
quantity: number;
componentItem: {
defaultCost: number | null;
};
}>;
operations: Array<{
laborEntries: Array<{
minutes: number;
}>;
}>;
},
profile: {
standardLaborRatePerHour: number;
overheadRatePerHour: number;
}
) {
const materialCost = workOrder.materialIssues.reduce((sum, issue) => sum + issue.quantity * (issue.componentItem.defaultCost ?? 0), 0);
const laborMinutes = workOrder.operations.reduce(
(sum, operation) => sum + operation.laborEntries.reduce((entrySum, entry) => entrySum + entry.minutes, 0),
0
);
const laborHours = laborMinutes / 60;
const laborCost = laborHours * profile.standardLaborRatePerHour;
const overheadCost = laborHours * profile.overheadRatePerHour;
const totalCost = materialCost + laborCost + overheadCost;
await prisma.financeManufacturingCostSnapshot.upsert({
where: { workOrderId: workOrder.id },
update: {
materialCost,
laborCost,
overheadCost,
totalCost,
materialIssueCount: workOrder.materialIssues.length,
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
calculatedAt: new Date(),
},
create: {
workOrderId: workOrder.id,
materialCost,
laborCost,
overheadCost,
totalCost,
materialIssueCount: workOrder.materialIssues.length,
laborEntryCount: workOrder.operations.reduce((sum, operation) => sum + operation.laborEntries.length, 0),
calculatedAt: new Date(),
},
});
return {
materialCost,
laborCost,
overheadCost,
totalCost,
};
}
export async function getFinanceDashboard(): Promise<FinanceDashboardDto> {
const profile = await getOrCreateProfile();
const [orders, payments, capex] = await Promise.all([
prisma.salesOrder.findMany({
include: {
customer: {
select: {
id: true,
name: true,
},
},
lines: {
select: {
quantity: true,
unitPrice: true,
},
},
customerPayments: {
select: {
amount: true,
},
},
purchaseOrderLines: {
include: {
purchaseOrder: {
include: {
receipts: {
include: {
lines: true,
},
},
},
},
},
},
workOrders: {
include: {
materialIssues: {
include: {
componentItem: {
select: {
defaultCost: true,
},
},
},
},
operations: {
include: {
laborEntries: {
select: {
minutes: true,
},
},
},
},
},
},
},
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
}),
prisma.financeCustomerPayment.findMany({
include: {
salesOrder: {
include: {
customer: {
select: {
id: true,
name: true,
},
},
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ paymentDate: "desc" }, { createdAt: "desc" }],
take: 40,
}),
prisma.capexEntry.findMany({
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
orderBy: [{ requestDate: "desc" }, { createdAt: "desc" }],
}),
]);
const salesOrderLedgers: FinanceSalesOrderLedgerDto[] = [];
for (const order of orders) {
const revenueTotal = order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
const paymentsReceived = order.customerPayments.reduce((sum, payment) => sum + payment.amount, 0);
const linkedPurchaseCommitted = order.purchaseOrderLines.reduce((sum, line) => sum + line.quantity * line.unitCost, 0);
const linkedPurchaseReceivedValue = order.purchaseOrderLines.reduce((sum, line) => {
const receivedQuantity = line.purchaseOrder.receipts.reduce((receiptSum, receipt) => {
const matchingQuantity = receipt.lines
.filter((receiptLine) => receiptLine.purchaseOrderLineId === line.id)
.reduce((lineSum, receiptLine) => lineSum + receiptLine.quantity, 0);
return receiptSum + matchingQuantity;
}, 0);
return sum + receivedQuantity * line.unitCost;
}, 0);
let manufacturingMaterialCost = 0;
let manufacturingLaborCost = 0;
let manufacturingOverheadCost = 0;
for (const workOrder of order.workOrders) {
const snapshot = await computeWorkOrderCostSnapshot(workOrder, profile);
manufacturingMaterialCost += snapshot.materialCost;
manufacturingLaborCost += snapshot.laborCost;
manufacturingOverheadCost += snapshot.overheadCost;
}
const manufacturingTotalCost = manufacturingMaterialCost + manufacturingLaborCost + manufacturingOverheadCost;
const totalRecognizedSpend = linkedPurchaseReceivedValue + manufacturingTotalCost;
const grossMarginEstimate = revenueTotal - totalRecognizedSpend;
const grossMarginPercent = revenueTotal > 0 ? (grossMarginEstimate / revenueTotal) * 100 : 0;
const accountsReceivableOpen = Math.max(revenueTotal - paymentsReceived, 0);
const paymentCoveragePercent = totalRecognizedSpend > 0 ? (paymentsReceived / totalRecognizedSpend) * 100 : 0;
salesOrderLedgers.push({
salesOrderId: order.id,
salesOrderNumber: order.documentNumber,
customerId: order.customer.id,
customerName: order.customer.name,
status: order.status,
issueDate: order.issueDate.toISOString(),
revenueTotal,
paymentsReceived,
accountsReceivableOpen,
linkedPurchaseCommitted,
linkedPurchaseReceivedValue,
manufacturingMaterialCost,
manufacturingLaborCost,
manufacturingOverheadCost,
manufacturingTotalCost,
totalRecognizedSpend,
grossMarginEstimate,
grossMarginPercent,
paymentCoveragePercent,
linkedPurchaseOrderCount: new Set(order.purchaseOrderLines.map((line) => line.purchaseOrderId)).size,
linkedWorkOrderCount: order.workOrders.length,
});
}
const summary: FinanceSummaryDto = {
bookedRevenue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.revenueTotal, 0),
paymentsReceived: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.paymentsReceived, 0),
accountsReceivableOpen: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.accountsReceivableOpen, 0),
linkedPurchaseCommitted: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseCommitted, 0),
linkedPurchaseReceivedValue: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.linkedPurchaseReceivedValue, 0),
manufacturingMaterialCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingMaterialCost, 0),
manufacturingLaborCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingLaborCost, 0),
manufacturingOverheadCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingOverheadCost, 0),
manufacturingTotalCost: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.manufacturingTotalCost, 0),
capexPlanned: capex.reduce((sum, entry) => sum + entry.plannedAmount, 0),
capexActual: capex.reduce((sum, entry) => sum + entry.actualAmount, 0),
grossMarginEstimate: salesOrderLedgers.reduce((sum, ledger) => sum + ledger.grossMarginEstimate, 0),
};
return {
generatedAt: new Date().toISOString(),
profile: mapProfile(profile),
summary,
salesOrderLedgers,
payments: payments.map(mapPayment),
capex: capex.map(mapCapex),
};
}
export async function updateFinanceProfile(payload: FinanceProfileInput, actorId?: string | null) {
const profile = await getOrCreateProfile();
const updated = await prisma.financeProfile.update({
where: { id: profile.id },
data: {
currencyCode: payload.currencyCode.trim().toUpperCase(),
standardLaborRatePerHour: payload.standardLaborRatePerHour,
overheadRatePerHour: payload.overheadRatePerHour,
},
});
await logAuditEvent({
actorId,
entityType: "finance-profile",
entityId: updated.id,
action: "updated",
summary: "Updated finance costing assumptions.",
metadata: {
currencyCode: updated.currencyCode,
standardLaborRatePerHour: updated.standardLaborRatePerHour,
overheadRatePerHour: updated.overheadRatePerHour,
},
});
return mapProfile(updated);
}
export async function createCustomerPayment(payload: FinanceCustomerPaymentInput, actorId?: string | null) {
const order = await prisma.salesOrder.findUnique({
where: { id: payload.salesOrderId },
include: {
customer: {
select: {
name: true,
},
},
},
});
if (!order) {
return { ok: false as const, reason: "Sales order was not found." };
}
const payment = await prisma.financeCustomerPayment.create({
data: {
salesOrderId: payload.salesOrderId,
paymentType: payload.paymentType,
paymentMethod: payload.paymentMethod,
paymentDate: new Date(payload.paymentDate),
amount: payload.amount,
reference: payload.reference.trim(),
notes: payload.notes,
createdById: actorId ?? null,
},
include: {
salesOrder: {
include: {
customer: {
select: {
id: true,
name: true,
},
},
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "finance-payment",
entityId: payment.id,
action: "created",
summary: `Posted customer payment against ${order.documentNumber}.`,
metadata: {
salesOrderId: order.id,
salesOrderNumber: order.documentNumber,
amount: payment.amount,
paymentType: payment.paymentType,
paymentMethod: payment.paymentMethod,
},
});
return { ok: true as const, payment: mapPayment(payment) };
}
export async function createCapexEntry(payload: FinanceCapexInput, actorId?: string | null) {
if (payload.vendorId) {
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Selected vendor was not found." };
}
}
if (payload.purchaseOrderId) {
const purchaseOrder = await prisma.purchaseOrder.findUnique({
where: { id: payload.purchaseOrderId },
select: { id: true },
});
if (!purchaseOrder) {
return { ok: false as const, reason: "Selected purchase order was not found." };
}
}
const created = await prisma.capexEntry.create({
data: {
title: payload.title.trim(),
category: payload.category,
status: payload.status,
vendorId: payload.vendorId,
purchaseOrderId: payload.purchaseOrderId,
plannedAmount: payload.plannedAmount,
actualAmount: payload.actualAmount,
requestDate: new Date(payload.requestDate),
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
notes: payload.notes,
},
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "capex-entry",
entityId: created.id,
action: "created",
summary: `Created CapEx entry ${created.title}.`,
metadata: {
title: created.title,
category: created.category,
status: created.status,
plannedAmount: created.plannedAmount,
actualAmount: created.actualAmount,
purchaseOrderId: created.purchaseOrder?.id ?? null,
},
});
return { ok: true as const, capex: mapCapex(created) };
}
export async function updateCapexEntry(capexId: string, payload: FinanceCapexInput, actorId?: string | null) {
const existing = await prisma.capexEntry.findUnique({
where: { id: capexId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "CapEx entry was not found." };
}
if (payload.vendorId) {
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Selected vendor was not found." };
}
}
if (payload.purchaseOrderId) {
const purchaseOrder = await prisma.purchaseOrder.findUnique({
where: { id: payload.purchaseOrderId },
select: { id: true },
});
if (!purchaseOrder) {
return { ok: false as const, reason: "Selected purchase order was not found." };
}
}
const updated = await prisma.capexEntry.update({
where: { id: capexId },
data: {
title: payload.title.trim(),
category: payload.category,
status: payload.status,
vendorId: payload.vendorId,
purchaseOrderId: payload.purchaseOrderId,
plannedAmount: payload.plannedAmount,
actualAmount: payload.actualAmount,
requestDate: new Date(payload.requestDate),
targetInServiceDate: payload.targetInServiceDate ? new Date(payload.targetInServiceDate) : null,
purchasedAt: payload.purchasedAt ? new Date(payload.purchasedAt) : null,
notes: payload.notes,
},
include: {
vendor: {
select: {
id: true,
name: true,
},
},
purchaseOrder: {
select: {
id: true,
documentNumber: true,
},
},
},
});
await logAuditEvent({
actorId,
entityType: "capex-entry",
entityId: updated.id,
action: "updated",
summary: `Updated CapEx entry ${updated.title}.`,
metadata: {
title: updated.title,
category: updated.category,
status: updated.status,
plannedAmount: updated.plannedAmount,
actualAmount: updated.actualAmount,
purchaseOrderId: updated.purchaseOrder?.id ?? null,
},
});
return { ok: true as const, capex: mapCapex(updated) };
}