POs
This commit is contained in:
128
server/src/modules/purchasing/router.ts
Normal file
128
server/src/modules/purchasing/router.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { purchaseOrderStatuses } from "@mrp/shared";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "../../lib/http.js";
|
||||
import { requirePermissions } from "../../lib/rbac.js";
|
||||
import {
|
||||
createPurchaseOrder,
|
||||
getPurchaseOrderById,
|
||||
listPurchaseOrders,
|
||||
listPurchaseVendorOptions,
|
||||
updatePurchaseOrder,
|
||||
updatePurchaseOrderStatus,
|
||||
} from "./service.js";
|
||||
|
||||
const purchaseLineSchema = z.object({
|
||||
itemId: z.string().trim().min(1),
|
||||
description: z.string(),
|
||||
quantity: z.number().int().positive(),
|
||||
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
|
||||
unitCost: z.number().nonnegative(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const purchaseOrderSchema = z.object({
|
||||
vendorId: z.string().trim().min(1),
|
||||
status: z.enum(purchaseOrderStatuses),
|
||||
issueDate: z.string().datetime(),
|
||||
taxPercent: z.number().min(0).max(100),
|
||||
freightAmount: z.number().nonnegative(),
|
||||
notes: z.string(),
|
||||
lines: z.array(purchaseLineSchema),
|
||||
});
|
||||
|
||||
const purchaseListQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
status: z.enum(purchaseOrderStatuses).optional(),
|
||||
});
|
||||
|
||||
const purchaseStatusUpdateSchema = z.object({
|
||||
status: z.enum(purchaseOrderStatuses),
|
||||
});
|
||||
|
||||
function getRouteParam(value: unknown) {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export const purchasingRouter = Router();
|
||||
|
||||
purchasingRouter.get("/vendors/options", requirePermissions(["purchasing.read"]), async (_request, response) => {
|
||||
return ok(response, await listPurchaseVendorOptions());
|
||||
});
|
||||
|
||||
purchasingRouter.get("/orders", requirePermissions(["purchasing.read"]), async (request, response) => {
|
||||
const parsed = purchaseListQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order filters are invalid.");
|
||||
}
|
||||
|
||||
return ok(response, await listPurchaseOrders(parsed.data));
|
||||
});
|
||||
|
||||
purchasingRouter.get("/orders/:orderId", 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, order);
|
||||
});
|
||||
|
||||
purchasingRouter.post("/orders", requirePermissions(["purchasing.write"]), async (request, response) => {
|
||||
const parsed = purchaseOrderSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await createPurchaseOrder(parsed.data);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.document, 201);
|
||||
});
|
||||
|
||||
purchasingRouter.put("/orders/:orderId", requirePermissions(["purchasing.write"]), async (request, response) => {
|
||||
const orderId = getRouteParam(request.params.orderId);
|
||||
if (!orderId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = purchaseOrderSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrder(orderId, parsed.data);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.document);
|
||||
});
|
||||
|
||||
purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasing.write"]), async (request, response) => {
|
||||
const orderId = getRouteParam(request.params.orderId);
|
||||
if (!orderId) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
|
||||
}
|
||||
|
||||
const parsed = purchaseStatusUpdateSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return fail(response, 400, "INVALID_INPUT", "Purchase order status payload is invalid.");
|
||||
}
|
||||
|
||||
const result = await updatePurchaseOrderStatus(orderId, parsed.data.status);
|
||||
if (!result.ok) {
|
||||
return fail(response, 400, "INVALID_INPUT", result.reason);
|
||||
}
|
||||
|
||||
return ok(response, result.document);
|
||||
});
|
||||
358
server/src/modules/purchasing/service.ts
Normal file
358
server/src/modules/purchasing/service.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
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." };
|
||||
}
|
||||
Reference in New Issue
Block a user