From 18e4044124a23471344689eddddcdfa40e06ac88 Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 15 Mar 2026 09:04:18 -0500 Subject: [PATCH] purchase slice 1 --- INSTRUCTIONS.md | 3 +- README.md | 14 +- ROADMAP.md | 15 +- client/src/lib/api.ts | 8 + .../modules/purchasing/PurchaseDetailPage.tsx | 250 ++++++++++++- client/src/modules/purchasing/config.ts | 9 + .../migration.sql | 34 ++ server/prisma/schema.prisma | 41 +++ server/src/modules/purchasing/router.ts | 39 +- server/src/modules/purchasing/service.ts | 344 ++++++++++++++++-- shared/src/purchasing/types.ts | 44 +++ 11 files changed, 753 insertions(+), 48 deletions(-) create mode 100644 server/prisma/migrations/20260315110000_purchase_receiving/migration.sql diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md index 3b7d438..3520636 100644 --- a/INSTRUCTIONS.md +++ b/INSTRUCTIONS.md @@ -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 diff --git a/README.md b/README.md index fe37606..c7797d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 3c74558..ef4916e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fcb0592..611e9e4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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( + `/api/v1/purchasing/orders/${orderId}/receipts`, + { method: "POST", body: JSON.stringify(payload) }, + token + ); + }, getShipmentOrderOptions(token: string) { return request("/api/v1/shipping/orders/options", undefined, token); }, diff --git a/client/src/modules/purchasing/PurchaseDetailPage.tsx b/client/src/modules/purchasing/PurchaseDetailPage.tsx index 429a43e..b58c382 100644 --- a/client/src/modules/purchasing/PurchaseDetailPage.tsx +++ b/client/src/modules/purchasing/PurchaseDetailPage.tsx @@ -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(null); + const [locationOptions, setLocationOptions] = useState([]); + const [receiptForm, setReceiptForm] = useState(emptyPurchaseReceiptInput); + const [receiptQuantities, setReceiptQuantities] = useState>({}); + 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 = {}; + for (const line of document.lines) { + if (line.remainingQuantity > 0) { + next[line.id] = current[line.id] ?? 0; + } + } + + return next; + }); + }, [document]); if (!document) { return
{status}
; } const activeDocument = document; + const openLines = activeDocument.lines.filter((line) => line.remainingQuantity > 0); + + function updateReceiptField(key: Key, value: PurchaseReceiptInput[Key]) { + setReceiptForm((current: PurchaseReceiptInput) => ({ ...current, [key]: value })); + } + + function updateReceiptQuantity(lineId: string, quantity: number) { + setReceiptQuantities((current: Record) => ({ + ...current, + [lineId]: quantity, + })); + } async function handleStatusChange(nextStatus: PurchaseOrderStatus) { if (!token) { @@ -59,6 +120,44 @@ export function PurchaseDetailPage() { } } + async function handleReceiptSubmit(event: React.FormEvent) { + 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 (
@@ -103,10 +202,12 @@ export function PurchaseDetailPage() {

Issue Date

{new Date(activeDocument.issueDate).toLocaleDateString()}

Lines

{activeDocument.lineCount}
-

Subtotal

${activeDocument.subtotal.toFixed(2)}
-

Total

${activeDocument.total.toFixed(2)}
+

Receipts

{activeDocument.receipts.length}
+

Qty Remaining

{activeDocument.lines.reduce((sum, line) => sum + line.remainingQuantity, 0)}
+

Subtotal

${activeDocument.subtotal.toFixed(2)}
+

Total

${activeDocument.total.toFixed(2)}

Tax

${activeDocument.taxAmount.toFixed(2)}
{activeDocument.taxPercent.toFixed(2)}%

Freight

${activeDocument.freightAmount.toFixed(2)}

Payment Terms

{activeDocument.paymentTerms || "N/A"}
@@ -133,7 +234,7 @@ export function PurchaseDetailPage() {
- + {activeDocument.lines.map((line: PurchaseOrderDetailDto["lines"][number]) => ( @@ -141,6 +242,8 @@ export function PurchaseDetailPage() { + + @@ -151,6 +254,143 @@ export function PurchaseDetailPage() { )} +
+ {canReceive ? ( +
+

Purchase Receiving

+

Receive material

+

Post received quantities to inventory and retain a receipt record against this order.

+ {openLines.length === 0 ? ( +
+ All ordered quantities have been received for this purchase order. +
+ ) : ( +
+
+ + +
+
ItemDescriptionQtyUOMUnit CostTotal
ItemDescriptionOrderedReceivedRemainingUOMUnit CostTotal
{line.itemSku}
{line.itemName}
{line.description} {line.quantity}{line.receivedQuantity}{line.remainingQuantity} {line.unitOfMeasure} ${line.unitCost.toFixed(2)} ${line.lineTotal.toFixed(2)}