purchase slice 1

This commit is contained in:
2026-03-15 09:04:18 -05:00
parent 5a1164f497
commit 18e4044124
11 changed files with 753 additions and 48 deletions

View File

@@ -13,6 +13,7 @@ This repository implements the platform foundation milestone:
- sales quotes and sales orders with quick actions and quote conversion
- purchase orders with quick actions and searchable vendor/SKU entry
- purchase orders restricted to inventory items flagged as purchasable
- purchase receiving foundation with inventory posting and receipt history
- shipping shipments linked to sales orders and packing-slip PDFs
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -49,10 +50,10 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- purchase receiving flow and vendor-side operational depth
- sales and purchasing document templates
- shipping labels, bills of lading, and logistics attachments
- projects and program management
- manufacturing execution
- vendor invoice/supporting-document attachments and broader vendor-side operational depth
- planning and gantt scheduling with live project/manufacturing data
- broader audit and operations maturity

View File

@@ -12,6 +12,7 @@ Current foundation scope includes:
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- sales quotes and sales orders with searchable customer and SKU entry
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
- purchase receiving with warehouse/location posting and receipt history against purchase orders
- shipping shipments linked to sales orders with packing-slip PDFs
- file storage and PDF rendering
@@ -34,11 +35,11 @@ Planned cross-module execution areas:
Near-term priorities:
1. Purchase receiving flow and vendor-facing operational depth
2. Sales and purchasing PDF templates
3. Shipping labels, bills of lading, and logistics attachments
4. Projects and program management
5. Manufacturing execution
1. Sales and purchasing PDF templates
2. Shipping labels, bills of lading, and logistics attachments
3. Projects and program management
4. Manufacturing execution
5. Vendor invoice/supporting-document attachments and broader vendor-facing operational depth
Revisit / deferred items:
@@ -221,13 +222,13 @@ The current purchasing foundation supports:
- searchable vendor lookup instead of a static vendor dropdown
- SKU-searchable line entry for purchase-order lines
- purchase-order item lookup restricted to inventory items flagged as purchasable
- receiving workflow tied to purchase orders, with receipt history and inventory posting
- document-level tax, freight, subtotal, and total calculations
- quick status actions directly from purchase-order detail pages
- vendor payment terms and currency surfaced on purchase-order forms and details
QOL direction:
- receiving workflow tied to purchase orders
- vendor invoice/supporting-document attachments
- purchasing PDFs through the shared document pipeline
- richer dashboard widgets for vendor queues and inbound material exceptions
@@ -291,6 +292,7 @@ As of March 14, 2026, the latest committed domain migrations include:
- inventory transactions and on-hand tracking
- sales quote and sales-order foundation
- purchase-order foundation
- purchase receiving foundation
- inventory default price support
- sales totals and commercial fields
- shipping foundation

View File

@@ -30,6 +30,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Sales quotes and sales orders with commercial totals logic
- Purchase orders with vendor lookup, item lines, totals, and quick status actions
- Purchase-order line selection restricted to inventory items flagged as purchasable
- Purchase receiving foundation with warehouse/location posting, receipt history, and per-line received quantity tracking
- Shipping shipment records linked to sales orders
- Packing-slip PDF rendering for shipments
- SKU-searchable BOM component selection for inventory-scale datasets
@@ -45,7 +46,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- Prisma migration execution is committed and documented, but local Windows Node 24 schema-engine behavior remains inconsistent; use Node 22 or Docker for migration execution
- The frontend bundle is functional but should be code-split later, especially around the gantt module
- CRM reporting is now functional, but broader account-role depth and downstream document rollups can still evolve later
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, receiving flow, shipment labels, or broader branded transactional PDF coverage beyond packing slips
- The current sales/purchasing/shipping foundation still does not include approvals, revisions, shipment labels, vendor-side attachment handling, or broader branded transactional PDF coverage beyond packing slips
- The dashboard is now live-data driven, but still needs richer KPI widgets, alerts, recent-activity queues, and exception reporting as more transactional depth is added
## Dashboard Plan
@@ -95,6 +96,7 @@ QOL subfeatures:
- Quotes, sales orders, and purchase orders
- Reusable line-item and totals model
- Purchase receiving flow tied to purchase-order lines and inventory receipts foundation
- Document states, approvals, and revision history
- Branded PDF templates rendered through Puppeteer
- Attachments for vendor invoices and supporting documents
@@ -223,6 +225,7 @@ QOL subfeatures:
- Frontend bundle splitting is still deferred; the Vite chunk-size warning remains
- Sales approvals and document revision history were planned but not yet built
- Broader branded PDFs for quotes and sales orders still need to be added
- Purchasing PDFs and vendor invoice/supporting-document attachments still need to be added
- Shipping is now linked to sales orders, but labels, bills of lading, and logistics attachments are still pending
- Inventory transactions exist, but transfers, reservations, and more advanced stock controls still need follow-up
- CRM document rollups and broader account-role depth were deferred until more downstream modules exist
@@ -243,8 +246,8 @@ QOL subfeatures:
## Near-term priority order
1. Purchase receiving flow and vendor-side operational depth
2. Sales and purchasing PDF templates
3. Shipping labels, bills of lading, and logistics attachments
4. Projects and program management
5. Manufacturing execution
1. Sales and purchasing PDF templates
2. Shipping labels, bills of lading, and logistics attachments
3. Projects and program management
4. Manufacturing execution
5. Vendor invoice/supporting-document attachments and broader vendor-side operational depth

