inventory

This commit is contained in:
2026-03-14 22:37:09 -05:00
parent 6589581908
commit 10b47da724
14 changed files with 651 additions and 43 deletions

View File

@@ -8,6 +8,8 @@ This repository implements the platform foundation milestone:
- local auth and RBAC
- company settings and branding
- file attachment storage
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
- inventory master data, BOM, warehouse, and stock-location foundation
- Dockerized single-container deployment
- Puppeteer PDF pipeline foundation
@@ -19,6 +21,7 @@ This repository implements the platform foundation milestone:
4. Keep uploaded files on disk under `/app/data/uploads`; never store blobs in SQLite.
5. Reuse shared DTOs and permission keys from the `shared` package.
6. Any UI that looks up items by SKU or item name must use a searchable picker/autocomplete, not a long dropdown.
7. Maintain the denser UI baseline on active screens; avoid reintroducing oversized `px-4 py-3` style controls, tall action bars, or overly loose card spacing without a specific reason.
## Operational notes
@@ -32,8 +35,8 @@ This repository implements the platform foundation milestone:
## Next roadmap candidates
- CRM entity detail pages and search
- inventory and BOM management
- inventory transactions and on-hand tracking
- BOM/item drawing attachments and support documents
- sales orders, purchase orders, and document templates
- shipping workflows and printable logistics documents
- manufacturing gantt scheduling with live project data

View File

@@ -85,6 +85,7 @@ The current inventory foundation supports:
- SKU, description, type, status, unit-of-measure, sellable/purchasable, default cost, and notes fields
- BOM header and BOM line editing directly on the item form
- searchable component lookup for BOM lines, designed for large item catalogs
- SKU-first searchable component lookup for BOM lines, with SKU shown in the picker and description kept separate in the selected row
- BOM detail display with component SKU, name, quantity, unit, notes, and position
- protected warehouse list, detail, create, and edit flows
- nested stock-location management inside each warehouse record
@@ -95,6 +96,16 @@ This module introduces `inventory.read` and `inventory.write` permissions. After
Moving forward, any UI that requires searching for an item by SKU or item name should use a searchable picker/autocomplete rather than a static dropdown.
## UI Density
The active client screens have been normalized toward a denser workspace layout:
- form controls and action bars use tighter padding
- CRM, inventory, warehouse, settings, dashboard, and login screens use reduced card spacing
- headings, metric cards, empty states, and long-text blocks were tightened for better data density
This denser layout is now the baseline for future screens. New pages should avoid reverting to oversized card padding, tall action bars, or long static dropdowns for operational datasets.
## Branding
Brand colors and typography are configured through the Company Settings page and the frontend theme token layer. Update runtime branding in-app, or adjust defaults in the theme config if you need a new baseline brand.
@@ -121,6 +132,7 @@ As of March 14, 2026, the latest committed domain migrations include:
- Dark mode persistence is handled through the frontend theme provider and should remain stable across page navigation.
- The shell layout is tuned for wider desktop use than the original foundation build, and now exposes CRM, inventory, settings, and planning modules from the same app shell.
- The active module screens now follow a tighter density baseline for forms, tables, and detail cards.
- The client build still emits a Vite chunk-size warning because the app has not been code-split yet.
## PDF Generation

View File

@@ -25,7 +25,10 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
- CRM shared file attachments on customer and vendor records, including delete support
- CRM reseller hierarchy, parent-child customer structure, and reseller discount support
- CRM multi-contact records, commercial terms, lifecycle stages, operational flags, and activity rollups
- Inventory item master, BOM, warehouse, and stock-location foundation
- SKU-searchable BOM component selection for inventory-scale datasets
- Theme persistence fixes and denser responsive workspace layouts
- Full-site density normalization pass across active CRM, inventory, settings, dashboard, and login screens
- SVAR Gantt integration wrapper with demo planning data
- Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow
@@ -36,6 +39,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
- Inventory currently covers master data and warehouse structure, but not stock movement, on-hand balances, or transaction history yet
## Planned feature phases
@@ -97,8 +101,8 @@ MRP Codex is being built as a streamlined, modular manufacturing resource planni
## Near-term priority order
1. Inventory item and BOM data model
2. Sales order and quote foundation
3. Shipping module tied to sales orders
4. Live manufacturing gantt scheduling
5. Expanded role and audit administration
1. Inventory transactions and on-hand tracking
2. BOM and item attachments for drawings and support docs
3. Sales order and quote foundation
4. Shipping module tied to sales orders
5. Live manufacturing gantt scheduling

