finance
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
104
server/src/modules/finance/router.ts
Normal file
104
server/src/modules/finance/router.ts
Normal 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);
|
||||
});
|
||||
619
server/src/modules/finance/service.ts
Normal file
619
server/src/modules/finance/service.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user