inventory control

This commit is contained in:
2026-03-15 14:00:12 -05:00
parent 16582d3cea
commit 1fcb0c5480
14 changed files with 986 additions and 205 deletions

View File

@@ -16,6 +16,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- filesystem-backed attachments - filesystem-backed attachments
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments - CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing - inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
- sales quotes, sales orders, approvals, revision history, and purchase orders - sales quotes, sales orders, approvals, revision history, and purchase orders
- purchase-order supporting documents and vendor-side purchasing visibility - purchase-order supporting documents and vendor-side purchasing visibility
- shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments - shipping shipments, packing-slip PDFs, shipping labels, bills of lading, and logistics attachments
@@ -118,9 +119,8 @@ If implementation changes invalidate those docs, update them in the same change
Near-term priorities are: Near-term priorities are:
1. Inventory transfers, reservations, and deeper stock controls 1. Broader audit-trail coverage and operational diagnostics
2. Broader audit-trail coverage and operational diagnostics 2. Code-splitting and bundle-size reduction
3. Code-splitting and bundle-size reduction
When adding new modules, preserve the ability to extend the system without refactoring the existing app shell. When adding new modules, preserve the ability to extend the system without refactoring the existing app shell.

View File

@@ -6,6 +6,9 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added ### Added
- Inventory transfers with paired physical stock movement posting between warehouses and locations
- Manual inventory reservations plus automatic work-order-driven component reservations
- Reserved and available stock visibility on inventory item detail and stock-by-location views
- Manufacturing stations with queue-day definitions and item-level station/time operation templates - Manufacturing stations with queue-day definitions and item-level station/time operation templates
- Automatic work-order operation plans copied from buildable item routing into planning/gantt - Automatic work-order operation plans copied from buildable item routing into planning/gantt
- Live planning gantt timelines backed by active projects and open manufacturing work orders - Live planning gantt timelines backed by active projects and open manufacturing work orders
@@ -27,13 +30,14 @@ This file is the running release and change log for MRP Codex. Keep it updated w
- The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping - The dashboard now treats Projects as a live first-class module alongside CRM, inventory, sales, and shipping
- The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects - The dashboard now treats Manufacturing as a live first-class module alongside CRM, inventory, sales, shipping, and projects
- The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page - The dashboard now treats Planning as a live first-class module with direct gantt access from the landing page
- Inventory control now distinguishes on-hand, reserved, and available stock instead of treating all positive stock as fully free
- Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically - Manufacturing and inventory now share a routing-driven workflow where assemblies/manufactured parts define station/time templates and work orders inherit them automatically
- Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow - Sales quote and sales-order detail pages now surface approval state and revision history directly in the operational workflow
- Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns - Project editing now uses searchable pickers for customer, owner, quote, sales-order, and shipment linkage instead of static operational dropdowns
- Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records - Project detail now surfaces linked work orders and can launch pre-seeded manufacturing records
- Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself - Purchase-order detail now links back to the vendor CRM record and supports direct supporting-document management on the PO itself
- Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders - Vendor CRM detail now exposes purchasing activity and can launch pre-seeded purchase orders
- Roadmap and project docs now treat inventory transfers and deeper stock controls as the next active priority after the planning slice - Roadmap and project docs now treat broader audit-trail coverage and operational diagnostics as the next active priority after the inventory-control slice
## 2026-03-15 ## 2026-03-15

View File

@@ -15,6 +15,7 @@ This repository implements the platform foundation milestone:
- file attachment storage - file attachment storage
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata - CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments - inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
- sales quotes and sales orders with quick actions and quote conversion - sales quotes and sales orders with quick actions and quote conversion
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders - sales approvals, approval stamps, and automatic revision history on quotes and sales orders
- purchase orders with quick actions and searchable vendor/SKU entry - purchase orders with quick actions and searchable vendor/SKU entry
@@ -61,6 +62,5 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates ## Next roadmap candidates
- inventory transfers, reservations, and deeper stock controls
- broader audit and operations maturity - broader audit and operations maturity
- code-splitting and bundle-size reduction - code-splitting and bundle-size reduction

View File

@@ -15,6 +15,7 @@ Current foundation scope includes:
- CRM search, filtering, status tagging, and reseller hierarchy - CRM search, filtering, status tagging, and reseller hierarchy
- CRM contact history, account contacts, and shared attachments - CRM contact history, account contacts, and shared attachments
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows - inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- inventory transfers, reservations, and available-stock visibility
- sales quotes and sales orders with searchable customer and SKU entry - sales quotes and sales orders with searchable customer and SKU entry
- sales approvals, approval stamps, and automatic revision history on quotes and sales orders - sales approvals, approval stamps, and automatic revision history on quotes and sales orders
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items - purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
@@ -43,9 +44,8 @@ Current completed foundation areas:
Near-term priorities: Near-term priorities:
1. Inventory transfers, reservations, and deeper stock controls 1. Broader audit-trail coverage and operational diagnostics
2. Broader audit-trail coverage and operational diagnostics 2. Code-splitting and bundle-size reduction
3. Code-splitting and bundle-size reduction
Revisit / deferred items: Revisit / deferred items:
@@ -206,15 +206,16 @@ The current inventory foundation supports:
- protected warehouse list, detail, create, and edit flows - protected warehouse list, detail, create, and edit flows
- nested stock-location management inside each warehouse record - nested stock-location management inside each warehouse record
- inventory transaction posting for receipts, issues, and adjustments - inventory transaction posting for receipts, issues, and adjustments
- inventory transfers with paired source/destination movement posting
- manual reservations plus automatic work-order component reservations
- item on-hand quantity, stock-by-location balances, and recent stock history - item on-hand quantity, stock-by-location balances, and recent stock history
- reserved and available quantity visibility by location
- item-level file attachments for drawings and support documents - item-level file attachments for drawings and support documents
- seeded sample inventory items and a starter assembly BOM during bootstrap - seeded sample inventory items and a starter assembly BOM during bootstrap
- seeded sample warehouse and stock locations during bootstrap - seeded sample warehouse and stock locations during bootstrap
QOL direction: QOL direction:
- stock transfers
- reservations and allocations
- clearer warehouse dashboards and shortage views - clearer warehouse dashboards and shortage views
- BOM revisions and where-used visibility - BOM revisions and where-used visibility

View File

