This commit is contained in:
2026-03-15 00:29:41 -05:00
parent f66001e514
commit 3323435114
22 changed files with 1376 additions and 8 deletions

View File

@@ -0,0 +1,36 @@
-- CreateTable
CREATE TABLE "PurchaseOrder" (
"id" TEXT NOT NULL PRIMARY KEY,
"documentNumber" TEXT NOT NULL,
"vendorId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"issueDate" DATETIME NOT NULL,
"taxPercent" REAL NOT NULL DEFAULT 0,
"freightAmount" REAL NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrder_vendorId_fkey" FOREIGN KEY ("vendorId") REFERENCES "Vendor" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PurchaseOrderLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseOrderId" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"unitCost" REAL NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseOrderLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "PurchaseOrder_documentNumber_key" ON "PurchaseOrder"("documentNumber");
-- CreateIndex
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_position_idx" ON "PurchaseOrderLine"("purchaseOrderId", "position");

View File

@@ -122,6 +122,7 @@ model InventoryItem {
inventoryTransactions InventoryTransaction[]
salesQuoteLines SalesQuoteLine[]
salesOrderLines SalesOrderLine[]
purchaseOrderLines PurchaseOrderLine[]
}
model Warehouse {
@@ -250,6 +251,7 @@ model Vendor {
updatedAt DateTime @updatedAt
contactEntries CrmContactEntry[]
contacts CrmContact[]
purchaseOrders PurchaseOrder[]
}
model CrmContactEntry {
@@ -368,3 +370,35 @@ model Shipment {
@@index([salesOrderId, createdAt])
}
model PurchaseOrder {
id String @id @default(cuid())
documentNumber String @unique
vendorId String
status String
issueDate DateTime
taxPercent Float @default(0)
freightAmount Float @default(0)
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
lines PurchaseOrderLine[]
}
model PurchaseOrderLine {
id String @id @default(cuid())
purchaseOrderId String
itemId String
description String
quantity Int
unitOfMeasure String
unitCost Float
position Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
@@index([purchaseOrderId, position])
}

View File

@@ -17,6 +17,7 @@ import { documentsRouter } from "./modules/documents/router.js";
import { filesRouter } from "./modules/files/router.js";
import { ganttRouter } from "./modules/gantt/router.js";
import { inventoryRouter } from "./modules/inventory/router.js";
import { purchasingRouter } from "./modules/purchasing/router.js";
import { salesRouter } from "./modules/sales/router.js";
import { shippingRouter } from "./modules/shipping/router.js";
import { settingsRouter } from "./modules/settings/router.js";
@@ -54,6 +55,7 @@ export function createApp() {
app.use("/api/v1/files", filesRouter);
app.use("/api/v1/crm", crmRouter);
app.use("/api/v1/inventory", inventoryRouter);
app.use("/api/v1/purchasing", purchasingRouter);
app.use("/api/v1/sales", salesRouter);
app.use("/api/v1/shipping", shippingRouter);
app.use("/api/v1/gantt", ganttRouter);

View File

@@ -18,6 +18,8 @@ const permissionDescriptions: Record<PermissionKey, string> = {
[permissions.ganttRead]: "View gantt timelines",
[permissions.salesRead]: "View sales data",
[permissions.salesWrite]: "Manage quotes and sales orders",
"purchasing.read": "View purchasing data",
"purchasing.write": "Manage purchase orders",
[permissions.shippingRead]: "View shipping data",
[permissions.shippingWrite]: "Manage shipments",
};

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

View 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." };
}