View File

@@ -47,6 +47,7 @@ import type {
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import type {
ShipmentDetailDto,
ShipmentInput,
@@ -470,6 +471,13 @@ export const api = {
token
);
},
createPurchaseReceipt(token: string, orderId: string, payload: PurchaseReceiptInput) {
return request<PurchaseOrderDetailDto>(
`/api/v1/purchasing/orders/${orderId}/receipts`,
{ method: "POST", body: JSON.stringify(payload) },
token
);
},
getShipmentOrderOptions(token: string) {
return request<ShipmentOrderOptionDto[]>("/api/v1/shipping/orders/options", undefined, token);
},

View File

@@ -1,21 +1,29 @@
import { permissions } from "@mrp/shared";
import type { PurchaseOrderDetailDto, PurchaseOrderStatus } from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { purchaseStatusOptions } from "./config";
import { emptyPurchaseReceiptInput, purchaseStatusOptions } from "./config";
import { PurchaseStatusBadge } from "./PurchaseStatusBadge";
export function PurchaseDetailPage() {
const { token, user } = useAuth();
const { orderId } = useParams();
const [document, setDocument] = useState<PurchaseOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [receiptForm, setReceiptForm] = useState<PurchaseReceiptInput>(emptyPurchaseReceiptInput);
const [receiptQuantities, setReceiptQuantities] = useState<Record<string, number>>({});
const [receiptStatus, setReceiptStatus] = useState("Receive ordered material into inventory against this purchase order.");
const [isSavingReceipt, setIsSavingReceipt] = useState(false);
const [status, setStatus] = useState("Loading purchase order...");
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const canManage = user?.permissions.includes("purchasing.write") ?? false;
const canReceive = canManage && (user?.permissions.includes(permissions.inventoryWrite) ?? false);
useEffect(() => {
if (!token || !orderId) {
@@ -31,13 +39,66 @@ export function PurchaseDetailPage() {
const message = error instanceof ApiError ? error.message : "Unable to load purchase order.";
setStatus(message);
});
}, [orderId, token]);
if (!canReceive) {
return;
}
api.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
setReceiptForm((current: PurchaseReceiptInput) => {
if (current.locationId) {
return current;
}
const firstOption = options[0];
return firstOption
? {
...current,
warehouseId: firstOption.warehouseId,
locationId: firstOption.locationId,
}
: current;
});
})
.catch(() => setLocationOptions([]));
}, [canReceive, orderId, token]);
useEffect(() => {
if (!document) {
return;
}
setReceiptQuantities((current) => {
const next: Record<string, number> = {};
for (const line of document.lines) {
if (line.remainingQuantity > 0) {
next[line.id] = current[line.id] ?? 0;
}
}
return next;
});
}, [document]);
if (!document) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
const activeDocument = document;
const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0);
function updateReceiptField<Key extends keyof PurchaseReceiptInput>(key: Key, value: PurchaseReceiptInput[Key]) {
setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value }));
}
function updateReceiptQuantity(lineId: string, quantity: number) {
setReceiptQuantities((current: Record<string, number>) => ({
...current,
[lineId]: quantity,
}));
}
async function handleStatusChange(nextStatus: PurchaseOrderStatus) {
if (!token) {
@@ -59,6 +120,44 @@ export function PurchaseDetailPage() {
}
}
async function handleReceiptSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !canReceive) {
return;
}
setIsSavingReceipt(true);
setReceiptStatus("Posting purchase receipt...");
try {
const payload: PurchaseReceiptInput = {
...receiptForm,
lines: openLines
.map((line) => ({
purchaseOrderLineId: line.id,
quantity: Math.max(0, Math.floor(receiptQuantities[line.id] ?? 0)),
}))
.filter((line) => line.quantity > 0),
};
const nextDocument = await api.createPurchaseReceipt(token, activeDocument.id, payload);
setDocument(nextDocument);
setReceiptQuantities({});
setReceiptForm((current: PurchaseReceiptInput) => ({
...current,
receivedAt: new Date().toISOString(),
notes: "",
}));
setReceiptStatus("Purchase receipt recorded.");
setStatus("Purchase order updated after receipt.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to record purchase receipt.";
setReceiptStatus(message);
} finally {
setIsSavingReceipt(false);
}
}
return (
<section className="space-y-4">
<div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -103,10 +202,12 @@ export function PurchaseDetailPage() {
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Issue Date</p><div className="mt-2 text-base font-bold text-text">{new Date(activeDocument.issueDate).toLocaleDateString()}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Lines</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lineCount}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Receipts</p><div className="mt-2 text-base font-bold text-text">{activeDocument.receipts.length}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Qty Remaining</p><div className="mt-2 text-base font-bold text-text">{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}</div></article>
</section>
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Subtotal</p><div className="mt-2 text-base font-bold text-text">${activeDocument.subtotal.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Total</p><div className="mt-2 text-base font-bold text-text">${activeDocument.total.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Tax</p><div className="mt-2 text-base font-bold text-text">${activeDocument.taxAmount.toFixed(2)}</div><div className="mt-1 text-xs text-muted">{activeDocument.taxPercent.toFixed(2)}%</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Freight</p><div className="mt-2 text-base font-bold text-text">${activeDocument.freightAmount.toFixed(2)}</div></article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Payment Terms</p><div className="mt-2 text-base font-bold text-text">{activeDocument.paymentTerms || "N/A"}</div></article>
@@ -133,7 +234,7 @@ export function PurchaseDetailPage() {
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70">
<table className="min-w-full divide-y divide-line/70 text-sm">
<thead className="bg-page/80 text-left text-muted">
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Qty</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
<tr><th className="px-2 py-2">Item</th><th className="px-2 py-2">Description</th><th className="px-2 py-2">Ordered</th><th className="px-2 py-2">Received</th><th className="px-2 py-2">Remaining</th><th className="px-2 py-2">UOM</th><th className="px-2 py-2">Unit Cost</th><th className="px-2 py-2">Total</th></tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => (
@@ -141,6 +242,8 @@ export function PurchaseDetailPage() {
<td className="px-2 py-2"><div className="font-semibold text-text">{line.itemSku}</div><div className="mt-1 text-xs text-muted">{line.itemName}</div></td>
<td className="px-2 py-2 text-muted">{line.description}</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.receivedQuantity}</td>
<td className="px-2 py-2 text-muted">{line.remainingQuantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">${line.unitCost.toFixed(2)}</td>
<td className="px-2 py-2 text-muted">${line.lineTotal.toFixed(2)}</td>
@@ -151,6 +254,143 @@ export function PurchaseDetailPage() {
</div>
)}
</section>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canReceive ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Purchase Receiving</p>
<h4 className="mt-2 text-lg font-bold text-text">Receive material</h4>
<p className="mt-2 text-sm text-muted">Post received quantities to inventory and retain a receipt record against this order.</p>
{openLines.length === 0 ? (
<div className="mt-5 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
All ordered quantities have been received for this purchase order.
</div>
) : (
<form className="mt-5 space-y-4" onSubmit={handleReceiptSubmit}>
<div className="grid gap-3 xl:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Receipt date</span>
<input
type="date"
value={receiptForm.receivedAt.slice(0, 10)}
onChange={(event) => updateReceiptField("receivedAt", new Date(event.target.value).toISOString())}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select
value={receiptForm.locationId}
onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateReceiptField("locationId", event.target.value);
if (nextLocation) {
updateReceiptField("warehouseId", nextLocation.warehouseId);
}
}}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea
value={receiptForm.notes}
onChange={(event) => updateReceiptField("notes", event.target.value)}
rows={3}
className="w-full rounded-3xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
<div className="space-y-3">
{openLines.map((line) => (
<div key={line.id} className="grid gap-3 rounded-3xl border border-line/70 bg-page/60 p-3 xl:grid-cols-[minmax(0,1.3fr)_0.6fr_0.7fr_0.7fr]">
<div>
<div className="font-semibold text-text">{line.itemSku}</div>
<div className="mt-1 text-xs text-muted">{line.itemName}</div>
<div className="mt-2 text-xs text-muted">{line.description}</div>
</div>
<div className="text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Remaining</div>
<div className="mt-2 font-semibold text-text">{line.remainingQuantity}</div>
</div>
<div className="text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Received</div>
<div className="mt-2 font-semibold text-text">{line.receivedQuantity}</div>
</div>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Receive Now</span>
<input
type="number"
min={0}
max={line.remainingQuantity}
step={1}
value={receiptQuantities[line.id] ?? 0}
onChange={(event) => updateReceiptQuantity(line.id, Number.parseInt(event.target.value, 10) || 0)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>
</div>
))}
</div>
<div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="min-w-0 text-sm text-muted">{receiptStatus}</span>
<button
type="submit"
disabled={isSavingReceipt || locationOptions.length === 0}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isSavingReceipt ? "Posting..." : "Post receipt"}
</button>
</div>
</form>
)}
</article>
) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Receipt History</p>
<h4 className="mt-2 text-lg font-bold text-text">Received material log</h4>
{activeDocument.receipts.length === 0 ? (
<div className="mt-6 rounded-3xl border border-dashed border-line/70 bg-page/60 px-4 py-8 text-center text-sm text-muted">
No purchase receipts have been recorded for this order yet.
</div>
) : (
<div className="mt-6 space-y-3">
{activeDocument.receipts.map((receipt) => (
<article key={receipt.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">{receipt.receiptNumber}</div>
<div className="mt-1 text-xs text-muted">
{receipt.warehouseCode} / {receipt.locationCode} · {receipt.totalQuantity} units across {receipt.lineCount} line{receipt.lineCount === 1 ? "" : "s"}
</div>
<div className="mt-2 text-xs text-muted">
{receipt.warehouseName} · {receipt.locationName}
</div>
<div className="mt-3 space-y-1">
{receipt.lines.map((line) => (
<div key={line.id} className="text-sm text-text">
<span className="font-semibold">{line.itemSku}</span> · {line.quantity}
</div>
))}
</div>
{receipt.notes ? <p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{receipt.notes}</p> : null}
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(receipt.receivedAt).toLocaleDateString()}</div>
<div className="mt-1">{receipt.createdByName}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
<div className="rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm text-muted">{status}</div>
</section>
);

View File

@@ -1,4 +1,5 @@
import type { PurchaseOrderInput, PurchaseOrderStatus } from "@mrp/shared";
import type { PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
export const purchaseStatusOptions: Array<{ value: PurchaseOrderStatus; label: string }> = [
{ value: "DRAFT", label: "Draft" },
@@ -28,3 +29,11 @@ export const emptyPurchaseOrderInput: PurchaseOrderInput = {
notes: "",
lines: [],
};
export const emptyPurchaseReceiptInput: PurchaseReceiptInput = {
receivedAt: new Date().toISOString(),
warehouseId: "",
locationId: "",
notes: "",
lines: [],
};

View File

@@ -0,0 +1,34 @@
CREATE TABLE "PurchaseReceipt" (
"id" TEXT NOT NULL PRIMARY KEY,
"receiptNumber" TEXT NOT NULL,
"purchaseOrderId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"receivedAt" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseReceipt_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceipt_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "PurchaseReceiptLine" (
"id" TEXT NOT NULL PRIMARY KEY,
"purchaseReceiptId" TEXT NOT NULL,
"purchaseOrderLineId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PurchaseReceiptLine_purchaseReceiptId_fkey" FOREIGN KEY ("purchaseReceiptId") REFERENCES "PurchaseReceipt" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PurchaseReceiptLine_purchaseOrderLineId_fkey" FOREIGN KEY ("purchaseOrderLineId") REFERENCES "PurchaseOrderLine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "PurchaseReceipt_receiptNumber_key" ON "PurchaseReceipt"("receiptNumber");
CREATE INDEX "PurchaseReceipt_purchaseOrderId_createdAt_idx" ON "PurchaseReceipt"("purchaseOrderId", "createdAt");
CREATE INDEX "PurchaseReceipt_warehouseId_createdAt_idx" ON "PurchaseReceipt"("warehouseId", "createdAt");
CREATE INDEX "PurchaseReceipt_locationId_createdAt_idx" ON "PurchaseReceipt"("locationId", "createdAt");
CREATE INDEX "PurchaseReceiptLine_purchaseReceiptId_idx" ON "PurchaseReceiptLine"("purchaseReceiptId");
CREATE INDEX "PurchaseReceiptLine_purchaseOrderLineId_idx" ON "PurchaseReceiptLine"("purchaseOrderLineId");

View File

@@ -20,6 +20,7 @@ model User {
userRoles UserRole[]
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
}
model Role {
@@ -134,6 +135,7 @@ model Warehouse {
updatedAt DateTime @updatedAt
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
}
model Customer {
@@ -198,6 +200,7 @@ model WarehouseLocation {
updatedAt DateTime @updatedAt
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
purchaseReceipts PurchaseReceipt[]
@@unique([warehouseId, code])
@@index([warehouseId])
@@ -384,6 +387,7 @@ model PurchaseOrder {
updatedAt DateTime @updatedAt
vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Restrict)
lines PurchaseOrderLine[]
receipts PurchaseReceipt[]
}
model PurchaseOrderLine {
@@ -399,6 +403,43 @@ model PurchaseOrderLine {
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
receiptLines PurchaseReceiptLine[]
@@index([purchaseOrderId, position])
}
model PurchaseReceipt {
id String @id @default(cuid())
receiptNumber String @unique
purchaseOrderId String
warehouseId String
locationId String
receivedAt DateTime
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
lines PurchaseReceiptLine[]
@@index([purchaseOrderId, createdAt])
@@index([warehouseId, createdAt])
@@index([locationId, createdAt])
}
model PurchaseReceiptLine {
id String @id @default(cuid())
purchaseReceiptId String
purchaseOrderLineId String
quantity Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
purchaseReceipt PurchaseReceipt @relation(fields: [purchaseReceiptId], references: [id], onDelete: Cascade)
purchaseOrderLine PurchaseOrderLine @relation(fields: [purchaseOrderLineId], references: [id], onDelete: Restrict)
@@index([purchaseReceiptId])
@@index([purchaseOrderLineId])
}

View File

@@ -1,11 +1,12 @@
import { permissions, purchaseOrderStatuses } from "@mrp/shared";
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 {
createPurchaseReceipt,
createPurchaseOrder,
getPurchaseOrderById,
listPurchaseOrders,
@@ -42,6 +43,19 @@ const purchaseStatusUpdateSchema = z.object({
status: z.enum(purchaseOrderStatuses),
});
const purchaseReceiptLineSchema = z.object({
purchaseOrderLineId: z.string().trim().min(1),
quantity: z.number().int().positive(),
});
const purchaseReceiptSchema = z.object({
receivedAt: z.string().datetime(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
notes: z.string(),
lines: z.array(purchaseReceiptLineSchema),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -126,3 +140,26 @@ purchasingRouter.patch("/orders/:orderId/status", requirePermissions(["purchasin
return ok(response, result.document);
});
purchasingRouter.post(
"/orders/:orderId/receipts",
requirePermissions([permissions.purchasingWrite, permissions.inventoryWrite]),
async (request, response) => {
const orderId = getRouteParam(request.params.orderId);
if (!orderId) {
return fail(response, 400, "INVALID_INPUT", "Purchase order id is invalid.");
}
const parsed = purchaseReceiptSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Purchase receipt payload is invalid.");
}
const result = await createPurchaseReceipt(orderId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.document, 201);
}
);

View File

@@ -1,15 +1,10 @@
import type {
PurchaseLineInput,
PurchaseOrderDetailDto,
PurchaseOrderInput,
PurchaseOrderStatus,
PurchaseOrderSummaryDto,
PurchaseVendorOptionDto,
} from "@mrp/shared";
import { Prisma } from "@prisma/client";
import type { PurchaseLineInput, PurchaseOrderDetailDto, PurchaseOrderInput, PurchaseOrderStatus, PurchaseOrderSummaryDto, PurchaseVendorOptionDto } from "@mrp/shared";
import type { PurchaseReceiptDto, PurchaseReceiptInput } from "@mrp/shared/dist/purchasing/types.js";
import { prisma } from "../../lib/prisma.js";
const purchaseOrderModel = (prisma as any).purchaseOrder;
const purchaseOrderModel = prisma.purchaseOrder;
type PurchaseLineRecord = {
id: string;
@@ -18,6 +13,9 @@ type PurchaseLineRecord = {
unitOfMeasure: string;
unitCost: number;
position: number;
receiptLines?: {
quantity: number;
}[];
item: {
id: string;
sku: string;
@@ -25,6 +23,42 @@ type PurchaseLineRecord = {
};
};
type PurchaseReceiptLineRecord = {
id: string;
purchaseOrderLineId: string;
quantity: number;
purchaseOrderLine: {
item: {
id: string;
sku: string;
name: string;
};
};
};
type PurchaseReceiptRecord = {
id: string;
receiptNumber: string;
receivedAt: Date;
notes: string;
createdAt: Date;
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
lines: PurchaseReceiptLineRecord[];
};
type PurchaseOrderRecord = {
id: string;
documentNumber: string;
@@ -43,6 +77,7 @@ type PurchaseOrderRecord = {
currencyCode: string | null;
};
lines: PurchaseLineRecord[];
receipts: PurchaseReceiptRecord[];
};
function roundMoney(value: number) {
@@ -65,6 +100,40 @@ function calculateTotals(subtotal: number, taxPercent: number, freightAmount: nu
};
}
function getCreatedByName(createdBy: PurchaseReceiptRecord["createdBy"]) {
return createdBy ? `${createdBy.firstName} ${createdBy.lastName}`.trim() : "System";
}
function mapPurchaseReceipt(record: PurchaseReceiptRecord, purchaseOrderId: string): PurchaseReceiptDto {
const lines = record.lines.map((line: PurchaseReceiptLineRecord) => ({
id: line.id,
purchaseOrderLineId: line.purchaseOrderLineId,
itemId: line.purchaseOrderLine.item.id,
itemSku: line.purchaseOrderLine.item.sku,
itemName: line.purchaseOrderLine.item.name,
quantity: line.quantity,
}));
return {
id: record.id,
receiptNumber: record.receiptNumber,
purchaseOrderId,
receivedAt: record.receivedAt.toISOString(),
notes: record.notes,
createdAt: record.createdAt.toISOString(),
createdByName: getCreatedByName(record.createdBy),
warehouseId: record.warehouse.id,
warehouseCode: record.warehouse.code,
warehouseName: record.warehouse.name,
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
totalQuantity: lines.reduce((sum: number, line: PurchaseReceiptDto["lines"][number]) => sum + line.quantity, 0),
lineCount: lines.length,
lines,
};
}
function normalizeLines(lines: PurchaseLineInput[]) {
return lines
.map((line, index) => ({
@@ -111,6 +180,14 @@ async function validateLines(lines: PurchaseLineInput[]) {
}
function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
const receivedByLineId = new Map<string, number>();
for (const receipt of record.receipts) {
for (const line of receipt.lines) {
receivedByLineId.set(line.purchaseOrderLineId, (receivedByLineId.get(line.purchaseOrderLineId) ?? 0) + line.quantity);
}
}
const lines = record.lines
.slice()
.sort((left, right) => left.position - right.position)
@@ -124,6 +201,8 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
unitOfMeasure: line.unitOfMeasure as PurchaseOrderDetailDto["lines"][number]["unitOfMeasure"],
unitCost: line.unitCost,
lineTotal: line.quantity * line.unitCost,
receivedQuantity: receivedByLineId.get(line.id) ?? 0,
remainingQuantity: Math.max(0, line.quantity - (receivedByLineId.get(line.id) ?? 0)),
position: line.position,
}));
const totals = calculateTotals(
@@ -131,6 +210,10 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
record.taxPercent,
record.freightAmount
);
const receipts = record.receipts
.slice()
.sort((left, right) => right.receivedAt.getTime() - left.receivedAt.getTime())
.map((receipt) => mapPurchaseReceipt(receipt, record.id));
return {
id: record.id,
@@ -152,6 +235,7 @@ function mapPurchaseOrder(record: PurchaseOrderRecord): PurchaseOrderDetailDto {
updatedAt: record.updatedAt.toISOString(),
lineCount: lines.length,
lines,
receipts,
};
}
@@ -160,8 +244,12 @@ async function nextDocumentNumber() {
return `PO-${String(next).padStart(5, "0")}`;
}
function buildInclude() {
return {
async function nextReceiptNumber(transaction: Prisma.TransactionClient | typeof prisma = prisma) {
const next = (await transaction.purchaseReceipt.count()) + 1;
return `PR-${String(next).padStart(5, "0")}`;
}
const purchaseOrderInclude = Prisma.validator<Prisma.PurchaseOrderInclude>()({
vendor: {
select: {
id: true,
@@ -183,6 +271,156 @@ function buildInclude() {
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
receipts: {
include: {
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
lines: {
include: {
purchaseOrderLine: {
include: {
item: {
select: {
id: true,
sku: true,
name: true,
},
},
},
},
},
orderBy: [{ createdAt: "asc" }],
},
},
orderBy: [{ receivedAt: "desc" }, { createdAt: "desc" }],
},
});
function normalizeReceiptLines(lines: PurchaseReceiptInput["lines"]) {
return lines
.map((line) => ({
purchaseOrderLineId: line.purchaseOrderLineId.trim(),
quantity: Number(line.quantity),
}))
.filter((line) => line.purchaseOrderLineId.length > 0 && line.quantity > 0);
}
async function validateReceipt(orderId: string, payload: PurchaseReceiptInput) {
const normalizedLines = normalizeReceiptLines(payload.lines);
if (normalizedLines.length === 0) {
return { ok: false as const, reason: "At least one receipt line is required." };
}
if (normalizedLines.some((line) => !Number.isInteger(line.quantity) || line.quantity <= 0)) {
return { ok: false as const, reason: "Receipt quantity must be a whole number greater than zero." };
}
const order = await purchaseOrderModel.findUnique({
where: { id: orderId },
select: {
id: true,
documentNumber: true,
status: true,
lines: {
select: {
id: true,
quantity: true,
itemId: true,
item: {
select: {
sku: true,
},
},
receiptLines: {
select: {
quantity: true,
},
},
},
},
},
});
if (!order) {
return { ok: false as const, reason: "Purchase order was not found." };
}
if (order.status === "CLOSED") {
return { ok: false as const, reason: "Closed purchase orders cannot receive additional stock." };
}
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const orderLinesById = new Map<string, { id: string; quantity: number; itemId: string; itemSku: string; receivedQuantity: number }>(
order.lines.map((line: { id: string; quantity: number; itemId: string; item: { sku: string }; receiptLines: { quantity: number }[] }) => [
line.id,
{
id: line.id,
quantity: line.quantity,
itemId: line.itemId,
itemSku: line.item.sku,
receivedQuantity: line.receiptLines.reduce((sum: number, receiptLine: { quantity: number }) => sum + receiptLine.quantity, 0),
},
])
);
const mergedQuantities = new Map<string, number>();
for (const line of normalizedLines) {
const current = mergedQuantities.get(line.purchaseOrderLineId) ?? 0;
mergedQuantities.set(line.purchaseOrderLineId, current + line.quantity);
}
for (const [lineId, quantity] of mergedQuantities.entries()) {
const orderLine = orderLinesById.get(lineId);
if (!orderLine) {
return { ok: false as const, reason: "Receipt lines must reference lines on the selected purchase order." };
}
if (orderLine.receivedQuantity + quantity > orderLine.quantity) {
return {
ok: false as const,
reason: `Receipt for ${orderLine.itemSku} exceeds the remaining ordered quantity.`,
};
}
}
return {
ok: true as const,
order,
lines: [...mergedQuantities.entries()].map(([purchaseOrderLineId, quantity]) => ({
purchaseOrderLineId,
quantity,
itemId: orderLinesById.get(purchaseOrderLineId)!.itemId,
})),
};
}
@@ -220,7 +458,7 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
}
: {}),
},
include: buildInclude(),
include: purchaseOrderInclude,
orderBy: [{ issueDate: "desc" }, { createdAt: "desc" }],
});
@@ -249,10 +487,10 @@ export async function listPurchaseOrders(filters: { q?: string; status?: Purchas
export async function getPurchaseOrderById(documentId: string) {
const record = await purchaseOrderModel.findUnique({
where: { id: documentId },
include: buildInclude(),
include: purchaseOrderInclude,
});
return record ? mapPurchaseOrder(record as PurchaseOrderRecord) : null;
return record ? mapPurchaseOrder(record as unknown as PurchaseOrderRecord) : null;
}
export async function createPurchaseOrder(payload: PurchaseOrderInput) {
@@ -356,3 +594,51 @@ export async function updatePurchaseOrderStatus(documentId: string, status: Purc
const detail = await getPurchaseOrderById(documentId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}
export async function createPurchaseReceipt(orderId: string, payload: PurchaseReceiptInput, createdById?: string | null) {
const validated = await validateReceipt(orderId, payload);
if (!validated.ok) {
return { ok: false as const, reason: validated.reason };
}
await prisma.$transaction(async (transaction) => {
const receiptNumber = await nextReceiptNumber(transaction);
const receipt = await transaction.purchaseReceipt.create({
data: {
receiptNumber,
purchaseOrderId: orderId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
receivedAt: new Date(payload.receivedAt),
notes: payload.notes,
createdById: createdById ?? null,
lines: {
create: validated.lines.map((line) => ({
purchaseOrderLineId: line.purchaseOrderLineId,
quantity: line.quantity,
})),
},
},
select: {
receiptNumber: true,
},
});
await transaction.inventoryTransaction.createMany({
data: validated.lines.map((line) => ({
itemId: line.itemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: "RECEIPT",
quantity: line.quantity,
reference: receipt.receiptNumber,
notes: `Purchase receipt for ${validated.order.documentNumber}`,
createdById: createdById ?? null,
})),
});
});
const detail = await getPurchaseOrderById(orderId);
return detail ? { ok: true as const, document: detail } : { ok: false as const, reason: "Unable to load updated purchase order." };
}

View File

@@ -22,6 +22,8 @@ export interface PurchaseLineDto {
unitOfMeasure: InventoryUnitOfMeasure;
unitCost: number;
lineTotal: number;
receivedQuantity: number;
remainingQuantity: number;
position: number;
}
@@ -57,6 +59,7 @@ export interface PurchaseOrderDetailDto extends PurchaseOrderSummaryDto {
currencyCode: string | null;
createdAt: string;
lines: PurchaseLineDto[];
receipts: PurchaseReceiptDto[];
}
export interface PurchaseOrderInput {
@@ -68,3 +71,44 @@ export interface PurchaseOrderInput {
notes: string;
lines: PurchaseLineInput[];
}
export interface PurchaseReceiptLineDto {
id: string;
purchaseOrderLineId: string;
itemId: string;
itemSku: string;
itemName: string;
quantity: number;
}
export interface PurchaseReceiptDto {
id: string;
receiptNumber: string;
purchaseOrderId: string;
receivedAt: string;
notes: string;
createdAt: string;
createdByName: string;
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
totalQuantity: number;
lineCount: number;
lines: PurchaseReceiptLineDto[];
}
export interface PurchaseReceiptLineInput {
purchaseOrderLineId: string;
quantity: number;
}
export interface PurchaseReceiptInput {
receivedAt: string;
warehouseId: string;
locationId: string;
notes: string;
lines: PurchaseReceiptLineInput[];
}