View File

@@ -16,6 +16,7 @@
- Theme state and brand tokens belong in `src/theme`.
- PDF screen components must remain separate from API-rendered document templates.
- Any item/SKU lookup UI must be implemented as a searchable picker or autocomplete; do not use long static dropdowns for inventory-scale datasets.
- Preserve the current dense operations UI style on active module pages: compact controls, tighter card padding, and shorter empty states unless a screen has a clear reason to be more spacious.
## Backend rules

View File

@@ -26,9 +26,11 @@ import type {
InventoryItemOptionDto,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransactionInput,
InventoryItemType,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
WarehouseSummaryDto,
} from "@mrp/shared/dist/inventory/types.js";
@@ -298,6 +300,9 @@ export const api = {
getInventoryItemOptions(token: string) {
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
},
getWarehouseLocationOptions(token: string) {
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
},
createInventoryItem(token: string, payload: InventoryItemInput) {
return request<InventoryItemDetailDto>(
"/api/v1/inventory/items",
@@ -318,6 +323,16 @@ export const api = {
token
);
},
createInventoryTransaction(token: string, itemId: string, payload: InventoryTransactionInput) {
return request<InventoryItemDetailDto>(
`/api/v1/inventory/items/${itemId}/transactions`,
{
method: "POST",
body: JSON.stringify(payload),
},
token
);
},
getWarehouses(token: string) {
return request<WarehouseSummaryDto[]>("/api/v1/inventory/warehouses", undefined, token);
},

View File

@@ -1,17 +1,23 @@
import type { InventoryItemDetailDto } from "@mrp/shared/dist/inventory/types.js";
import type { InventoryItemDetailDto, InventoryTransactionInput, WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { permissions } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../../auth/AuthProvider";
import { api, ApiError } from "../../lib/api";
import { emptyInventoryTransactionInput, inventoryTransactionOptions } from "./config";
import { InventoryStatusBadge } from "./InventoryStatusBadge";
import { InventoryTransactionTypeBadge } from "./InventoryTransactionTypeBadge";
import { InventoryTypeBadge } from "./InventoryTypeBadge";
export function InventoryDetailPage() {
const { token, user } = useAuth();
const { itemId } = useParams();
const [item, setItem] = useState<InventoryItemDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [transactionForm, setTransactionForm] = useState<InventoryTransactionInput>(emptyInventoryTransactionInput);
const [transactionStatus, setTransactionStatus] = useState("Record receipts, issues, and adjustments against this item.");
const [isSavingTransaction, setIsSavingTransaction] = useState(false);
const [status, setStatus] = useState("Loading inventory item...");
const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
@@ -31,8 +37,60 @@ export function InventoryDetailPage() {
const message = error instanceof ApiError ? error.message : "Unable to load inventory item.";
setStatus(message);
});
api
.getWarehouseLocationOptions(token)
.then((options) => {
setLocationOptions(options);
setTransactionForm((current) => {
if (current.locationId) {
return current;
}
const firstOption = options[0];
return firstOption
? {
...current,
warehouseId: firstOption.warehouseId,
locationId: firstOption.locationId,
}
: current;
});
})
.catch(() => setLocationOptions([]));
}, [itemId, token]);
function updateTransactionField<Key extends keyof InventoryTransactionInput>(key: Key, value: InventoryTransactionInput[Key]) {
setTransactionForm((current) => ({ ...current, [key]: value }));
}
async function handleTransactionSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!token || !itemId) {
return;
}
setIsSavingTransaction(true);
setTransactionStatus("Saving stock transaction...");
try {
const nextItem = await api.createInventoryTransaction(token, itemId, transactionForm);
setItem(nextItem);
setTransactionStatus("Stock transaction recorded.");
setTransactionForm((current) => ({
...emptyInventoryTransactionInput,
transactionType: current.transactionType,
warehouseId: current.warehouseId,
locationId: current.locationId,
}));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to save stock transaction.";
setTransactionStatus(message);
} finally {
setIsSavingTransaction(false);
}
}
if (!item) {
return <div className="rounded-[28px] border border-line/70 bg-surface/90 p-4 text-sm text-muted shadow-panel">{status}</div>;
}
@@ -63,6 +121,24 @@ export function InventoryDetailPage() {
</div>
</div>
</div>
<section className="grid gap-3 xl:grid-cols-4">
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">On Hand</p>
<div className="mt-2 text-base font-bold text-text">{item.onHandQuantity}</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">Stock Locations</p>
<div className="mt-2 text-base font-bold text-text">{item.stockBalances.length}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Transactions</p>
<div className="mt-2 text-base font-bold text-text">{item.recentTransactions.length}</div>
</article>
<article className="rounded-[24px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">BOM Lines</p>
<div className="mt-2 text-base font-bold text-text">{item.bomLines.length}</div>
</article>
</section>
<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">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Item Definition</p>
@@ -93,6 +169,28 @@ export function InventoryDetailPage() {
<div className="mt-8 rounded-2xl border border-line/70 bg-page/70 px-2 py-2 text-sm text-muted">
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">
@@ -132,6 +230,131 @@ export function InventoryDetailPage() {
</div>
)}
</section>
<section className="grid gap-3 2xl:grid-cols-[minmax(360px,0.82fr)_minmax(0,1.18fr)]">
{canManage ? (
<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 Transactions</p>
<h4 className="mt-2 text-lg font-bold text-text">Record movement</h4>
<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">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Transaction type</span>
<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"
>
{inventoryTransactionOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Quantity</span>
<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"
/>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Stock location</span>
<select
value={transactionForm.locationId}
onChange={(event) => {
const nextLocation = locationOptions.find((option) => option.locationId === event.target.value);
updateTransactionField("locationId", event.target.value);
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"
>
{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">Reference</span>
<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"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">Notes</span>
<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"
/>
</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">
<span className="min-w-0 text-sm text-muted">{transactionStatus}</span>
<button
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"}
</button>
</div>
</form>
</article>
) : null}
<article className="rounded-[28px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Stock History</p>
<h4 className="mt-2 text-lg font-bold text-text">Recent movements</h4>
{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">
No stock transactions have been recorded for this item yet.
</div>
) : (
<div className="mt-6 space-y-3">
{item.recentTransactions.map((transaction) => (
<article key={transaction.id} className="rounded-3xl border border-line/70 bg-page/60 p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="flex flex-wrap items-center gap-2">
<InventoryTransactionTypeBadge type={transaction.transactionType} />
<span className={`text-sm font-semibold ${transaction.signedQuantity >= 0 ? "text-emerald-700 dark:text-emerald-300" : "text-rose-700 dark:text-rose-300"}`}>
{transaction.signedQuantity >= 0 ? "+" : ""}
{transaction.signedQuantity}
</span>
</div>
<div className="mt-2 text-sm font-semibold text-text">
{transaction.warehouseCode} / {transaction.locationCode}
</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.notes ? <p className="mt-2 whitespace-pre-line text-sm leading-6 text-text">{transaction.notes}</p> : null}
</div>
<div className="text-sm text-muted lg:text-right">
<div>{new Date(transaction.createdAt).toLocaleString()}</div>
<div className="mt-1">{transaction.createdByName}</div>
</div>
</div>
</article>
))}
</div>
)}
</article>
</section>
</section>
);
}

View File

@@ -374,10 +374,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Qty</span>
<input
type="number"
min={0.0001}
step={0.0001}
min={1}
step={1}
value={line.quantity}
onChange={(event) => updateBomLine(index, { ...line, quantity: Number(event.target.value) })}
onChange={(event) => updateBomLine(index, { ...line, quantity: Number.parseInt(event.target.value, 10) || 0 })}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</label>

View File

@@ -0,0 +1,13 @@
import type { InventoryTransactionType } from "@mrp/shared/dist/inventory/types.js";
import { inventoryTransactionOptions, inventoryTransactionPalette } from "./config";
export function InventoryTransactionTypeBadge({ type }: { type: InventoryTransactionType }) {
const label = inventoryTransactionOptions.find((option) => option.value === type)?.label ?? type;
return (
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold uppercase tracking-[0.12em] ${inventoryTransactionPalette[type]}`}>
{label}
</span>
);
}

View File

@@ -1,6 +1,7 @@
import {
inventoryItemStatuses,
inventoryItemTypes,
inventoryTransactionTypes,
inventoryUnitsOfMeasure,
type InventoryBomLineInput,
type InventoryItemInput,
@@ -8,6 +9,8 @@ import {
type WarehouseLocationInput,
type InventoryItemStatus,
type InventoryItemType,
type InventoryTransactionInput,
type InventoryTransactionType,
type InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
@@ -33,6 +36,15 @@ export const emptyInventoryItemInput: InventoryItemInput = {
bomLines: [],
};
export const emptyInventoryTransactionInput: InventoryTransactionInput = {
transactionType: "RECEIPT",
quantity: 1,
warehouseId: "",
locationId: "",
reference: "",
notes: "",
};
export const emptyWarehouseLocationInput: WarehouseLocationInput = {
code: "",
name: "",
@@ -87,4 +99,18 @@ export const inventoryTypePalette: Record<InventoryItemType, string> = {
SERVICE: "border border-violet-400/30 bg-violet-500/12 text-violet-700 dark:text-violet-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure };
export const inventoryTransactionOptions: Array<{ value: InventoryTransactionType; label: string }> = [
{ value: "RECEIPT", label: "Receipt" },
{ value: "ISSUE", label: "Issue" },
{ value: "ADJUSTMENT_IN", label: "Adjustment In" },
{ value: "ADJUSTMENT_OUT", label: "Adjustment Out" },
];
export const inventoryTransactionPalette: Record<InventoryTransactionType, string> = {
RECEIPT: "border border-emerald-400/30 bg-emerald-500/12 text-emerald-700 dark:text-emerald-300",
ISSUE: "border border-rose-400/30 bg-rose-500/12 text-rose-700 dark:text-rose-300",
ADJUSTMENT_IN: "border border-sky-400/30 bg-sky-500/12 text-sky-700 dark:text-sky-300",
ADJUSTMENT_OUT: "border border-amber-400/30 bg-amber-500/12 text-amber-700 dark:text-amber-300",
};
export { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure };

View File

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "InventoryTransaction" (
"id" TEXT NOT NULL PRIMARY KEY,
"itemId" TEXT NOT NULL,
"warehouseId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"transactionType" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"reference" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryTransaction_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "InventoryItem" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_warehouseId_fkey" FOREIGN KEY ("warehouseId") REFERENCES "Warehouse" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "WarehouseLocation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "InventoryTransaction_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "InventoryTransaction_itemId_createdAt_idx" ON "InventoryTransaction"("itemId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_warehouseId_createdAt_idx" ON "InventoryTransaction"("warehouseId", "createdAt");
-- CreateIndex
CREATE INDEX "InventoryTransaction_locationId_createdAt_idx" ON "InventoryTransaction"("locationId", "createdAt");

View File

@@ -19,6 +19,7 @@ model User {
updatedAt DateTime @updatedAt
userRoles UserRole[]
contactEntries CrmContactEntry[]
inventoryTransactions InventoryTransaction[]
}
model Role {
@@ -117,6 +118,7 @@ model InventoryItem {
updatedAt DateTime @updatedAt
bomLines InventoryBomLine[] @relation("InventoryBomParent")
usedInBomLines InventoryBomLine[] @relation("InventoryBomComponent")
inventoryTransactions InventoryTransaction[]
}
model Warehouse {
@@ -127,6 +129,7 @@ model Warehouse {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
locations WarehouseLocation[]
inventoryTransactions InventoryTransaction[]
}
model Customer {
@@ -188,11 +191,34 @@ model WarehouseLocation {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
warehouse Warehouse @relation(fields: [warehouseId], references: [id], onDelete: Cascade)
inventoryTransactions InventoryTransaction[]
@@unique([warehouseId, code])
@@index([warehouseId])
}
model InventoryTransaction {
id String @id @default(cuid())
itemId String
warehouseId String
locationId String
transactionType String
quantity Int
reference String
notes String
createdById 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: Restrict)
location WarehouseLocation @relation(fields: [locationId], references: [id], onDelete: Restrict)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([itemId, createdAt])
@@index([warehouseId, createdAt])
@@index([locationId, createdAt])
}
model Vendor {
id String @id @default(cuid())
name String

View File

@@ -1,5 +1,5 @@
import { permissions } from "@mrp/shared";
import { inventoryItemStatuses, inventoryItemTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { inventoryItemStatuses, inventoryItemTypes, inventoryTransactionTypes, inventoryUnitsOfMeasure } from "@mrp/shared/dist/inventory/types.js";
import { Router } from "express";
import { z } from "zod";
@@ -7,11 +7,13 @@ import { fail, ok } from "../../lib/http.js";
import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
getWarehouseById,
listInventoryItemOptions,
listInventoryItems,
listWarehouseLocationOptions,
listWarehouses,
updateInventoryItem,
updateWarehouse,
@@ -19,7 +21,7 @@ import {
const bomLineSchema = z.object({
componentItemId: z.string().trim().min(1),
quantity: z.number().positive(),
quantity: z.number().int().positive(),
unitOfMeasure: z.enum(inventoryUnitsOfMeasure),
notes: z.string(),
position: z.number().int().nonnegative(),
@@ -45,6 +47,15 @@ const inventoryListQuerySchema = z.object({
type: z.enum(inventoryItemTypes).optional(),
});
const inventoryTransactionSchema = z.object({
transactionType: z.enum(inventoryTransactionTypes),
quantity: z.number().int().positive(),
warehouseId: z.string().trim().min(1),
locationId: z.string().trim().min(1),
reference: z.string().max(120),
notes: z.string(),
});
const warehouseLocationSchema = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(160),
@@ -84,6 +95,10 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
return ok(response, await listInventoryItemOptions());
});
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions());
});
inventoryRouter.get("/items/:itemId", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
@@ -131,6 +146,25 @@ inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryW
return ok(response, item);
});
inventoryRouter.post("/items/:itemId/transactions", 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 = inventoryTransactionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Inventory transaction payload is invalid.");
}
const result = await createInventoryTransaction(itemId, parsed.data, request.authUser?.id);
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) => {
return ok(response, await listWarehouses());
});

View File

@@ -3,13 +3,18 @@ import type {
InventoryBomLineInput,
InventoryItemDetailDto,
InventoryItemInput,
InventoryStockBalanceDto,
WarehouseDetailDto,
WarehouseInput,
WarehouseLocationOptionDto,
WarehouseLocationDto,
WarehouseLocationInput,
WarehouseSummaryDto,
InventoryItemStatus,
InventoryItemSummaryDto,
InventoryTransactionDto,
InventoryTransactionInput,
InventoryTransactionType,
InventoryItemType,
InventoryUnitOfMeasure,
} from "@mrp/shared/dist/inventory/types.js";
@@ -44,6 +49,30 @@ type InventoryDetailRecord = {
createdAt: Date;
updatedAt: Date;
bomLines: BomLineRecord[];
inventoryTransactions: InventoryTransactionRecord[];
};
type InventoryTransactionRecord = {
id: string;
transactionType: string;
quantity: number;
reference: string;
notes: string;
createdAt: Date;
warehouse: {
id: string;
code: string;
name: string;
};
location: {
id: string;
code: string;
name: string;
};
createdBy: {
firstName: string;
lastName: string;
} | null;
};
type WarehouseLocationRecord = {
@@ -85,6 +114,64 @@ function mapWarehouseLocation(record: WarehouseLocationRecord): WarehouseLocatio
};
}
function getSignedQuantity(transactionType: InventoryTransactionType, quantity: number) {
return transactionType === "RECEIPT" || transactionType === "ADJUSTMENT_IN" ? quantity : -quantity;
}
function mapInventoryTransaction(record: InventoryTransactionRecord): InventoryTransactionDto {
const transactionType = record.transactionType as InventoryTransactionType;
const signedQuantity = getSignedQuantity(transactionType, record.quantity);
return {
id: record.id,
transactionType,
quantity: record.quantity,
signedQuantity,
notes: record.notes,
reference: record.reference,
createdAt: record.createdAt.toISOString(),
warehouseId: record.warehouse.id,
warehouseCode: record.warehouse.code,
warehouseName: record.warehouse.name,
locationId: record.location.id,
locationCode: record.location.code,
locationName: record.location.name,
createdByName: record.createdBy ? `${record.createdBy.firstName} ${record.createdBy.lastName}`.trim() : "System",
};
}
function buildStockBalances(transactions: InventoryTransactionRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>();
for (const transaction of transactions) {
const transactionType = transaction.transactionType as InventoryTransactionType;
const signedQuantity = getSignedQuantity(transactionType, transaction.quantity);
const key = `${transaction.warehouse.id}:${transaction.location.id}`;
const current = grouped.get(key);
if (current) {
current.quantityOnHand += signedQuantity;
continue;
}
grouped.set(key, {
warehouseId: transaction.warehouse.id,
warehouseCode: transaction.warehouse.code,
warehouseName: transaction.warehouse.name,
locationId: transaction.location.id,
locationCode: transaction.location.code,
locationName: transaction.location.name,
quantityOnHand: signedQuantity,
});
}
return [...grouped.values()]
.filter((balance) => balance.quantityOnHand !== 0)
.sort((left, right) =>
`${left.warehouseCode}-${left.locationCode}`.localeCompare(`${right.warehouseCode}-${right.locationCode}`)
);
}
function mapSummary(record: {
id: string;
sku: string;
@@ -112,6 +199,12 @@ function mapSummary(record: {
}
function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
const recentTransactions = record.inventoryTransactions
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
.map(mapInventoryTransaction);
const stockBalances = buildStockBalances(record.inventoryTransactions);
return {
...mapSummary({
id: record.id,
@@ -130,6 +223,9 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
onHandQuantity: stockBalances.reduce((sum, balance) => sum + balance.quantityOnHand, 0),
stockBalances,
recentTransactions,
};
}
@@ -192,7 +288,7 @@ function normalizeBomLines(bomLines: InventoryBomLineInput[]) {
return bomLines
.map((line, index) => ({
componentItemId: line.componentItemId,
quantity: line.quantity,
quantity: Number(line.quantity),
unitOfMeasure: line.unitOfMeasure,
notes: line.notes,
position: line.position ?? (index + 1) * 10,
@@ -217,6 +313,10 @@ async function validateBomLines(parentItemId: string | null, bomLines: Inventory
return { ok: false as const, reason: "BOM line quantity must be greater than zero." };
}
if (normalized.some((line) => !Number.isInteger(line.quantity))) {
return { ok: false as const, reason: "BOM line quantity must be a whole number." };
}
if (parentItemId && normalized.some((line) => line.componentItemId === parentItemId)) {
return { ok: false as const, reason: "An item cannot reference itself in its BOM." };
}
@@ -298,12 +398,108 @@ export async function getInventoryItemById(itemId: string) {
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
inventoryTransactions: {
include: {
warehouse: {
select: {
id: true,
code: true,
name: true,
},
},
location: {
select: {
id: true,
code: true,
name: true,
},
},
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
},
});
return item ? mapDetail(item) : null;
}
export async function listWarehouseLocationOptions() {
const warehouses = await prisma.warehouse.findMany({
include: {
locations: {
orderBy: [{ code: "asc" }],
},
},
orderBy: [{ code: "asc" }],
});
return warehouses.flatMap((warehouse): WarehouseLocationOptionDto[] =>
warehouse.locations.map((location) => ({
warehouseId: warehouse.id,
warehouseCode: warehouse.code,
warehouseName: warehouse.name,
locationId: location.id,
locationCode: location.code,
locationName: location.name,
}))
);
}
export async function createInventoryTransaction(itemId: string, payload: InventoryTransactionInput, 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 location = await prisma.warehouseLocation.findUnique({
where: { id: payload.locationId },
select: {
id: true,
warehouseId: true,
},
});
if (!location || location.warehouseId !== payload.warehouseId) {
return { ok: false as const, reason: "Warehouse location is invalid for the selected warehouse." };
}
const detail = await getInventoryItemById(itemId);
if (!detail) {
return { ok: false as const, reason: "Inventory item was not found." };
}
const signedQuantity = getSignedQuantity(payload.transactionType, payload.quantity);
if (signedQuantity < 0 && detail.onHandQuantity + signedQuantity < 0) {
return { ok: false as const, reason: "Transaction would drive on-hand quantity below zero." };
}
await prisma.inventoryTransaction.create({
data: {
itemId,
warehouseId: payload.warehouseId,
locationId: payload.locationId,
transactionType: payload.transactionType,
quantity: payload.quantity,
reference: payload.reference.trim(),
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 createInventoryItem(payload: InventoryItemInput) {
const validatedBom = await validateBomLines(null, payload.bomLines);
if (!validatedBom.ok) {
@@ -328,23 +524,12 @@ export async function createInventoryItem(payload: InventoryItemInput) {
}
: undefined,
},
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
return mapDetail(item);
return getInventoryItemById(item.id);
}
export async function updateInventoryItem(itemId: string, payload: InventoryItemInput) {
@@ -379,23 +564,12 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
create: validatedBom.bomLines,
},
},
include: {
bomLines: {
include: {
componentItem: {
select: {
id: true,
sku: true,
name: true,
},
},
},
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
},
},
});
return mapDetail(item);
return getInventoryItemById(item.id);
}
export async function listWarehouses() {

View File

@@ -1,10 +1,12 @@
export const inventoryItemTypes = ["PURCHASED", "MANUFACTURED", "ASSEMBLY", "SERVICE"] as const;
export const inventoryItemStatuses = ["DRAFT", "ACTIVE", "OBSOLETE"] 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 type InventoryItemType = (typeof inventoryItemTypes)[number];
export type InventoryItemStatus = (typeof inventoryItemStatuses)[number];
export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number];
export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number];
export interface InventoryBomLineDto {
id: string;
@@ -31,6 +33,15 @@ export interface InventoryItemOptionDto {
name: string;
}
export interface WarehouseLocationOptionDto {
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
}
export interface WarehouseLocationDto {
id: string;
code: string;
@@ -78,12 +89,51 @@ export interface InventoryItemSummaryDto {
updatedAt: string;
}
export interface InventoryStockBalanceDto {
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
quantityOnHand: number;
}
export interface InventoryTransactionDto {
id: string;
transactionType: InventoryTransactionType;
quantity: number;
signedQuantity: number;
notes: string;
reference: string;
createdAt: string;
warehouseId: string;
warehouseCode: string;
warehouseName: string;
locationId: string;
locationCode: string;
locationName: string;
createdByName: string;
}
export interface InventoryTransactionInput {
transactionType: InventoryTransactionType;
quantity: number;
warehouseId: string;
locationId: string;
reference: string;
notes: string;
}
export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
description: string;
defaultCost: number | null;
notes: string;
createdAt: string;
bomLines: InventoryBomLineDto[];
onHandQuantity: number;
stockBalances: InventoryStockBalanceDto[];
recentTransactions: InventoryTransactionDto[];
}
export interface InventoryItemInput {