@@ -32,6 +32,7 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups - CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
- Inventory item master, BOM, warehouse, and stock-location foundation - Inventory item master, BOM, warehouse, and stock-location foundation
- Inventory transactions, on-hand tracking, and item attachments - Inventory transactions, on-hand tracking, and item attachments
- Inventory transfers, reservations, available-stock visibility, and work-order-driven material reservation automation
- Sales quotes and sales orders with commercial totals logic - Sales quotes and sales orders with commercial totals logic
- Purchase orders with vendor lookup, item lines, totals, and quick status actions - Purchase orders with vendor lookup, item lines, totals, and quick status actions
- Purchase-order line selection restricted to inventory items flagged as purchasable - Purchase-order line selection restricted to inventory items flagged as purchasable
@@ -282,6 +283,5 @@ QOL subfeatures:
## Near-term priority order ## Near-term priority order
1. Inventory transfers, reservations, and deeper stock controls 1. Broader audit-trail coverage and operational diagnostics
2. Broader audit-trail coverage and operational diagnostics 2. Code-splitting and bundle-size reduction
3. Code-splitting and bundle-size reduction

View File

@@ -23,8 +23,10 @@ import type {
InventoryItemDetailDto, InventoryItemDetailDto,
InventoryItemInput, InventoryItemInput,
InventoryItemOptionDto, InventoryItemOptionDto,
InventoryReservationInput,
InventoryItemStatus, InventoryItemStatus,
InventoryItemSummaryDto, InventoryItemSummaryDto,
InventoryTransferInput,
InventoryTransactionInput, InventoryTransactionInput,
InventoryItemType, InventoryItemType,
WarehouseDetailDto, WarehouseDetailDto,
@@ -378,6 +380,26 @@ export const api = {
token token
); );
}, },
createInventoryTransfer(token: string, itemId: string, payload: InventoryTransferInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transfers`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
createInventoryReservation(token: string, itemId: string, payload: InventoryReservationInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/reservations`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getWarehouses(token: string) { getWarehouses(token: string) {
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token); return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
}, },

View File

