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

@@ -0,0 +1,83 @@
-- CreateTable
CREATE TABLE "FinanceProfile" (
"id" TEXT NOT NULL PRIMARY KEY,
"currencyCode" TEXT NOT NULL DEFAULT 'USD',
"standardLaborRatePerHour" REAL NOT NULL DEFAULT 45,
"overheadRatePerHour" REAL NOT NULL DEFAULT 18,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "FinanceCustomerPayment" (
"id" TEXT NOT NULL PRIMARY KEY,
"salesOrderId" TEXT NOT NULL,
"paymentType" TEXT NOT NULL,
"paymentMethod" TEXT NOT NULL,
"paymentDate" DATETIME NOT NULL,
"amount" REAL NOT NULL,
"reference" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "FinanceCustomerPayment_salesOrderId_fkey" FOREIGN KEY ("salesOrderId") REFERENCES "SalesOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "FinanceCustomerPayment_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "FinanceManufacturingCostSnapshot" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"materialCost" REAL NOT NULL DEFAULT 0,
"laborCost" REAL NOT NULL DEFAULT 0,
"overheadCost" REAL NOT NULL DEFAULT 0,
"totalCost" REAL NOT NULL DEFAULT 0,
"materialIssueCount" INTEGER NOT NULL DEFAULT 0,
"laborEntryCount" INTEGER NOT NULL DEFAULT 0,
"calculatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "FinanceManufacturingCostSnapshot_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "CapexEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"title" TEXT NOT NULL,
"category" TEXT NOT NULL,
"status" TEXT NOT NULL,
"vendorId" TEXT,
"purchaseOrderId" TEXT,
"plannedAmount" REAL NOT NULL,
"actualAmount" REAL NOT NULL,
"requestDate" DATETIME NOT NULL,
"targetInServiceDate" DATETIME,
"purchasedAt" DATETIME,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "CapexEntry_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "CapexEntry_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "FinanceCustomerPayment_salesOrderId_paymentDate_idx" ON "FinanceCustomerPayment"("salesOrderId", "paymentDate");
-- CreateIndex
CREATE INDEX "FinanceCustomerPayment_createdAt_idx" ON "FinanceCustomerPayment"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "FinanceManufacturingCostSnapshot_workOrderId_key" ON "FinanceManufacturingCostSnapshot"("workOrderId");
-- CreateIndex
CREATE INDEX "FinanceManufacturingCostSnapshot_calculatedAt_idx" ON "FinanceManufacturingCostSnapshot"("calculatedAt");
-- CreateIndex
CREATE INDEX "CapexEntry_status_requestDate_idx" ON "CapexEntry"("status", "requestDate");
-- CreateIndex
CREATE INDEX "CapexEntry_vendorId_createdAt_idx" ON "CapexEntry"("vendorId", "createdAt");
-- CreateIndex
CREATE INDEX "CapexEntry_purchaseOrderId_createdAt_idx" ON "CapexEntry"("purchaseOrderId", "createdAt");

View File

@@ -29,6 +29,7 @@ model User {
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
shipmentPicks ShipmentPick[]
financeCustomerPayments FinanceCustomerPayment[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -401,6 +402,7 @@ model Vendor {
contactEntries CrmContactEntry[]
contacts CrmContact[]
purchaseOrders PurchaseOrder[]
capexEntries CapexEntry[]
preferredSupplyItems InventoryItem[]
}
@@ -496,6 +498,7 @@ model SalesOrder {
revisions SalesOrderRevision[]
workOrders WorkOrder[]
purchaseOrderLines PurchaseOrderLine[]
customerPayments FinanceCustomerPayment[]
}
model SalesOrderLine {
@@ -665,6 +668,7 @@ model WorkOrder {
materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[]
reservations InventoryReservation[]
financeCostSnapshot FinanceManufacturingCostSnapshot?
@@index([itemId, createdAt])
@@index([projectId, dueDate])
@@ -788,6 +792,74 @@ model WorkOrderCompletion {
@@index([workOrderId, createdAt])
}
model FinanceProfile {
id String @id @default(cuid())
currencyCode String @default("USD")
standardLaborRatePerHour Float @default(45)
overheadRatePerHour Float @default(18)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model FinanceCustomerPayment {
id String @id @default(cuid())
salesOrderId String
paymentType String
paymentMethod String
paymentDate DateTime
amount Float
reference String
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
salesOrder SalesOrder @relation(fields: [salesOrderId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([salesOrderId, paymentDate])
@@index([createdAt])
}
model FinanceManufacturingCostSnapshot {
id String @id @default(cuid())
workOrderId String @unique
materialCost Float @default(0)
laborCost Float @default(0)
overheadCost Float @default(0)
totalCost Float @default(0)
materialIssueCount Int @default(0)
laborEntryCount Int @default(0)
calculatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([calculatedAt])
}
model CapexEntry {
id String @id @default(cuid())
title String
category String
status String
vendorId String?
purchaseOrderId String?
plannedAmount Float
actualAmount Float
requestDate DateTime
targetInServiceDate DateTime?
purchasedAt DateTime?
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor? @relation(fields: [vendorId], references: [id], onDelete: SetNull)
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id], onDelete: SetNull)
@@index([status, requestDate])
@@index([vendorId, createdAt])
@@index([purchaseOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique
@@ -803,6 +875,7 @@ model PurchaseOrder {
lines PurchaseOrderLine[]
receipts PurchaseReceipt[]
revisions PurchaseOrderRevision[]
capexEntries CapexEntry[]
}
model PurchaseOrderLine {

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) };
}