Files
mrp/server/src/modules/purchasing/service.ts
2026-03-15 00:29:41 -05:00

359 lines
9.9 KiB
TypeScript

import type {
PurchaseLineInput,
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import { prisma } from "../../lib/prisma.js";
const purchaseOrderModel = (prisma as any).purchaseOrder;
type PurchaseLineRecord = {
id: string;
description: string;
quantity: number;
unitOfMeasure: string;
unitCost: number;
position: number;
item: {
id: string;
sku: string;
name: string;
};
};
type PurchaseOrderRecord = {
id: string;
documentNumber: string;
status: string;
issueDate: Date;
taxPercent: number;
freightAmount: number;
notes: string;
createdAt: Date;
updatedAt: Date;
vendor: {
id: string;
name: string;
email: string;
paymentTerms: string | null;
currencyCode: string | null;
};
lines: PurchaseLineRecord[];
};
function roundMoney(value: number) {
return Math.round(value * 100) / 100;
}
function calculateTotals(subtotal: number, taxPercent: number, freightAmount: number) {
const normalizedSubtotal = roundMoney(subtotal);
const normalizedTaxPercent = Number.isFinite(taxPercent) ? taxPercent : 0;
const normalizedFreight = roundMoney(Number.isFinite(freightAmount) ? freightAmount : 0);
const taxAmount = roundMoney(normalizedSubtotal * (normalizedTaxPercent / 100));
const total = roundMoney(normalizedSubtotal + taxAmount + normalizedFreight);
return {
subtotal: normalizedSubtotal,
taxPercent: normalizedTaxPercent,
taxAmount,
freightAmount: normalizedFreight,
total,
};
}
function normalizeLines(lines: PurchaseLineInput[]) {
return lines
.map((line, index) => ({
itemId: line.itemId,
description: line.description.trim(),
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
unitCost: Number(line.unitCost),
position: line.position ?? (index + 1) * 10,
}))
.filter((line) => line.itemId.trim().length > 0);
}
async function validateLines(lines: PurchaseLineInput[]) {
const normalized = normalizeLines(lines);
if (normalized.length === 0) {
return { ok: false as const, reason: "At least one line item is required." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Line quantity must be a whole number greater than zero." };
}
if (normalized.some((line) => Number.isNaN(line.unitCost) || line.unitCost < 0)) {
return { ok: false as const, reason: "Unit cost must be zero or greater." };
}
const itemIds = [...new Set(normalized.map((line) => line.itemId))];
const items = await prisma.inventoryItem.findMany({
where: { id: { in: itemIds } },
select: { id: true, isPurchasable: true },
});
if (items.length !== itemIds.length) {
return { ok: false as const, reason: "One or more purchase lines reference an invalid inventory item." };
}
if (items.some((item) => !item.isPurchasable)) {
return { ok: false as const, reason: "Purchase orders can only include purchasable inventory items." };
}
return { ok: true as const, lines: normalized };
}
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
.map((line) => ({
id: line.id,
itemId: line.item.id,
itemSku: line.item.sku,
itemName: line.item.name,
description: line.description,
quantity: line.quantity,
unitOfMeasure: line.unitOfMeasure as PurchaseOrderDetailDto["lines"][number]["unitOfMeasure"],
unitCost: line.unitCost,
lineTotal: line.quantity * line.unitCost,
position: line.position,
}));
const totals = calculateTotals(
lines.reduce((sum, line) => sum + line.lineTotal, 0),
record.taxPercent,
record.freightAmount
);
return {
id: record.id,
documentNumber: record.documentNumber,
vendorId: record.vendor.id,
vendorName: record.vendor.name,
vendorEmail: record.vendor.email,
paymentTerms: record.vendor.paymentTerms,
currencyCode: record.vendor.currencyCode,
status: record.status as PurchaseOrderStatus,
subtotal: totals.subtotal,
taxPercent: totals.taxPercent,
taxAmount: totals.taxAmount,
freightAmount: totals.freightAmount,
total: totals.total,
issueDate: record.issueDate.toISOString(),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
};
}
async function nextDocumentNumber() {
const next = (await purchaseOrderModel.count()) + 1;
return `PO-${String(next).padStart(5, "0")}`;
}
function buildInclude() {
return {
vendor: {
select: {
id: true,
name: true,
email: true,
paymentTerms: true,
currencyCode: true,
},
},
lines: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
};
}
export async function listPurchaseVendorOptions(): Promise<PurchaseVendorOptionDto[]> {
const vendors = await prisma.vendor.findMany({
where: {
status: {
not: "INACTIVE",
},
},
select: {
id: true,
name: true,
email: true,
paymentTerms: true,
currencyCode: true,
},
orderBy: [{ name: "asc" }],
});
return vendors;
}
export async function listPurchaseOrders(filters: { q?: string; status?: PurchaseOrderStatus } = {}) {
const query = filters.q?.trim();
const records = await purchaseOrderModel.findMany({
where: {
...(filters.status ? { status: filters.status } : {}),
...(query
? {
OR: [
{ documentNumber: { contains: query } },
{ vendor: { name: { contains: query } } },
],
}
: {}),
},
include: buildInclude(),
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
return records.map((record: unknown) => {
const detail = mapPurchaseOrder(record as PurchaseOrderRecord);
const summary: PurchaseOrderSummaryDto = {
id: detail.id,
documentNumber: detail.documentNumber,
vendorId: detail.vendorId,
vendorName: detail.vendorName,
status: detail.status,
subtotal: detail.subtotal,
taxPercent: detail.taxPercent,
taxAmount: detail.taxAmount,
freightAmount: detail.freightAmount,
total: detail.total,
issueDate: detail.issueDate,
updatedAt: detail.updatedAt,
lineCount: detail.lineCount,
};
return summary;
});
}
export async function getPurchaseOrderById(documentId: string) {
const record = await purchaseOrderModel.findUnique({
where: { id: documentId },
include: buildInclude(),
});
return record ? mapPurchaseOrder(record as PurchaseOrderRecord) : null;
}
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Vendor was not found." };
}
const documentNumber = await nextDocumentNumber();
const created = await purchaseOrderModel.create({
data: {
documentNumber,
vendorId: payload.vendorId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getPurchaseOrderById(created.id);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrder(documentId: string, payload: PurchaseOrderInput) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Purchase order was not found." };
}
const validatedLines = await validateLines(payload.lines);
if (!validatedLines.ok) {
return { ok: false as const, reason: validatedLines.reason };
}
const vendor = await prisma.vendor.findUnique({
where: { id: payload.vendorId },
select: { id: true },
});
if (!vendor) {
return { ok: false as const, reason: "Vendor was not found." };
}
await purchaseOrderModel.update({
where: { id: documentId },
data: {
vendorId: payload.vendorId,
status: payload.status,
issueDate: new Date(payload.issueDate),
taxPercent: payload.taxPercent,
freightAmount: payload.freightAmount,
notes: payload.notes,
lines: {
deleteMany: {},
create: validatedLines.lines,
},
},
select: { id: true },
});
const detail = await getPurchaseOrderById(documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load saved purchase order." };
}
export async function updatePurchaseOrderStatus(documentId: string, status: PurchaseOrderStatus) {
const existing = await purchaseOrderModel.findUnique({
where: { id: documentId },
select: { id: true },
});
if (!existing) {
return { ok: false as const, reason: "Purchase order was not found." };
}
await purchaseOrderModel.update({
where: { id: documentId },
data: { status },
select: { id: true },
});
const detail = await getPurchaseOrderById(documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}