@@ -1,4 +1,10 @@
import type { InventoryItemDetailDto, InventoryTransactionInput, WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js"; import type {
InventoryItemDetailDto,
InventoryReservationInput,
InventoryTransactionInput,
InventoryTransferInput,
WarehouseLocationOptionDto,
} from "@mrp/shared/dist/inventory/types.js";
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -11,14 +17,36 @@ import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge"; import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge"; import { InventoryTypeBadge } from "./InventoryTypeBadge";
const emptyTransferInput: InventoryTransferInput = {
quantity: 1,
fromWarehouseId: "",
fromLocationId: "",
toWarehouseId: "",
toLocationId: "",
notes: "",
};
const emptyReservationInput: InventoryReservationInput = {
quantity: 1,
warehouseId: null,
locationId: null,
notes: "",
};
export function InventoryDetailPage() { export function InventoryDetailPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const { itemId } = useParams(); const { itemId } = useParams();
const [item, setItem] = useState<InventoryItemDetailDto | null>(null); const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]); const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput); const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
const [transferForm, setTransferForm] = useState<InventoryTransferInput>(emptyTransferInput);
const [reservationForm, setReservationForm] = useState<InventoryReservationInput>(emptyReservationInput);
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item."); const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
const [transferStatus, setTransferStatus] = useState("Move physical stock between warehouses or locations without manual paired entries.");
const [reservationStatus, setReservationStatus] = useState("Reserve stock manually while active work orders reserve component demand automatically.");
const [isSavingTransaction, setIsSavingTransaction] = useState(false); const [isSavingTransaction, setIsSavingTransaction] = useState(false);
const [isSavingTransfer, setIsSavingTransfer] = useState(false);
const [isSavingReservation, setIsSavingReservation] = useState(false);
const [status, setStatus] = useState("Loading inventory item..."); const [status, setStatus] = useState("Loading inventory item...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false; const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
@@ -43,20 +71,23 @@ export function InventoryDetailPage() {
.getWarehouseLocationOptions(token) .getWarehouseLocationOptions(token)
.then((options) => { .then((options) => {
setLocationOptions(options); setLocationOptions(options);
setTransactionForm((current) => { const firstOption = options[0];
if (current.locationId) { if (!firstOption) {
return current; return;
} }
const firstOption = options[0]; setTransactionForm((current) => ({
return firstOption ...current,
? { warehouseId: current.warehouseId || firstOption.warehouseId,
...current, locationId: current.locationId || firstOption.locationId,
warehouseId: firstOption.warehouseId, }));
locationId: firstOption.locationId, setTransferForm((current) => ({
} ...current,
: current; fromWarehouseId: current.fromWarehouseId || firstOption.warehouseId,
}); fromLocationId: current.fromLocationId || firstOption.locationId,
toWarehouseId: current.toWarehouseId || firstOption.warehouseId,
toLocationId: current.toLocationId || firstOption.locationId,
}));
}) })
.catch(() => setLocationOptions([])); .catch(() => setLocationOptions([]));
}, [itemId, token]); }, [itemId, token]);
@@ -65,6 +96,10 @@ export function InventoryDetailPage() {
setTransactionForm((current) => ({ ...current, [key]: value })); setTransactionForm((current) => ({ ...current, [key]: value }));
} }
function updateTransferField<Key extends keyof InventoryTransferInput>(key: Key, value: InventoryTransferInput[Key]) {
setTransferForm((current) => ({ ...current, [key]: value }));
}
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!token || !itemId) { if (!token || !itemId) {
@@ -92,8 +127,51 @@ export function InventoryDetailPage() {
} }
} }
async function handleTransferSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingTransfer(true);
setTransferStatus("Saving transfer...");
try {
const nextItem = await api.createInventoryTransfer(token, itemId, transferForm);
setItem(nextItem);
setTransferStatus("Transfer recorded.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save transfer.";
setTransferStatus(message);
} finally {
setIsSavingTransfer(false);
}
}
async function handleReservationSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingReservation(true);
setReservationStatus("Saving reservation...");
try {
const nextItem = await api.createInventoryReservation(token, itemId, reservationForm);
setItem(nextItem);
setReservationStatus("Reservation recorded.");
setReservationForm((current) => ({ ...current, quantity: 1, notes: "" }));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save reservation.";
setReservationStatus(message);
} finally {
setIsSavingReservation(false);
}
}
if (!item) { if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>; return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
} }
return ( return (
@@ -111,7 +189,7 @@ export function InventoryDetailPage() {
<p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p> <p className="mt-3 text-sm text-muted">Last updated {new Date(item.updatedAt).toLocaleString()}.</p>
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"> <Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to items Back to items
</Link> </Link>
{canManage ? ( {canManage ? (
@@ -122,11 +200,20 @@ export function InventoryDetailPage() {
</div> </div>
</div> </div>
</div> </div>
<section className="grid gap-3 xl:grid-cols-5">
<section className="grid gap-3 xl:grid-cols-7">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <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">On Hand</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div> <div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</div>
</article> </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">Reserved</p>
<div className="mt-2 text-base font-bold text-text">{item.reservedQuantity}</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">Available</p>
<div className="mt-2 text-base font-bold text-text">{item.availableQuantity}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <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">Stock Locations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div> <div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
@@ -136,14 +223,15 @@ export function InventoryDetailPage() {
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div> <div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <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">BOM Lines</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transfers</p>
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</div> <div className="mt-2 text-base font-bold text-text">{item.transfers.length}</div>
</article> </article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"> <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">Operations</p> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Reservations</p>
<div className="mt-2 text-base font-bold text-text">{item.operations.length}</div> <div className="mt-2 text-base font-bold text-text">{item.reservations.length}</div>
</article> </article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1.05fr)_minmax(340px,0.95fr)]">
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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">Item Definition</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
@@ -173,128 +261,41 @@ export function InventoryDetailPage() {
</dl> </dl>
</article> </article>
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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">Internal Notes</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock By Location</p>
<p className="mt-3 whitespace-pre-line text-sm leading-6 text-text">{item.notes || "No internal notes recorded for this item yet."}</p> {item.stockBalances.length === 0 ? (
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted"> <p className="mt-4 text-sm text-muted">No stock or reservation balances have been posted for this item yet.</p>
Created {new Date(item.createdAt).toLocaleDateString()}
</div>
<div className="mt-4 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Current stock by location</p>
{item.stockBalances.length === 0 ? (
<p className="mt-2 text-sm text-muted">No stock has been posted for this item yet.</p>
) : (
<div className="mt-3 space-y-2">
{item.stockBalances.map((balance) => (
<div key={balance.locationId} className="flex items-center justify-between rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm">
<div className="min-w-0">
<div className="font-semibold text-text">
{balance.warehouseCode} / {balance.locationCode}
</div>
<div className="text-xs text-muted">
{balance.warehouseName} · {balance.locationName}
</div>
</div>
<div className="shrink-0 font-semibold text-text">{balance.quantityOnHand}</div>
</div>
))}
</div>
)}
</div>
</article>
</div>
<section 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">Bill Of Materials</p>
<h4 className="mt-2 text-lg font-bold text-text">Component structure</h4>
{item.bomLines.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 BOM lines are defined for this item yet.
</div>
) : (
<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">Position</th>
<th className="px-2 py-2">Component</th>
<th className="px-2 py-2">Quantity</th>
<th className="px-2 py-2">UOM</th>
<th className="px-2 py-2">Notes</th>
</tr>
</thead>
<tbody className="divide-y divide-line/70 bg-surface">
{item.bomLines.map((line) => (
<tr key={line.id}>
<td className="px-2 py-2 text-muted">{line.position}</td>
<td className="px-2 py-2">
<div className="font-semibold text-text">{line.componentSku}</div>
<div className="mt-1 text-xs text-muted">{line.componentName}</div>
</td>
<td className="px-2 py-2 text-muted">{line.quantity}</td>
<td className="px-2 py-2 text-muted">{line.unitOfMeasure}</td>
<td className="px-2 py-2 text-muted">{line.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{(item.type === "ASSEMBLY" || item.type === "MANUFACTURED") ? (
<section 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">Manufacturing Routing</p>
<h4 className="mt-2 text-lg font-bold text-text">Station template</h4>
{item.operations.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 station operations are defined for this buildable item yet.
</div>
) : ( ) : (
<div className="mt-6 overflow-hidden rounded-2xl border border-line/70"> <div className="mt-4 space-y-2">
<table className="min-w-full divide-y divide-line/70 text-sm"> {item.stockBalances.map((balance) => (
<thead className="bg-page/80 text-left text-muted"> <div key={`${balance.warehouseId}-${balance.locationId}`} className="flex items-center justify-between rounded-2xl border border-line/70 bg-page/60 px-2 py-2 text-sm">
<tr> <div className="min-w-0">
<th className="px-2 py-2">Position</th> <div className="font-semibold text-text">
<th className="px-2 py-2">Station</th> {balance.warehouseCode} / {balance.locationCode}
<th className="px-2 py-2">Setup</th> </div>
<th className="px-2 py-2">Run / Unit</th> <div className="text-xs text-muted">
<th className="px-2 py-2">Move</th> {balance.warehouseName} / {balance.locationName}
<th className="px-2 py-2">Notes</th> </div>
</tr> </div>
</thead> <div className="text-right">
<tbody className="divide-y divide-line/70 bg-surface"> <div className="font-semibold text-text">{balance.quantityOnHand} on hand</div>
{item.operations.map((operation) => ( <div className="text-xs text-muted">{balance.quantityReserved} reserved / {balance.quantityAvailable} available</div>
<tr key={operation.id}> </div>
<td className="px-2 py-2 text-muted">{operation.position}</td> </div>
<td className="px-2 py-2"> ))}
<div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td>
<td className="px-2 py-2 text-muted">{operation.setupMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.runMinutesPerUnit} min</td>
<td className="px-2 py-2 text-muted">{operation.moveMinutes} min</td>
<td className="px-2 py-2 text-muted">{operation.notes || "-"}</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
</section> </article>
) : null} </div>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
<section className="grid gap-3 xl:grid-cols-2">
{canManage ? ( {canManage ? (
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransactionSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock Transactions</p>
<h4 className="mt-2 text-lg font-bold text-text">Record movement</h4> <div className="mt-5 grid gap-3">
<p className="mt-2 text-sm text-muted">Post receipts, issues, and adjustments to update on-hand inventory.</p>
<form className="mt-5 space-y-4" onSubmit={handleTransactionSubmit}>
<div className="grid gap-3 xl:grid-cols-2"> <div className="grid gap-3 xl:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span> <span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
<select <select value={transactionForm.transactionType} onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
value={transactionForm.transactionType}
onChange={(event) => updateTransactionField("transactionType", event.target.value as InventoryTransactionInput["transactionType"])}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
>
{inventoryTransactionOptions.map((option) => ( {inventoryTransactionOptions.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
@@ -304,29 +305,18 @@ export function InventoryDetailPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span> <span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input <input type="number" min={1} step={1} value={transactionForm.quantity} onChange={(event) => updateTransactionField("quantity", Number.parseInt(event.target.value, 10) || 0)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
type="number"
min={1}
step={1}
value={transactionForm.quantity}
onChange={(event) => updateTransactionField("quantity", Number.parseInt(event.target.value, 10) || 0)}
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>
</div> </div>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span> <span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select <select value={transactionForm.locationId} onChange={(event) => {
value={transactionForm.locationId} const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
onChange={(event) => { updateTransactionField("locationId", event.target.value);
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value); if (nextLocation) {
updateTransactionField("locationId", event.target.value); updateTransactionField("warehouseId", nextLocation.warehouseId);
if (nextLocation) { }
updateTransactionField("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">
}
}}
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) => ( {locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}> <option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode} {option.warehouseCode} / {option.locationCode}
@@ -336,38 +326,23 @@ export function InventoryDetailPage() {
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Reference</span> <span className="mb-2 block text-sm font-semibold text-text">Reference</span>
<input <input value={transactionForm.reference} onChange={(event) => updateTransactionField("reference", event.target.value)} placeholder="PO, WO, adjustment note, etc." className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
value={transactionForm.reference}
onChange={(event) => updateTransactionField("reference", event.target.value)}
placeholder="PO, WO, adjustment note, etc."
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>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span> <span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea <textarea value={transactionForm.notes} onChange={(event) => updateTransactionField("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" />
value={transactionForm.notes}
onChange={(event) => updateTransactionField("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> </label>
<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"> <div className="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="min-w-0 text-sm text-muted">{transactionStatus}</span> <span className="text-sm text-muted">{transactionStatus}</span>
<button <button type="submit" disabled={isSavingTransaction} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
type="submit"
disabled={isSavingTransaction || 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"
>
{isSavingTransaction ? "Posting..." : "Post transaction"} {isSavingTransaction ? "Posting..." : "Post transaction"}
</button> </button>
</div> </div>
</form> </div>
</article> </form>
) : null} ) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <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">Stock History</p> <p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Recent Movements</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent movements</h4>
{item.recentTransactions.length === 0 ? ( {item.recentTransactions.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"> <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 stock transactions have been recorded for this item yet. No stock transactions have been recorded for this item yet.
@@ -388,9 +363,6 @@ export function InventoryDetailPage() {
<div className="mt-2 text-sm font-semibold text-text"> <div className="mt-2 text-sm font-semibold text-text">
{transaction.warehouseCode} / {transaction.locationCode} {transaction.warehouseCode} / {transaction.locationCode}
</div> </div>
<div className="text-xs text-muted">
{transaction.warehouseName} · {transaction.locationName}
</div>
{transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null} {transaction.reference ? <div className="mt-2 text-xs text-muted">Ref: {transaction.reference}</div> : null}
{transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null} {transaction.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
</div> </div>
@@ -405,6 +377,154 @@ export function InventoryDetailPage() {
)} )}
</article> </article>
</section> </section>
{canManage ? (
<section className="grid gap-3 xl:grid-cols-2">
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleTransferSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Transfer</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={transferForm.quantity} onChange={(event) => updateTransferField("quantity", Number.parseInt(event.target.value, 10) || 1)} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">From</span>
<select value={transferForm.fromLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("fromLocationId", event.target.value);
if (option) {
updateTransferField("fromWarehouseId", option.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={`from-${option.locationId}`} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">To</span>
<select value={transferForm.toLocationId} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
updateTransferField("toLocationId", event.target.value);
if (option) {
updateTransferField("toWarehouseId", option.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={`to-${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={transferForm.notes} onChange={(event) => updateTransferField("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="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{transferStatus}</span>
<button type="submit" disabled={isSavingTransfer} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingTransfer ? "Posting transfer..." : "Post transfer"}
</button>
</div>
</div>
</form>
<form className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5" onSubmit={handleReservationSubmit}>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Manual Reservation</p>
<div className="mt-5 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<input type="number" min={1} step={1} value={reservationForm.quantity} onChange={(event) => setReservationForm((current) => ({ ...current, quantity: Number.parseInt(event.target.value, 10) || 1 }))} 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">Location</span>
<select value={reservationForm.locationId ?? ""} onChange={(event) => {
const option = locationOptions.find((entry) => entry.locationId === event.target.value);
setReservationForm((current) => ({
...current,
locationId: event.target.value || null,
warehouseId: option ? option.warehouseId : null,
}));
}} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Global / not location-specific</option>
{locationOptions.map((option) => (
<option key={option.locationId} value={option.locationId}>
{option.warehouseCode} / {option.locationCode}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<textarea value={reservationForm.notes} onChange={(event) => setReservationForm((current) => ({ ...current, 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="flex flex-col gap-3 rounded-2xl border border-line/70 bg-page/70 px-2 py-2">
<span className="text-sm text-muted">{reservationStatus}</span>
<button type="submit" disabled={isSavingReservation} className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60">
{isSavingReservation ? "Saving reservation..." : "Create reservation"}
</button>
</div>
</div>
</form>
</section>
) : null}
<section className="grid gap-3 xl:grid-cols-2">
<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">Reservations</p>
{item.reservations.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 reservations have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.reservations.map((reservation) => (
<article key={reservation.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-semibold text-text">{reservation.quantity} reserved</div>
<div className="mt-1 text-xs text-muted">{reservation.sourceLabel ?? reservation.sourceType}</div>
</div>
<div className="text-xs text-muted">{reservation.status}</div>
</div>
<div className="mt-2 text-xs text-muted">
{reservation.warehouseCode && reservation.locationCode ? `${reservation.warehouseCode} / ${reservation.locationCode}` : "Not location-specific"}
</div>
<div className="mt-2 text-sm text-text">{reservation.notes || "No notes recorded."}</div>
</article>
))}
</div>
)}
</article>
<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">Transfers</p>
{item.transfers.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 transfers have been recorded for this item.
</div>
) : (
<div className="mt-5 space-y-3">
{item.transfers.map((transfer) => (
<article key={transfer.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-semibold text-text">{transfer.quantity} moved</div>
<div className="text-xs text-muted">{new Date(transfer.createdAt).toLocaleString()}</div>
</div>
<div className="mt-2 text-xs text-muted">
{transfer.fromWarehouseCode} / {transfer.fromLocationCode} to {transfer.toWarehouseCode} / {transfer.toLocationCode}
</div>
<div className="mt-2 text-sm text-text">{transfer.notes || "No notes recorded."}</div>
</article>
))}
</div>
)}
</article>
</section>
<InventoryAttachmentsPanel itemId={item.id} /> <InventoryAttachmentsPanel itemId={item.id} />
</section> </section>
); );

View File

@@ -71,7 +71,7 @@ export function ManufacturingPage() {
<div className="mt-1 text-xs text-muted">{station.description || "No description"}</div> <div className="mt-1 text-xs text-muted">{station.description || "No description"}</div>
</div> </div>
<div className="text-right text-xs text-muted"> <div className="text-right text-xs text-muted">
<div>{station.queueDays} queue day(s)</div> <div>{station.queueDays} expected wait day(s)</div>
<div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div> <div className="mt-1">{station.isActive ? "Active" : "Inactive"}</div>
</div> </div>
</div> </div>
@@ -93,7 +93,7 @@ export function ManufacturingPage() {
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} 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>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Queue Days</span> <span className="mb-2 block text-sm font-semibold text-text">Expected Wait (Days)</span>
<input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" /> <input type="number" min={0} step={1} value={form.queueDays} onChange={(event) => setForm((current) => ({ ...current, queueDays: Number.parseInt(event.target.value, 10) || 0 }))} 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>
<label className="block"> <label className="block">

View File

@@ -0,0 +1,43 @@
CREATE TABLE "InventoryTransfer" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"fromWarehouseId" TEXT NOT NULL,
"fromLocationId" TEXT NOT NULL,
"toWarehouseId" TEXT NOT NULL,
"toLocationId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransfer_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromWarehouseId_fkey" FOREIGN KEY ("fromWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_fromLocationId_fkey" FOREIGN KEY ("fromLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toWarehouseId_fkey" FOREIGN KEY ("toWarehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_toLocationId_fkey" FOREIGN KEY ("toLocationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransfer_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE TABLE "InventoryReservation" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT,
"locationId" TEXT,
"workOrderId" TEXT,
"sourceType" TEXT NOT NULL,
"sourceId" TEXT,
"quantity" INTEGER NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryReservation_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryReservation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "InventoryTransfer_itemId_createdAt_idx" ON "InventoryTransfer"("itemId", "createdAt");
CREATE INDEX "InventoryReservation_itemId_status_createdAt_idx" ON "InventoryReservation"("itemId", "status", "createdAt");
CREATE INDEX "InventoryReservation_warehouseId_locationId_status_idx" ON "InventoryReservation"("warehouseId", "locationId", "status");
CREATE INDEX "InventoryReservation_workOrderId_status_idx" ON "InventoryReservation"("workOrderId", "status");

View File

@@ -28,6 +28,7 @@ model User {
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy") approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy") salesOrderRevisionsCreated SalesOrderRevision[] @relation("SalesOrderRevisionCreatedBy")
inventoryTransfersCreated InventoryTransfer[] @relation("InventoryTransferCreatedBy")
} }
model Role { model Role {
@@ -134,6 +135,8 @@ model InventoryItem {
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
operations InventoryItemOperation[] operations InventoryItemOperation[]
reservations InventoryReservation[]
transfers InventoryTransfer[]
} }
model Warehouse { model Warehouse {
@@ -148,6 +151,9 @@ model Warehouse {
purchaseReceipts PurchaseReceipt[] purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSources InventoryTransfer[] @relation("InventoryTransferFromWarehouse")
transferDestinations InventoryTransfer[] @relation("InventoryTransferToWarehouse")
} }
model Customer { model Customer {
@@ -216,6 +222,9 @@ model WarehouseLocation {
purchaseReceipts PurchaseReceipt[] purchaseReceipts PurchaseReceipt[]
workOrders WorkOrder[] workOrders WorkOrder[]
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
reservations InventoryReservation[]
transferSourceLocations InventoryTransfer[] @relation("InventoryTransferFromLocation")
transferDestinationLocations InventoryTransfer[] @relation("InventoryTransferToLocation")
@@unique([warehouseId, code]) @@unique([warehouseId, code])
@@index([warehouseId]) @@index([warehouseId])
@@ -243,6 +252,51 @@ model InventoryTransaction {
@@index([locationId, createdAt]) @@index([locationId, createdAt])
} }
model InventoryTransfer {
id String @id @default(cuid())
itemId String
fromWarehouseId String
fromLocationId String
toWarehouseId String
toLocationId String
quantity Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
fromWarehouse Warehouse @relation("InventoryTransferFromWarehouse", fields: [fromWarehouseId], references: [id], onDelete: Restrict)
fromLocation WarehouseLocation @relation("InventoryTransferFromLocation", fields: [fromLocationId], references: [id], onDelete: Restrict)
toWarehouse Warehouse @relation("InventoryTransferToWarehouse", fields: [toWarehouseId], references: [id], onDelete: Restrict)
toLocation WarehouseLocation @relation("InventoryTransferToLocation", fields: [toLocationId], references: [id], onDelete: Restrict)
createdBy User? @relation("InventoryTransferCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
}
model InventoryReservation {
id String @id @default(cuid())
itemId String
warehouseId String?
locationId String?
workOrderId String?
sourceType String
sourceId String?
quantity Int
status String @default("ACTIVE")
notes String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
item InventoryItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
warehouse Warehouse? @relation(fields: [warehouseId], references: [id], onDelete: SetNull)
location WarehouseLocation? @relation(fields: [locationId], references: [id], onDelete: SetNull)
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
@@index([itemId, status, createdAt])
@@index([warehouseId, locationId, status])
@@index([workOrderId, status])
}
model Vendor { model Vendor {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -480,6 +534,7 @@ model WorkOrder {
operations WorkOrderOperation[] operations WorkOrderOperation[]
materialIssues WorkOrderMaterialIssue[] materialIssues WorkOrderMaterialIssue[]
completions WorkOrderCompletion[] completions WorkOrderCompletion[]
reservations InventoryReservation[]
@@index([itemId, createdAt]) @@index([itemId, createdAt])
@@index([projectId, dueDate]) @@index([projectId, dueDate])

View File

@@ -7,6 +7,8 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js"; import { requirePermissions } from "../../lib/rbac.js";
import { import {
createInventoryItem, createInventoryItem,
createInventoryReservation,
createInventoryTransfer,
createInventoryTransaction, createInventoryTransaction,
createWarehouse, createWarehouse,
getInventoryItemById, getInventoryItemById,
@@ -67,6 +69,22 @@ const inventoryTransactionSchema = z.object({
notes: z.string(), notes: z.string(),
}); });
const inventoryTransferSchema = z.object({
quantity: z.number().int().positive(),
fromWarehouseId: z.string().trim().min(1),
fromLocationId: z.string().trim().min(1),
toWarehouseId: z.string().trim().min(1),
toLocationId: z.string().trim().min(1),
notes: z.string(),
});
const inventoryReservationSchema = z.object({
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1).nullable(),
locationId: z.string().trim().min(1).nullable(),
notes: z.string(),
});
const warehouseLocationSchema = z.object({ const warehouseLocationSchema = z.object({
code: z.string().trim().min(1).max(64), code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160), name: z.string().trim().min(1).max(160),
@@ -176,6 +194,44 @@ inventoryRouter.post("/items/:itemId/transactions", requirePermissions([permissi
return ok(response, result.item, 201); return ok(response, result.item, 201);
}); });
inventoryRouter.post("/items/:itemId/transfers", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryTransferSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transfer payload is invalid.");
}
const result = await createInventoryTransfer(itemId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.post("/items/:itemId/reservations", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
return fail(response, 400, "INVALID_INPUT", "Inventory item id is invalid.");
}
const parsed = inventoryReservationSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory reservation payload is invalid.");
}
const result = await createInventoryReservation(itemId, parsed.data);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.item, 201);
});
inventoryRouter.get("/warehouses", requirePermissions([permissions.inventoryRead]), async (_request, response) => { inventoryRouter.get("/warehouses", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouses()); return ok(response, await listWarehouses());
}); });

View File

@@ -4,7 +4,12 @@ import type {
InventoryItemDetailDto, InventoryItemDetailDto,
InventoryItemInput, InventoryItemInput,
InventoryItemOperationDto, InventoryItemOperationDto,
InventoryReservationDto,
InventoryReservationInput,
InventoryReservationStatus,
InventoryStockBalanceDto, InventoryStockBalanceDto,
InventoryTransferDto,
InventoryTransferInput,
WarehouseDetailDto, WarehouseDetailDto,
WarehouseInput, WarehouseInput,
WarehouseLocationOptionDto, WarehouseLocationOptionDto,
@@ -67,6 +72,8 @@ type InventoryDetailRecord = {
bomLines: BomLineRecord[]; bomLines: BomLineRecord[];
operations: OperationRecord[]; operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[]; inventoryTransactions: InventoryTransactionRecord[];
reservations: InventoryReservationRecord[];
transfers: InventoryTransferRecord[];
}; };
type InventoryTransactionRecord = { type InventoryTransactionRecord = {
@@ -109,6 +116,61 @@ type WarehouseDetailRecord = {
locations: WarehouseLocationRecord[]; locations: WarehouseLocationRecord[];
}; };
type InventoryReservationRecord = {
id: string;
quantity: number;
status: string;
sourceType: string;
sourceId: string | null;
notes: string;
createdAt: Date;
warehouse: {
id: string;
code: string;
name: string;
} | null;
location: {
id: string;
code: string;
name: string;
} | null;
workOrder: {
id: string;
workOrderNumber: string;
} | null;
};
type InventoryTransferRecord = {
id: string;
quantity: number;
notes: string;
createdAt: Date;
fromWarehouse: {
id: string;
code: string;
name: string;
};
fromLocation: {
id: string;
code: string;
name: string;
};
toWarehouse: {
id: string;
code: string;
name: string;
};
toLocation: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
};
function mapBomLine(record: BomLineRecord): InventoryBomLineDto { function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
return { return {
id: record.id, id: record.id,
@@ -172,7 +234,48 @@ function mapInventoryTransaction(record: InventoryTransactionRecord): InventoryT
}; };
} }
function buildStockBalances(transactions: InventoryTransactionRecord[]): InventoryStockBalanceDto[] { function mapReservation(record: InventoryReservationRecord): InventoryReservationDto {
return {
id: record.id,
quantity: record.quantity,
status: record.status as InventoryReservationStatus,
sourceType: record.sourceType,
sourceId: record.sourceId,
sourceLabel: record.workOrder ? record.workOrder.workOrderNumber : null,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
warehouseId: record.warehouse?.id ?? null,
warehouseCode: record.warehouse?.code ?? null,
warehouseName: record.warehouse?.name ?? null,
locationId: record.location?.id ?? null,
locationCode: record.location?.code ?? null,
locationName: record.location?.name ?? null,
};
}
function mapTransfer(record: InventoryTransferRecord): InventoryTransferDto {
return {
id: record.id,
quantity: record.quantity,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
fromWarehouseId: record.fromWarehouse.id,
fromWarehouseCode: record.fromWarehouse.code,
fromWarehouseName: record.fromWarehouse.name,
fromLocationId: record.fromLocation.id,
fromLocationCode: record.fromLocation.code,
fromLocationName: record.fromLocation.name,
toWarehouseId: record.toWarehouse.id,
toWarehouseCode: record.toWarehouse.code,
toWarehouseName: record.toWarehouse.name,
toLocationId: record.toLocation.id,
toLocationCode: record.toLocation.code,
toLocationName: record.toLocation.name,
};
}
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>(); const grouped = new Map<string, InventoryStockBalanceDto>();
for (const transaction of transactions) { for (const transaction of transactions) {
@@ -194,11 +297,40 @@ function buildStockBalances(transactions: InventoryTransactionRecord[]): Invento
locationCode: transaction.location.code, locationCode: transaction.location.code,
locationName: transaction.location.name, locationName: transaction.location.name,
quantityOnHand: signedQuantity, quantityOnHand: signedQuantity,
quantityReserved: 0,
quantityAvailable: signedQuantity,
});
}
for (const reservation of reservations) {
if (!reservation.warehouse || !reservation.location || reservation.status !== "ACTIVE") {
continue;
}
const key = `${reservation.warehouse.id}:${reservation.location.id}`;
const current = grouped.get(key);
if (current) {
current.quantityReserved += reservation.quantity;
current.quantityAvailable = current.quantityOnHand - current.quantityReserved;
continue;
}
grouped.set(key, {
warehouseId: reservation.warehouse.id,
warehouseCode: reservation.warehouse.code,
warehouseName: reservation.warehouse.name,
locationId: reservation.location.id,
locationCode: reservation.location.code,
locationName: reservation.location.name,
quantityOnHand: 0,
quantityReserved: reservation.quantity,
quantityAvailable: -reservation.quantity,
}); });
} }
return [...grouped.values()] return [...grouped.values()]
.filter((balance) => balance.quantityOnHand !== 0) .filter((balance) => balance.quantityOnHand !== 0 || balance.quantityReserved !== 0)
.sort((left, right) => .sort((left, right) =>
`${left.warehouseCode}-${left.locationCode}`.localeCompare(`${right.warehouseCode}-${right.locationCode}`) `${left.warehouseCode}-${left.locationCode}`.localeCompare(`${right.warehouseCode}-${right.locationCode}`)
); );
@@ -235,7 +367,13 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
.slice() .slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()) .sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapInventoryTransaction); .map(mapInventoryTransaction);
const stockBalances = buildStockBalances(record.inventoryTransactions); const activeReservations = record.reservations.filter((reservation) => reservation.status === "ACTIVE");
const stockBalances = buildStockBalances(record.inventoryTransactions, activeReservations);
const reservedQuantity = activeReservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
const transferHistory = record.transfers
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapTransfer);
return { return {
...mapSummary({ ...mapSummary({
@@ -258,8 +396,12 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine), bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
operations: record.operations.slice().sort((a, b) => a.position - b.position).map(mapOperation), operations: record.operations.slice().sort((a, b) => a.position - b.position).map(mapOperation),
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0), onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
reservedQuantity,
availableQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityAvailable, 0),
stockBalances, stockBalances,
recentTransactions, recentTransactions,
transfers: transferHistory,
reservations: record.reservations.slice().sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()).map(mapReservation),
}; };
} }
@@ -353,6 +495,24 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
.filter((location) => location.code.length > 0 && location.name.length > 0); .filter((location) => location.code.length > 0 && location.name.length > 0);
} }
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
itemId,
warehouseId,
locationId,
},
select: {
transactionType: true,
quantity: true,
},
});
return transactions.reduce((total, transaction) => {
return total + (transaction.transactionType === "RECEIPT" || transaction.transactionType === "ADJUSTMENT_IN" ? transaction.quantity : -transaction.quantity);
}, 0);
}
async function validateBomLines(parentItemId: string | null, bomLines: InventoryBomLineInput[]) { async function validateBomLines(parentItemId: string | null, bomLines: InventoryBomLineInput[]) {
const normalized = normalizeBomLines(bomLines); const normalized = normalizeBomLines(bomLines);
@@ -434,6 +594,22 @@ async function validateOperations(type: InventoryItemType, operations: Inventory
return { ok: true as const, operations: normalized }; return { ok: true as const, operations: normalized };
} }
async function getActiveReservedQuantity(itemId: string, warehouseId: string, locationId: string) {
const reservations = await prisma.inventoryReservation.findMany({
where: {
itemId,
warehouseId,
locationId,
status: "ACTIVE",
},
select: {
quantity: true,
},
});
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
}
export async function listInventoryItems(filters: InventoryListFilters = {}) { export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({ const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters), where: buildWhereClause(filters),
@@ -529,6 +705,70 @@ export async function getInventoryItemById(itemId: string) {
}, },
orderBy: [{ createdAt: "desc" }], orderBy: [{ createdAt: "desc" }],
}, },
reservations: {
include: {
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
workOrder: {
select: {
id: true,
workOrderNumber: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
transfers: {
include: {
fromWarehouse: {
select: {
id: true,
code: true,
name: true,
},
},
fromLocation: {
select: {
id: true,
code: true,
name: true,
},
},
toWarehouse: {
select: {
id: true,
code: true,
name: true,
},
},
toLocation: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
}, },
}); });
@@ -579,14 +819,13 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." }; return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
} }
const detail = await getInventoryItemById(itemId);
if (!detail) {
return { ok: false as const, reason: "Inventory item was not found." };
}
const signedQuantity = getSignedQuantity(payload.transactionType, payload.quantity); const signedQuantity = getSignedQuantity(payload.transactionType, payload.quantity);
if (signedQuantity < 0 && detail.onHandQuantity + signedQuantity < 0) { if (signedQuantity < 0) {
return { ok: false as const, reason: "Transaction would drive on-hand quantity below zero." }; const onHand = await getItemLocationOnHand(itemId, payload.warehouseId, payload.locationId);
const reserved = await getActiveReservedQuantity(itemId, payload.warehouseId, payload.locationId);
if (onHand - reserved + signedQuantity < 0) {
return { ok: false as const, reason: "Transaction would drive available quantity below zero at the selected location." };
}
} }
await prisma.inventoryTransaction.create({ await prisma.inventoryTransaction.create({
@@ -606,6 +845,134 @@ export async function createInventoryTransaction(itemId: string, payload: Invent
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." }; return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
} }
export async function createInventoryTransfer(itemId: string, payload: InventoryTransferInput, createdById?: string | null) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
select: { id: true },
});
if (!item) {
return { ok: false as const, reason: "Inventory item was not found." };
}
const [fromLocation, toLocation] = await Promise.all([
prisma.warehouseLocation.findUnique({
where: { id: payload.fromLocationId },
select: { id: true, warehouseId: true },
}),
prisma.warehouseLocation.findUnique({
where: { id: payload.toLocationId },
select: { id: true, warehouseId: true },
}),
]);
if (!fromLocation || fromLocation.warehouseId !== payload.fromWarehouseId) {
return { ok: false as const, reason: "Source location is invalid for the selected source warehouse." };
}
if (!toLocation || toLocation.warehouseId !== payload.toWarehouseId) {
return { ok: false as const, reason: "Destination location is invalid for the selected destination warehouse." };
}
const onHand = await getItemLocationOnHand(itemId, payload.fromWarehouseId, payload.fromLocationId);
const reserved = await getActiveReservedQuantity(itemId, payload.fromWarehouseId, payload.fromLocationId);
if (onHand - reserved < payload.quantity) {
return { ok: false as const, reason: "Transfer quantity exceeds available stock at the source location." };
}
await prisma.$transaction(async (tx) => {
await tx.inventoryTransfer.create({
data: {
itemId,
fromWarehouseId: payload.fromWarehouseId,
fromLocationId: payload.fromLocationId,
toWarehouseId: payload.toWarehouseId,
toLocationId: payload.toLocationId,
quantity: payload.quantity,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.fromWarehouseId,
locationId: payload.fromLocationId,
transactionType: "ISSUE",
quantity: payload.quantity,
reference: "Inventory transfer out",
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.toWarehouseId,
locationId: payload.toLocationId,
transactionType: "RECEIPT",
quantity: payload.quantity,
reference: "Inventory transfer in",
notes: payload.notes,
createdById: createdById ?? null,
},
});
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryReservation(itemId: string, payload: InventoryReservationInput) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
select: { id: true },
});
if (!item) {
return { ok: false as const, reason: "Inventory item was not found." };
}
if ((payload.warehouseId && !payload.locationId) || (!payload.warehouseId && payload.locationId)) {
return { ok: false as const, reason: "Reservation warehouse and location must be provided together." };
}
if (payload.warehouseId && payload.locationId) {
const location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: { warehouseId: true },
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Reservation location is invalid for the selected warehouse." };
}
const onHand = await getItemLocationOnHand(itemId, payload.warehouseId, payload.locationId);
const reserved = await getActiveReservedQuantity(itemId, payload.warehouseId, payload.locationId);
if (onHand - reserved < payload.quantity) {
return { ok: false as const, reason: "Reservation quantity exceeds available stock at the selected location." };
}
}
await prisma.inventoryReservation.create({
data: {
itemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
sourceType: "MANUAL",
sourceId: null,
quantity: payload.quantity,
status: "ACTIVE",
notes: payload.notes,
},
});
const nextDetail = await getInventoryItemById(itemId);
return nextDetail ? { ok: true as const, item: nextDetail } : { ok: false as const, reason: "Unable to load updated inventory item." };
}
export async function createInventoryItem(payload: InventoryItemInput) { export async function createInventoryItem(payload: InventoryItemInput) {
const validatedBom = await validateBomLines(null, payload.bomLines); const validatedBom = await validateBomLines(null, payload.bomLines);
if (!validatedBom.ok) { if (!validatedBom.ok) {

View File

@@ -366,6 +366,10 @@ function addMinutes(value: Date, minutes: number) {
return new Date(value.getTime() + minutes * 60 * 1000); return new Date(value.getTime() + minutes * 60 * 1000);
} }
function shouldReserveForStatus(status: string) {
return status === "RELEASED" || status === "IN_PROGRESS" || status === "ON_HOLD";
}
function buildWorkOrderOperationPlan( function buildWorkOrderOperationPlan(
itemOperations: WorkOrderRecord["item"]["operations"], itemOperations: WorkOrderRecord["item"]["operations"],
quantity: number, quantity: number,
@@ -463,6 +467,46 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
}); });
} }
async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return;
}
await prisma.inventoryReservation.deleteMany({
where: {
workOrderId,
sourceType: "WORK_ORDER",
},
});
if (!shouldReserveForStatus(workOrder.status)) {
return;
}
const reservations = workOrder.materialRequirements
.filter((requirement) => requirement.remainingQuantity > 0)
.map((requirement) => ({
itemId: requirement.componentItemId,
warehouseId: workOrder.warehouseId,
locationId: workOrder.locationId,
workOrderId,
sourceType: "WORK_ORDER",
sourceId: workOrderId,
quantity: requirement.remainingQuantity,
status: "ACTIVE",
notes: `${workOrder.workOrderNumber} component demand`,
}));
if (reservations.length === 0) {
return;
}
await prisma.inventoryReservation.createMany({
data: reservations,
});
}
async function nextWorkOrderNumber() { async function nextWorkOrderNumber() {
const next = (await workOrderModel.count()) + 1; const next = (await workOrderModel.count()) + 1;
return `WO-${String(next).padStart(5, "0")}`; return `WO-${String(next).padStart(5, "0")}`;
@@ -696,6 +740,7 @@ export async function createWorkOrder(payload: WorkOrderInput) {
}); });
await regenerateWorkOrderOperations(created.id); await regenerateWorkOrderOperations(created.id);
await syncWorkOrderReservations(created.id);
const workOrder = await getWorkOrderById(created.id); const workOrder = await getWorkOrderById(created.id);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." }; return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
@@ -738,6 +783,7 @@ export async function updateWorkOrder(workOrderId: string, payload: WorkOrderInp
}); });
await regenerateWorkOrderOperations(workOrderId); await regenerateWorkOrderOperations(workOrderId);
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId); const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." }; return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
@@ -773,6 +819,8 @@ export async function updateWorkOrderStatus(workOrderId: string, status: WorkOrd
}, },
}); });
await syncWorkOrderReservations(workOrderId);
const workOrder = await getWorkOrderById(workOrderId); const workOrder = await getWorkOrderById(workOrderId);
return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." }; return workOrder ? { ok: true as const, workOrder } : { ok: false as const, reason: "Unable to load saved work order." };
} }
@@ -859,6 +907,8 @@ export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkO
}); });
}); });
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId); const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." }; return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
} }
@@ -917,6 +967,8 @@ export async function recordWorkOrderCompletion(workOrderId: string, payload: Wo
}); });
}); });
await syncWorkOrderReservations(workOrderId);
const nextWorkOrder = await getWorkOrderById(workOrderId); const nextWorkOrder = await getWorkOrderById(workOrderId);
return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." }; return nextWorkOrder ? { ok: true as const, workOrder: nextWorkOrder } : { ok: false as const, reason: "Unable to load updated work order." };
} }

View File

@@ -2,11 +2,13 @@ export const inventoryItemTypes = ["PURCHASED", "MANUFACTURED", "ASSEMBLY", "SER
export const inventoryItemStatuses = ["DRAFT", "ACTIVE", "OBSOLETE"] as const; export const inventoryItemStatuses = ["DRAFT", "ACTIVE", "OBSOLETE"] as const;
export const inventoryUnitsOfMeasure = ["EA", "FT", "IN", "LB", "KG", "SET"] as const; export const inventoryUnitsOfMeasure = ["EA", "FT", "IN", "LB", "KG", "SET"] as const;
export const inventoryTransactionTypes = ["RECEIPT", "ISSUE", "ADJUSTMENT_IN", "ADJUSTMENT_OUT"] as const; export const inventoryTransactionTypes = ["RECEIPT", "ISSUE", "ADJUSTMENT_IN", "ADJUSTMENT_OUT"] as const;
export const inventoryReservationStatuses = ["ACTIVE", "RELEASED", "CONSUMED"] as const;
export type InventoryItemType = (typeof inventoryItemTypes)[number]; export type InventoryItemType = (typeof inventoryItemTypes)[number];
export type InventoryItemStatus = (typeof inventoryItemStatuses)[number]; export type InventoryItemStatus = (typeof inventoryItemStatuses)[number];
export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number]; export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number];
export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number]; export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number];
export type InventoryReservationStatus = (typeof inventoryReservationStatuses)[number];
export interface InventoryBomLineDto { export interface InventoryBomLineDto {
id: string; id: string;
@@ -121,6 +123,8 @@ export interface InventoryStockBalanceDto {
locationCode: string; locationCode: string;
locationName: string; locationName: string;
quantityOnHand: number; quantityOnHand: number;
quantityReserved: number;
quantityAvailable: number;
} }
export interface InventoryTransactionDto { export interface InventoryTransactionDto {
@@ -149,6 +153,59 @@ export interface InventoryTransactionInput {
notes: string; notes: string;
} }
export interface InventoryTransferDto {
id: string;
quantity: number;
notes: string;
createdAt: string;
createdByName: string;
fromWarehouseId: string;
fromWarehouseCode: string;
fromWarehouseName: string;
fromLocationId: string;
fromLocationCode: string;
fromLocationName: string;
toWarehouseId: string;
toWarehouseCode: string;
toWarehouseName: string;
toLocationId: string;
toLocationCode: string;
toLocationName: string;
}
export interface InventoryTransferInput {
quantity: number;
fromWarehouseId: string;
fromLocationId: string;
toWarehouseId: string;
toLocationId: string;
notes: string;
}
export interface InventoryReservationDto {
id: string;
quantity: number;
status: InventoryReservationStatus;
sourceType: string;
sourceId: string | null;
sourceLabel: string | null;
notes: string;
createdAt: string;
warehouseId: string | null;
warehouseCode: string | null;
warehouseName: string | null;
locationId: string | null;
locationCode: string | null;
locationName: string | null;
}
export interface InventoryReservationInput {
quantity: number;
warehouseId: string | null;
locationId: string | null;
notes: string;
}
export interface InventoryItemDetailDto extends InventoryItemSummaryDto { export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
description: string; description: string;
defaultCost: number | null; defaultCost: number | null;
@@ -158,8 +215,12 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
bomLines: InventoryBomLineDto[]; bomLines: InventoryBomLineDto[];
operations: InventoryItemOperationDto[]; operations: InventoryItemOperationDto[];
onHandQuantity: number; onHandQuantity: number;
reservedQuantity: number;
availableQuantity: number;
stockBalances: InventoryStockBalanceDto[]; stockBalances: InventoryStockBalanceDto[];
recentTransactions: InventoryTransactionDto[]; recentTransactions: InventoryTransactionDto[];
transfers: InventoryTransferDto[];
reservations: InventoryReservationDto[];
} }
export interface InventoryItemInput { export interface InventoryItemInput {