diff --git a/AGENTS.md b/AGENTS.md
index 331baa2..9aa699e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,6 +17,7 @@ MRP Codex is a modular Manufacturing Resource Planning platform intended to be a
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
+- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- sales quotes, sales orders, approvals, revision history/comparison, and purchase orders
- purchase-order revision history and revision comparison across document and receipt changes
- purchase-order supporting documents and vendor-side purchasing visibility
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ba12af..5bda8f9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ This file is the running release and change log for MRP Codex. Keep it updated w
### Added
+- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form
- Revision comparison views for sales quotes, sales orders, and purchase orders with field- and line-level before/after diffs
- Purchase-order revision snapshots covering document edits, status changes, and receipt posting
- Session review cues on admin auth sessions, including flagged stale activity, multi-session counts, and multi-IP warnings
diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md
index 3c1986e..f0d33f4 100644
--- a/INSTRUCTIONS.md
+++ b/INSTRUCTIONS.md
@@ -16,6 +16,7 @@ This repository implements the platform foundation milestone:
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
+- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- sales quotes and sales orders with quick actions and quote conversion
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
- purchase orders with quick actions and searchable vendor/SKU entry
diff --git a/README.md b/README.md
index 78498da..37fd804 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ Current foundation scope includes:
- CRM contact history, account contacts, and shared attachments
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
- inventory transfers, reservations, and available-stock visibility
+- inventory SKU master builder with family-scoped sequence generation and branch-aware taxonomy management
- sales quotes and sales orders with searchable customer and SKU entry
- sales approvals, approval stamps, automatic revision history, and revision comparison on quotes and sales orders
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts
index 41da386..7928f0c 100644
--- a/client/src/lib/api.ts
+++ b/client/src/lib/api.ts
@@ -36,6 +36,12 @@ import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOptionDto,
+ InventorySkuBuilderPreviewDto,
+ InventorySkuCatalogTreeDto,
+ InventorySkuFamilyDto,
+ InventorySkuFamilyInput,
+ InventorySkuNodeDto,
+ InventorySkuNodeInput,
InventoryReservationInput,
InventoryItemStatus,
InventoryItemSummaryDto,
@@ -430,6 +436,38 @@ export const api = {
getInventoryItemOptions(token: string) {
return request("/api/v1/inventory/items/options", undefined, token);
},
+ getInventorySkuFamilies(token: string) {
+ return request("/api/v1/inventory/sku/families", undefined, token);
+ },
+ getInventorySkuCatalog(token: string) {
+ return request("/api/v1/inventory/sku/catalog", undefined, token);
+ },
+ getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
+ return request(
+ `/api/v1/inventory/sku/nodes${buildQueryString({
+ familyId,
+ parentNodeId: parentNodeId ?? undefined,
+ })}`,
+ undefined,
+ token
+ );
+ },
+ getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
+ return request(
+ `/api/v1/inventory/sku/preview${buildQueryString({
+ familyId,
+ nodeId: nodeId ?? undefined,
+ })}`,
+ undefined,
+ token
+ );
+ },
+ createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
+ return request("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
+ },
+ createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
+ return request("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
+ },
getWarehouseLocationOptions(token: string) {
return request("/api/v1/inventory/locations/options", undefined, token);
},
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 13de16a..b22a78a 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -44,6 +44,9 @@ const InventoryDetailPage = React.lazy(() =>
const InventoryFormPage = React.lazy(() =>
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
);
+const InventorySkuMasterPage = React.lazy(() =>
+ import("./modules/inventory/InventorySkuMasterPage").then((module) => ({ default: module.InventorySkuMasterPage }))
+);
const WarehousesPage = React.lazy(() =>
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
);
@@ -148,6 +151,7 @@ const router = createBrowserRouter([
children: [
{ path: "/inventory/items", element: lazyElement( ) },
{ path: "/inventory/items/:itemId", element: lazyElement( ) },
+ { path: "/inventory/sku-master", element: lazyElement( ) },
{ path: "/inventory/warehouses", element: lazyElement( ) },
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement( ) },
],
diff --git a/client/src/modules/inventory/InventoryFormPage.tsx b/client/src/modules/inventory/InventoryFormPage.tsx
index 5cbe7f4..3f0e899 100644
--- a/client/src/modules/inventory/InventoryFormPage.tsx
+++ b/client/src/modules/inventory/InventoryFormPage.tsx
@@ -1,5 +1,13 @@
import type { PurchaseVendorOptionDto } from "@mrp/shared";
-import type { InventoryBomLineInput, InventoryItemInput, InventoryItemOperationInput, InventoryItemOptionDto } from "@mrp/shared/dist/inventory/types.js";
+import type {
+ InventoryBomLineInput,
+ InventoryItemInput,
+ InventoryItemOperationInput,
+ InventoryItemOptionDto,
+ InventorySkuBuilderPreviewDto,
+ InventorySkuFamilyDto,
+ InventorySkuNodeDto,
+} from "@mrp/shared/dist/inventory/types.js";
import type { ManufacturingStationDto } from "@mrp/shared";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
@@ -28,6 +36,10 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
const [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
const [isSaving, setIsSaving] = useState(false);
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
+ const [skuFamilies, setSkuFamilies] = useState([]);
+ const [skuLevelOptions, setSkuLevelOptions] = useState([]);
+ const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState>([]);
+ const [skuPreview, setSkuPreview] = useState(null);
function getComponentOption(componentItemId: string) {
return componentOptions.find((option) => option.id === componentItemId) ?? null;
@@ -37,6 +49,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
return getComponentOption(componentItemId)?.sku ?? "";
}
+ function getSkuFamilyName(familyId: string | null) {
+ if (!familyId) {
+ return "";
+ }
+
+ return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
+ }
+
useEffect(() => {
if (!token) {
return;
@@ -71,6 +91,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
.then((item) => {
setForm({
sku: item.sku,
+ skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
name: item.name,
description: item.description,
type: item.type,
@@ -98,6 +119,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
notes: operation.notes,
})),
});
+ setSelectedSkuNodeIds(item.skuBuilder?.nodePath.map((entry) => entry.id) ?? []);
+ setSkuPreview(item.skuBuilder ? { ...item.skuBuilder, nextSequenceNumber: item.skuBuilder.sequenceNumber ?? 0, availableLevels: Math.max(0, 6 - item.skuBuilder.segments.length), hasChildren: false } : null);
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
setStatus("Inventory item loaded.");
setVendorSearchTerm(item.preferredVendorName ?? "");
@@ -115,12 +138,104 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
+ api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
}, [token]);
+ useEffect(() => {
+ const familyId = form.skuBuilder?.familyId ?? null;
+ if (!token || !familyId) {
+ setSkuLevelOptions([]);
+ setSelectedSkuNodeIds([]);
+ setSkuPreview(null);
+ return;
+ }
+
+ let cancelled = false;
+ const activeFamilyId: string = familyId;
+ const activeToken: string = token;
+
+ async function loadSkuBuilderState() {
+ const nextOptions: InventorySkuNodeDto[][] = [];
+ let parentNodeId: string | null = null;
+ const lineage = selectedSkuNodeIds.filter((value): value is string => Boolean(value));
+
+ for (let levelIndex = 0; levelIndex <= lineage.length; levelIndex += 1) {
+ const options = await api.getInventorySkuNodes(activeToken, activeFamilyId, parentNodeId ?? undefined);
+ if (!options.length) {
+ break;
+ }
+
+ nextOptions.push(options);
+ const nextSelectedNodeId = lineage[levelIndex];
+ if (!nextSelectedNodeId || !options.some((option) => option.id === nextSelectedNodeId)) {
+ break;
+ }
+
+ parentNodeId = nextSelectedNodeId;
+ }
+
+ const leafNodeId = lineage.length > 0 ? lineage[lineage.length - 1] : null;
+ const preview = await api.getInventorySkuPreview(activeToken, activeFamilyId, leafNodeId ?? undefined);
+ if (!cancelled) {
+ setSkuLevelOptions(nextOptions);
+ setSkuPreview(preview);
+ setForm((current) => ({
+ ...current,
+ sku: preview.generatedSku,
+ skuBuilder: current.skuBuilder ? { familyId: current.skuBuilder.familyId, nodeId: leafNodeId ?? null } : current.skuBuilder,
+ }));
+ }
+ }
+
+ void loadSkuBuilderState().catch(() => {
+ if (!cancelled) {
+ setSkuLevelOptions([]);
+ setSkuPreview(null);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [form.skuBuilder?.familyId, selectedSkuNodeIds, token]);
+
function updateField(key: Key, value: InventoryItemInput[Key]) {
setForm((current) => ({ ...current, [key]: value }));
}
+ function updateSkuFamily(familyId: string) {
+ if (!familyId) {
+ setSelectedSkuNodeIds([]);
+ setSkuLevelOptions([]);
+ setSkuPreview(null);
+ setForm((current) => ({
+ ...current,
+ skuBuilder: null,
+ sku: mode === "edit" && !current.skuBuilder ? current.sku : "",
+ }));
+ return;
+ }
+
+ setSelectedSkuNodeIds([]);
+ setForm((current) => ({
+ ...current,
+ skuBuilder: {
+ familyId,
+ nodeId: null,
+ },
+ }));
+ }
+
+ function updateSkuNode(levelIndex: number, nodeId: string) {
+ setSelectedSkuNodeIds((current) => {
+ const next = current.slice(0, levelIndex);
+ if (nodeId) {
+ next[levelIndex] = nodeId;
+ }
+ return next;
+ });
+ }
+
function getSelectedVendorName(vendorId: string | null) {
if (!vendorId) {
return "";
@@ -231,24 +346,86 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
-
- Cancel
-
+
+
+ SKU master
+
+
+ Cancel
+
+
-
- SKU
- updateField("sku", 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"
- />
-
+
+
+ SKU builder
+
+ Manage SKU tree
+
+
+
+
+ Family
+ updateSkuFamily(event.target.value)}
+ className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
+ >
+ Select family
+ {skuFamilies.filter((family) => family.isActive).map((family) => (
+
+ {family.code} - {family.name}
+
+ ))}
+
+
+ {skuLevelOptions.length > 0 ? (
+
+ {skuLevelOptions.map((options, levelIndex) => (
+
+ Level {levelIndex + 2}
+ updateSkuNode(levelIndex, event.target.value)}
+ className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
+ >
+ Stop at this level
+ {options.map((option) => (
+
+ {option.code} - {option.label}
+
+ ))}
+
+
+ ))}
+
+ ) : null}
+
+
Generated SKU
+
{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}
+
+ {skuPreview
+ ? `${skuPreview.familyCode} branch with ${skuPreview.sequenceCode}${String(skuPreview.nextSequenceNumber).padStart(4, "0")} next in sequence.`
+ : form.skuBuilder?.familyId
+ ? `Building from ${getSkuFamilyName(form.skuBuilder.familyId)}.`
+ : "SKU suffix is family-scoped and assigned by the server when the item is saved."}
+
+
+
+
+
Item name
{canManage ? (
-
- New item
-
+
+
+ SKU master
+
+
+ New item
+
+
) : null}
diff --git a/client/src/modules/inventory/InventorySkuMasterPage.tsx b/client/src/modules/inventory/InventorySkuMasterPage.tsx
new file mode 100644
index 0000000..10340b4
--- /dev/null
+++ b/client/src/modules/inventory/InventorySkuMasterPage.tsx
@@ -0,0 +1,298 @@
+import { permissions } from "@mrp/shared";
+import type { InventorySkuCatalogTreeDto, InventorySkuFamilyInput, InventorySkuNodeDto, InventorySkuNodeInput } from "@mrp/shared/dist/inventory/types.js";
+import type { ReactNode } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+
+import { useAuth } from "../../auth/AuthProvider";
+import { api, ApiError } from "../../lib/api";
+
+const emptyFamilyForm: InventorySkuFamilyInput = {
+ code: "",
+ sequenceCode: "",
+ name: "",
+ description: "",
+ isActive: true,
+};
+
+const emptyNodeForm: InventorySkuNodeInput = {
+ familyId: "",
+ parentNodeId: null,
+ code: "",
+ label: "",
+ description: "",
+ sortOrder: 10,
+ isActive: true,
+};
+
+export function InventorySkuMasterPage() {
+ const { token, user } = useAuth();
+ const [catalog, setCatalog] = useState
({ families: [], nodes: [] });
+ const [familyForm, setFamilyForm] = useState(emptyFamilyForm);
+ const [nodeForm, setNodeForm] = useState(emptyNodeForm);
+ const [selectedFamilyId, setSelectedFamilyId] = useState("");
+ const [status, setStatus] = useState("Loading SKU master...");
+
+ const canManage = user?.permissions.includes(permissions.inventoryWrite) ?? false;
+
+ useEffect(() => {
+ if (!token) {
+ return;
+ }
+
+ api
+ .getInventorySkuCatalog(token)
+ .then((nextCatalog) => {
+ setCatalog(nextCatalog);
+ const firstFamilyId = nextCatalog.families[0]?.id ?? "";
+ setSelectedFamilyId((current) => current || firstFamilyId);
+ setNodeForm((current) => ({
+ ...current,
+ familyId: current.familyId || firstFamilyId,
+ }));
+ setStatus(`${nextCatalog.families.length} family branch(es) loaded.`);
+ })
+ .catch((error: unknown) => {
+ setStatus(error instanceof ApiError ? error.message : "Unable to load SKU master.");
+ });
+ }, [token]);
+
+ const familyNodes = useMemo(
+ () =>
+ catalog.nodes
+ .filter((node) => node.familyId === selectedFamilyId)
+ .sort((left, right) => left.level - right.level || left.sortOrder - right.sortOrder || left.code.localeCompare(right.code)),
+ [catalog.nodes, selectedFamilyId]
+ );
+
+ const parentOptions = useMemo(
+ () => familyNodes.filter((node) => node.level < 6),
+ [familyNodes]
+ );
+
+ function renderNodes(parentNodeId: string | null, depth = 0): ReactNode {
+ const branchNodes = familyNodes.filter((node) => node.parentNodeId === parentNodeId);
+ if (!branchNodes.length) {
+ return null;
+ }
+
+ return branchNodes.map((node) => (
+
+
+
+
+
{node.code} - {node.label}
+
Level {node.level} • {node.childCount} child branch(es)
+
+
+ setNodeForm((current) => ({
+ ...current,
+ familyId: selectedFamilyId,
+ parentNodeId: node.id,
+ }))
+ }
+ className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text"
+ >
+ Add child
+
+
+ {node.description ?
{node.description}
: null}
+
+ {renderNodes(node.id, depth + 1)}
+
+ ));
+ }
+
+ async function reloadCatalog() {
+ if (!token) {
+ return;
+ }
+
+ const nextCatalog = await api.getInventorySkuCatalog(token);
+ setCatalog(nextCatalog);
+ if (!selectedFamilyId && nextCatalog.families[0]) {
+ setSelectedFamilyId(nextCatalog.families[0].id);
+ }
+ }
+
+ async function handleCreateFamily(event: React.FormEvent) {
+ event.preventDefault();
+ if (!token || !canManage) {
+ return;
+ }
+
+ try {
+ const created = await api.createInventorySkuFamily(token, familyForm);
+ setFamilyForm(emptyFamilyForm);
+ setSelectedFamilyId(created.id);
+ setNodeForm((current) => ({ ...current, familyId: created.id, parentNodeId: null }));
+ await reloadCatalog();
+ setStatus(`Created SKU family ${created.code}.`);
+ } catch (error: unknown) {
+ setStatus(error instanceof ApiError ? error.message : "Unable to create SKU family.");
+ }
+ }
+
+ async function handleCreateNode(event: React.FormEvent) {
+ event.preventDefault();
+ if (!token || !canManage || !nodeForm.familyId) {
+ return;
+ }
+
+ try {
+ const created = await api.createInventorySkuNode(token, nodeForm);
+ setNodeForm((current) => ({
+ ...emptyNodeForm,
+ familyId: current.familyId,
+ parentNodeId: created.parentNodeId,
+ }));
+ await reloadCatalog();
+ setStatus(`Created SKU branch ${created.code}.`);
+ } catch (error: unknown) {
+ setStatus(error instanceof ApiError ? error.message : "Unable to create SKU branch.");
+ }
+ }
+
+ return (
+
+
+
+
+
Inventory Master Data
+
SKU Master Builder
+
Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.
+
+
+ Back to items
+
+
+
+
+
+
+
+ Families
+
+ {catalog.families.length === 0 ? (
+
No SKU families defined yet.
+ ) : (
+ catalog.families.map((family) => (
+
{
+ setSelectedFamilyId(family.id);
+ setNodeForm((current) => ({ ...current, familyId: family.id, parentNodeId: null }));
+ }}
+ className={`block w-full rounded-[18px] border px-3 py-3 text-left transition ${
+ selectedFamilyId === family.id ? "border-brand bg-brand/8" : "border-line/70 bg-page/60 hover:bg-page/80"
+ }`}
+ >
+ {family.code} ({family.sequenceCode})
+ {family.name}
+ {family.childNodeCount} branch nodes • next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}
+
+ ))
+ )}
+
+
+
+ {canManage ? (
+
+ ) : null}
+
+
+
+
+
+
+
Branch tree
+
{status}
+
+ {selectedFamilyId ? (
+
Up to 6 total SKU levels including family root.
+ ) : null}
+
+
+ {selectedFamilyId ? renderNodes(null) :
Select a family to inspect or extend its branch tree.
}
+
+
+
+ {canManage ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/client/src/modules/inventory/config.ts b/client/src/modules/inventory/config.ts
index 7993706..73e0e0c 100644
--- a/client/src/modules/inventory/config.ts
+++ b/client/src/modules/inventory/config.ts
@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
+ skuBuilder: null,
name: "",
description: "",
type: "PURCHASED",
diff --git a/server/prisma/migrations/20260315233000_inventory_sku_builder/migration.sql b/server/prisma/migrations/20260315233000_inventory_sku_builder/migration.sql
new file mode 100644
index 0000000..2c7ab69
--- /dev/null
+++ b/server/prisma/migrations/20260315233000_inventory_sku_builder/migration.sql
@@ -0,0 +1,78 @@
+-- CreateTable
+CREATE TABLE "InventorySkuFamily" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "code" TEXT NOT NULL,
+ "sequenceCode" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "nextSequenceNumber" INTEGER NOT NULL DEFAULT 1,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "InventorySkuNode" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "familyId" TEXT NOT NULL,
+ "parentNodeId" TEXT,
+ "code" TEXT NOT NULL,
+ "label" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "path" TEXT NOT NULL,
+ "level" INTEGER NOT NULL,
+ "sortOrder" INTEGER NOT NULL DEFAULT 0,
+ "isActive" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "InventorySkuNode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "InventorySkuNode_parentNodeId_fkey" FOREIGN KEY ("parentNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_InventoryItem" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "sku" TEXT NOT NULL,
+ "skuFamilyId" TEXT,
+ "skuNodeId" TEXT,
+ "skuSequenceNumber" INTEGER,
+ "name" TEXT NOT NULL,
+ "description" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "status" TEXT NOT NULL,
+ "unitOfMeasure" TEXT NOT NULL,
+ "isSellable" BOOLEAN NOT NULL DEFAULT true,
+ "isPurchasable" BOOLEAN NOT NULL DEFAULT true,
+ "preferredVendorId" TEXT,
+ "defaultCost" REAL,
+ "defaultPrice" REAL,
+ "notes" TEXT NOT NULL,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" DATETIME NOT NULL,
+ CONSTRAINT "InventoryItem_preferredVendorId_fkey" FOREIGN KEY ("preferredVendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT "InventoryItem_skuFamilyId_fkey" FOREIGN KEY ("skuFamilyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT "InventoryItem_skuNodeId_fkey" FOREIGN KEY ("skuNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+INSERT INTO "new_InventoryItem" ("createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt") SELECT "createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt" FROM "InventoryItem";
+DROP TABLE "InventoryItem";
+ALTER TABLE "new_InventoryItem" RENAME TO "InventoryItem";
+CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
+CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
+CREATE INDEX "InventoryItem_skuFamilyId_idx" ON "InventoryItem"("skuFamilyId");
+CREATE INDEX "InventoryItem_skuNodeId_idx" ON "InventoryItem"("skuNodeId");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
+
+-- CreateIndex
+CREATE UNIQUE INDEX "InventorySkuFamily_code_key" ON "InventorySkuFamily"("code");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "InventorySkuFamily_sequenceCode_key" ON "InventorySkuFamily"("sequenceCode");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "InventorySkuNode_familyId_path_key" ON "InventorySkuNode"("familyId", "path");
+
+-- CreateIndex
+CREATE INDEX "InventorySkuNode_familyId_parentNodeId_sortOrder_idx" ON "InventorySkuNode"("familyId", "parentNodeId", "sortOrder");
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 1a921d9..aada118 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -138,6 +138,9 @@ model FileAttachment {
model InventoryItem {
id String @id @default(cuid())
sku String @unique
+ skuFamilyId String?
+ skuNodeId String?
+ skuSequenceNumber Int?
name String
description String
type String
@@ -163,8 +166,48 @@ model InventoryItem {
reservations InventoryReservation[]
transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
+ skuFamily InventorySkuFamily? @relation(fields: [skuFamilyId], references: [id], onDelete: SetNull)
+ skuNode InventorySkuNode? @relation(fields: [skuNodeId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
+ @@index([skuFamilyId])
+ @@index([skuNodeId])
+}
+
+model InventorySkuFamily {
+ id String @id @default(cuid())
+ code String @unique
+ sequenceCode String @unique
+ name String
+ description String
+ nextSequenceNumber Int @default(1)
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ nodes InventorySkuNode[]
+ items InventoryItem[]
+}
+
+model InventorySkuNode {
+ id String @id @default(cuid())
+ familyId String
+ parentNodeId String?
+ code String
+ label String
+ description String
+ path String
+ level Int
+ sortOrder Int @default(0)
+ isActive Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ family InventorySkuFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
+ parentNode InventorySkuNode? @relation("InventorySkuNodeTree", fields: [parentNodeId], references: [id], onDelete: Cascade)
+ childNodes InventorySkuNode[] @relation("InventorySkuNodeTree")
+ items InventoryItem[]
+
+ @@unique([familyId, path])
+ @@index([familyId, parentNodeId, sortOrder])
}
model Warehouse {
diff --git a/server/src/modules/inventory/router.ts b/server/src/modules/inventory/router.ts
index 71f07c0..a966667 100644
--- a/server/src/modules/inventory/router.ts
+++ b/server/src/modules/inventory/router.ts
@@ -8,10 +8,16 @@ import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryReservation,
+ createInventorySkuFamily,
+ createInventorySkuNode,
createInventoryTransfer,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
+ listInventorySkuCatalog,
+ listInventorySkuFamilies,
+ listInventorySkuNodeOptions,
+ previewInventorySku,
getWarehouseById,
listInventoryItemOptions,
listInventoryItems,
@@ -40,6 +46,12 @@ const operationSchema = z.object({
const inventoryItemSchema = z.object({
sku: z.string().trim().min(1).max(64),
+ skuBuilder: z
+ .object({
+ familyId: z.string().trim().min(1),
+ nodeId: z.string().trim().min(1).nullable(),
+ })
+ .nullable(),
name: z.string().trim().min(1).max(160),
description: z.string(),
type: z.enum(inventoryItemTypes),
@@ -99,6 +111,34 @@ const warehouseSchema = z.object({
locations: z.array(warehouseLocationSchema),
});
+const skuFamilySchema = z.object({
+ code: z.string().trim().min(2).max(12),
+ sequenceCode: z.string().trim().min(2).max(2),
+ name: z.string().trim().min(1).max(160),
+ description: z.string(),
+ isActive: z.boolean(),
+});
+
+const skuNodeSchema = z.object({
+ familyId: z.string().trim().min(1),
+ parentNodeId: z.string().trim().min(1).nullable(),
+ code: z.string().trim().min(1).max(32),
+ label: z.string().trim().min(1).max(160),
+ description: z.string(),
+ sortOrder: z.number().int().nonnegative(),
+ isActive: z.boolean(),
+});
+
+const skuPreviewQuerySchema = z.object({
+ familyId: z.string().trim().min(1),
+ nodeId: z.string().trim().min(1).nullable().optional(),
+});
+
+const skuNodeOptionsQuerySchema = z.object({
+ familyId: z.string().trim().min(1),
+ parentNodeId: z.string().trim().min(1).nullable().optional(),
+});
+
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -125,6 +165,46 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
return ok(response, await listInventoryItemOptions());
});
+inventoryRouter.get("/sku/families", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
+ return ok(response, await listInventorySkuFamilies());
+});
+
+inventoryRouter.get("/sku/catalog", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
+ return ok(response, await listInventorySkuCatalog());
+});
+
+inventoryRouter.get("/sku/nodes", requirePermissions([permissions.inventoryRead]), async (request, response) => {
+ const parsed = skuNodeOptionsQuerySchema.safeParse({
+ familyId: request.query.familyId,
+ parentNodeId: request.query.parentNodeId ?? null,
+ });
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "SKU node filters are invalid.");
+ }
+
+ return ok(response, await listInventorySkuNodeOptions(parsed.data.familyId, parsed.data.parentNodeId ?? null));
+});
+
+inventoryRouter.get("/sku/preview", requirePermissions([permissions.inventoryRead]), async (request, response) => {
+ const parsed = skuPreviewQuerySchema.safeParse({
+ familyId: request.query.familyId,
+ nodeId: request.query.nodeId ?? null,
+ });
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
+ }
+
+ const preview = await previewInventorySku({
+ familyId: parsed.data.familyId,
+ nodeId: parsed.data.nodeId ?? null,
+ });
+ if (!preview) {
+ return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
+ }
+
+ return ok(response, preview);
+});
+
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions());
});
@@ -157,6 +237,34 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
return ok(response, item, 201);
});
+inventoryRouter.post("/sku/families", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
+ const parsed = skuFamilySchema.safeParse(request.body);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
+ }
+
+ const family = await createInventorySkuFamily(parsed.data);
+ if (!family) {
+ return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
+ }
+
+ return ok(response, family, 201);
+});
+
+inventoryRouter.post("/sku/nodes", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
+ const parsed = skuNodeSchema.safeParse(request.body);
+ if (!parsed.success) {
+ return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
+ }
+
+ const node = await createInventorySkuNode(parsed.data);
+ if (!node) {
+ return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
+ }
+
+ return ok(response, node, 201);
+});
+
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {
diff --git a/server/src/modules/inventory/service.ts b/server/src/modules/inventory/service.ts
index a680467..dadbb4f 100644
--- a/server/src/modules/inventory/service.ts
+++ b/server/src/modules/inventory/service.ts
@@ -4,6 +4,14 @@ import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOperationDto,
+ InventorySkuBuilderInput,
+ InventorySkuBuilderPreviewDto,
+ InventorySkuBuilderSelectionDto,
+ InventorySkuCatalogTreeDto,
+ InventorySkuFamilyDto,
+ InventorySkuFamilyInput,
+ InventorySkuNodeDto,
+ InventorySkuNodeInput,
InventoryReservationDto,
InventoryReservationInput,
InventoryReservationStatus,
@@ -58,6 +66,9 @@ type OperationRecord = {
type InventoryDetailRecord = {
id: string;
sku: string;
+ skuFamilyId: string | null;
+ skuNodeId: string | null;
+ skuSequenceNumber: number | null;
name: string;
description: string;
type: string;
@@ -74,6 +85,20 @@ type InventoryDetailRecord = {
notes: string;
createdAt: Date;
updatedAt: Date;
+ skuFamily: {
+ id: string;
+ code: string;
+ sequenceCode: string;
+ name: string;
+ } | null;
+ skuNode: {
+ id: string;
+ code: string;
+ label: string;
+ path: string;
+ level: number;
+ parentNodeId: string | null;
+ } | null;
bomLines: BomLineRecord[];
operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[];
@@ -176,6 +201,36 @@ type InventoryTransferRecord = {
} | null;
};
+type InventorySkuFamilyRecord = {
+ id: string;
+ code: string;
+ sequenceCode: string;
+ name: string;
+ description: string;
+ nextSequenceNumber: number;
+ isActive: boolean;
+ _count: {
+ nodes: number;
+ items: number;
+ };
+};
+
+type InventorySkuNodeRecord = {
+ id: string;
+ familyId: string;
+ parentNodeId: string | null;
+ code: string;
+ label: string;
+ description: string;
+ path: string;
+ level: number;
+ sortOrder: number;
+ isActive: boolean;
+ _count: {
+ childNodes: number;
+ };
+};
+
function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
return {
id: record.id,
@@ -280,6 +335,44 @@ function mapTransfer(record: InventoryTransferRecord): InventoryTransferDto {
};
}
+function mapSkuFamily(record: InventorySkuFamilyRecord): InventorySkuFamilyDto {
+ return {
+ id: record.id,
+ code: record.code,
+ sequenceCode: record.sequenceCode,
+ name: record.name,
+ description: record.description,
+ nextSequenceNumber: record.nextSequenceNumber,
+ isActive: record.isActive,
+ childNodeCount: record._count.nodes,
+ itemCount: record._count.items,
+ };
+}
+
+function mapSkuNode(record: InventorySkuNodeRecord): InventorySkuNodeDto {
+ return {
+ id: record.id,
+ familyId: record.familyId,
+ parentNodeId: record.parentNodeId,
+ code: record.code,
+ label: record.label,
+ description: record.description,
+ path: record.path,
+ level: record.level,
+ sortOrder: record.sortOrder,
+ isActive: record.isActive,
+ childCount: record._count.childNodes,
+ };
+}
+
+function formatSkuSequence(sequenceNumber: number) {
+ return sequenceNumber.toString().padStart(4, "0");
+}
+
+function formatGeneratedSku(sequenceCode: string, segments: string[], sequenceNumber: number) {
+ return [...segments, `${sequenceCode}${formatSkuSequence(sequenceNumber)}`].join("-");
+}
+
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map();
@@ -390,7 +483,7 @@ function mapSummary(record: {
};
}
-function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
+function mapDetail(record: InventoryDetailRecord, skuBuilder: InventorySkuBuilderSelectionDto | null): InventoryItemDetailDto {
const recentTransactions = record.inventoryTransactions
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
@@ -429,6 +522,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null,
+ skuBuilder,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -533,6 +627,156 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
.filter((location) => location.code.length > 0 && location.name.length > 0);
}
+function normalizeSkuFamilyInput(input: InventorySkuFamilyInput) {
+ return {
+ code: input.code.trim().toUpperCase(),
+ sequenceCode: input.sequenceCode.trim().toUpperCase(),
+ name: input.name.trim(),
+ description: input.description.trim(),
+ isActive: input.isActive,
+ };
+}
+
+function normalizeSkuNodeInput(input: InventorySkuNodeInput) {
+ return {
+ familyId: input.familyId,
+ parentNodeId: input.parentNodeId,
+ code: input.code.trim(),
+ label: input.label.trim(),
+ description: input.description.trim(),
+ sortOrder: Number(input.sortOrder ?? 0),
+ isActive: input.isActive,
+ };
+}
+
+async function getSkuNodeLineage(nodeId: string) {
+ const lineage: Array<{
+ id: string;
+ familyId: string;
+ parentNodeId: string | null;
+ code: string;
+ label: string;
+ path: string;
+ level: number;
+ }> = [];
+ type SkuLineageNode = (typeof lineage)[number];
+
+ let currentId: string | null = nodeId;
+ while (currentId) {
+ const currentNode: SkuLineageNode | null = await prisma.inventorySkuNode.findUnique({
+ where: { id: currentId },
+ select: {
+ id: true,
+ familyId: true,
+ parentNodeId: true,
+ code: true,
+ label: true,
+ path: true,
+ level: true,
+ },
+ });
+
+ if (!currentNode) {
+ return null;
+ }
+
+ lineage.unshift(currentNode);
+ currentId = currentNode.parentNodeId;
+ }
+
+ return lineage;
+}
+
+async function buildSkuBuilderSelection(
+ family: { id: string; code: string; name: string; sequenceCode: string },
+ nodeId: string | null,
+ sequenceNumber: number | null
+): Promise {
+ const lineage = nodeId ? await getSkuNodeLineage(nodeId) : [];
+ const segments = [family.code, ...(lineage ?? []).map((node) => node.code)];
+ const effectiveSequenceNumber = sequenceNumber ?? 0;
+
+ return {
+ familyId: family.id,
+ familyCode: family.code,
+ familyName: family.name,
+ sequenceCode: family.sequenceCode,
+ nodeId,
+ nodePath: (lineage ?? []).map((node) => ({
+ id: node.id,
+ code: node.code,
+ label: node.label,
+ level: node.level,
+ })),
+ sequenceNumber,
+ generatedSku: formatGeneratedSku(family.sequenceCode, segments, effectiveSequenceNumber),
+ segments,
+ };
+}
+
+async function validateSkuBuilder(input: InventorySkuBuilderInput | null) {
+ if (!input) {
+ return { ok: true as const, family: null, node: null, segments: [] as string[] };
+ }
+
+ const family = await prisma.inventorySkuFamily.findUnique({
+ where: { id: input.familyId },
+ select: {
+ id: true,
+ code: true,
+ name: true,
+ sequenceCode: true,
+ nextSequenceNumber: true,
+ isActive: true,
+ },
+ });
+
+ if (!family || !family.isActive) {
+ return { ok: false as const, reason: "Selected SKU family was not found or is inactive." };
+ }
+
+ let node:
+ | {
+ id: string;
+ familyId: string;
+ code: string;
+ label: string;
+ path: string;
+ level: number;
+ isActive: boolean;
+ }
+ | null = null;
+ let segments = [family.code];
+
+ if (input.nodeId) {
+ const lineage = await getSkuNodeLineage(input.nodeId);
+ if (!lineage || lineage.length === 0) {
+ return { ok: false as const, reason: "Selected SKU branch was not found." };
+ }
+
+ node = await prisma.inventorySkuNode.findUnique({
+ where: { id: input.nodeId },
+ select: {
+ id: true,
+ familyId: true,
+ code: true,
+ label: true,
+ path: true,
+ level: true,
+ isActive: true,
+ },
+ });
+
+ if (!node || node.familyId !== family.id || !node.isActive || lineage.some((entry) => entry.familyId !== family.id)) {
+ return { ok: false as const, reason: "Selected SKU branch is invalid for the chosen family." };
+ }
+
+ segments = [family.code, ...lineage.map((entry) => entry.code)];
+ }
+
+ return { ok: true as const, family, node, segments };
+}
+
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
@@ -672,6 +916,188 @@ async function getActiveReservedQuantity(itemId: string, warehouseId: string, lo
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
}
+export async function listInventorySkuFamilies() {
+ const families = await prisma.inventorySkuFamily.findMany({
+ include: {
+ _count: {
+ select: {
+ nodes: true,
+ items: true,
+ },
+ },
+ },
+ orderBy: [{ code: "asc" }],
+ });
+
+ return families.map(mapSkuFamily);
+}
+
+export async function listInventorySkuCatalog(): Promise {
+ const [families, nodes] = await Promise.all([
+ prisma.inventorySkuFamily.findMany({
+ include: {
+ _count: {
+ select: {
+ nodes: true,
+ items: true,
+ },
+ },
+ },
+ orderBy: [{ code: "asc" }],
+ }),
+ prisma.inventorySkuNode.findMany({
+ include: {
+ _count: {
+ select: {
+ childNodes: true,
+ },
+ },
+ },
+ orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { code: "asc" }],
+ }),
+ ]);
+
+ return {
+ families: families.map(mapSkuFamily),
+ nodes: nodes.map(mapSkuNode),
+ };
+}
+
+export async function listInventorySkuNodeOptions(familyId: string, parentNodeId: string | null = null) {
+ const nodes = await prisma.inventorySkuNode.findMany({
+ where: {
+ familyId,
+ parentNodeId,
+ isActive: true,
+ },
+ include: {
+ _count: {
+ select: {
+ childNodes: true,
+ },
+ },
+ },
+ orderBy: [{ sortOrder: "asc" }, { code: "asc" }],
+ });
+
+ return nodes.map(mapSkuNode);
+}
+
+export async function previewInventorySku(input: InventorySkuBuilderInput): Promise {
+ const validated = await validateSkuBuilder(input);
+ if (!validated.ok || !validated.family) {
+ return null;
+ }
+
+ const childCount = validated.node
+ ? await prisma.inventorySkuNode.count({
+ where: {
+ parentNodeId: validated.node.id,
+ isActive: true,
+ },
+ })
+ : await prisma.inventorySkuNode.count({
+ where: {
+ familyId: validated.family.id,
+ parentNodeId: null,
+ isActive: true,
+ },
+ });
+
+ return {
+ ...(await buildSkuBuilderSelection(validated.family, validated.node?.id ?? null, validated.family.nextSequenceNumber)),
+ nextSequenceNumber: validated.family.nextSequenceNumber,
+ availableLevels: Math.max(0, 6 - validated.segments.length),
+ hasChildren: childCount > 0,
+ };
+}
+
+export async function createInventorySkuFamily(input: InventorySkuFamilyInput) {
+ const payload = normalizeSkuFamilyInput(input);
+ if (!/^[A-Z0-9]{2,12}$/.test(payload.code) || !/^[A-Z]{2}$/.test(payload.sequenceCode) || payload.name.length === 0) {
+ return null;
+ }
+
+ const family = await prisma.inventorySkuFamily.create({
+ data: payload,
+ include: {
+ _count: {
+ select: {
+ nodes: true,
+ items: true,
+ },
+ },
+ },
+ });
+
+ return mapSkuFamily(family);
+}
+
+export async function createInventorySkuNode(input: InventorySkuNodeInput) {
+ const payload = normalizeSkuNodeInput(input);
+ if (!payload.familyId || payload.code.length === 0 || payload.label.length === 0 || payload.code.includes("-")) {
+ return null;
+ }
+
+ const family = await prisma.inventorySkuFamily.findUnique({
+ where: { id: payload.familyId },
+ select: { id: true, code: true, isActive: true },
+ });
+ if (!family || !family.isActive) {
+ return null;
+ }
+
+ let level = 2;
+ let path = payload.code;
+
+ if (payload.parentNodeId) {
+ const parentNode = await prisma.inventorySkuNode.findUnique({
+ where: { id: payload.parentNodeId },
+ select: {
+ id: true,
+ familyId: true,
+ path: true,
+ level: true,
+ isActive: true,
+ },
+ });
+
+ if (!parentNode || parentNode.familyId !== family.id || !parentNode.isActive || parentNode.level >= 6) {
+ return null;
+ }
+
+ level = parentNode.level + 1;
+ path = `${parentNode.path}/${payload.code}`;
+ }
+
+ if (level > 6) {
+ return null;
+ }
+
+ const node = await prisma.inventorySkuNode.create({
+ data: {
+ familyId: family.id,
+ parentNodeId: payload.parentNodeId,
+ code: payload.code,
+ label: payload.label,
+ description: payload.description,
+ path,
+ level,
+ sortOrder: payload.sortOrder,
+ isActive: payload.isActive,
+ },
+ include: {
+ _count: {
+ select: {
+ childNodes: true,
+ },
+ },
+ },
+ });
+
+ return mapSkuNode(node);
+}
+
export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters),
@@ -740,6 +1166,24 @@ export async function getInventoryItemById(itemId: string) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
include: {
+ skuFamily: {
+ select: {
+ id: true,
+ code: true,
+ sequenceCode: true,
+ name: true,
+ },
+ },
+ skuNode: {
+ select: {
+ id: true,
+ code: true,
+ label: true,
+ path: true,
+ level: true,
+ parentNodeId: true,
+ },
+ },
bomLines: {
include: {
componentItem: {
@@ -862,7 +1306,16 @@ export async function getInventoryItemById(itemId: string) {
},
});
- return item ? mapDetail(item) : null;
+ if (!item) {
+ return null;
+ }
+
+ const skuBuilder =
+ item.skuFamily && item.skuSequenceNumber
+ ? await buildSkuBuilderSelection(item.skuFamily, item.skuNode?.id ?? null, item.skuSequenceNumber)
+ : null;
+
+ return mapDetail(item as InventoryDetailRecord, skuBuilder);
}
export async function listWarehouseLocationOptions() {
@@ -1120,35 +1573,89 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedPreferredVendor.ok) {
return null;
}
+ const validatedSku = await validateSkuBuilder(payload.skuBuilder);
+ if (!validatedSku.ok) {
+ return null;
+ }
- const item = await prisma.inventoryItem.create({
- data: {
- sku: payload.sku,
- name: payload.name,
- description: payload.description,
- type: payload.type,
- status: payload.status,
- unitOfMeasure: payload.unitOfMeasure,
- isSellable: payload.isSellable,
- isPurchasable: payload.isPurchasable,
- preferredVendorId: payload.preferredVendorId,
- defaultCost: payload.defaultCost,
- defaultPrice: payload.defaultPrice,
- notes: payload.notes,
- bomLines: validatedBom.bomLines.length
- ? {
- create: validatedBom.bomLines,
- }
- : undefined,
- operations: validatedOperations.operations.length
- ? {
- create: validatedOperations.operations,
- }
- : undefined,
- },
- select: {
- id: true,
- },
+ const item = await prisma.$transaction(async (transaction) => {
+ if (validatedSku.family) {
+ const sequenceNumber = validatedSku.family.nextSequenceNumber;
+ await transaction.inventorySkuFamily.update({
+ where: { id: validatedSku.family.id },
+ data: {
+ nextSequenceNumber: {
+ increment: 1,
+ },
+ },
+ });
+
+ return transaction.inventoryItem.create({
+ data: {
+ sku: formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, sequenceNumber),
+ skuFamilyId: validatedSku.family.id,
+ skuNodeId: validatedSku.node?.id ?? null,
+ skuSequenceNumber: sequenceNumber,
+ name: payload.name,
+ description: payload.description,
+ type: payload.type,
+ status: payload.status,
+ unitOfMeasure: payload.unitOfMeasure,
+ isSellable: payload.isSellable,
+ isPurchasable: payload.isPurchasable,
+ preferredVendorId: payload.preferredVendorId,
+ defaultCost: payload.defaultCost,
+ defaultPrice: payload.defaultPrice,
+ notes: payload.notes,
+ bomLines: validatedBom.bomLines.length
+ ? {
+ create: validatedBom.bomLines,
+ }
+ : undefined,
+ operations: validatedOperations.operations.length
+ ? {
+ create: validatedOperations.operations,
+ }
+ : undefined,
+ },
+ select: {
+ id: true,
+ },
+ });
+ }
+
+ return transaction.inventoryItem.create({
+ data: {
+ sku: payload.sku,
+ skuFamilyId: null,
+ skuNodeId: null,
+ skuSequenceNumber: null,
+ name: payload.name,
+ description: payload.description,
+ type: payload.type,
+ status: payload.status,
+ unitOfMeasure: payload.unitOfMeasure,
+ isSellable: payload.isSellable,
+ isPurchasable: payload.isPurchasable,
+ preferredVendorId: payload.preferredVendorId,
+ defaultCost: payload.defaultCost,
+ defaultPrice: payload.defaultPrice,
+ notes: payload.notes,
+ bomLines: validatedBom.bomLines.length
+ ? {
+ create: validatedBom.bomLines,
+ }
+ : undefined,
+ operations: validatedOperations.operations.length
+ ? {
+ create: validatedOperations.operations,
+ }
+ : undefined,
+ },
+ select: {
+ id: true,
+ },
+ });
});
await logAuditEvent({
@@ -1156,9 +1663,11 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
entityType: "inventory-item",
entityId: item.id,
action: "created",
- summary: `Created inventory item ${payload.sku}.`,
+ summary: `Created inventory item ${validatedSku.family ? "generated SKU" : payload.sku}.`,
metadata: {
sku: payload.sku,
+ skuFamilyId: validatedSku.family?.id ?? null,
+ skuNodeId: validatedSku.node?.id ?? null,
name: payload.name,
type: payload.type,
status: payload.status,
@@ -1189,34 +1698,75 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedPreferredVendor.ok) {
return null;
}
+ const validatedSku = await validateSkuBuilder(payload.skuBuilder);
+ if (!validatedSku.ok) {
+ return null;
+ }
- const item = await prisma.inventoryItem.update({
- where: { id: itemId },
- data: {
- sku: payload.sku,
- name: payload.name,
- description: payload.description,
- type: payload.type,
- status: payload.status,
- unitOfMeasure: payload.unitOfMeasure,
- isSellable: payload.isSellable,
- isPurchasable: payload.isPurchasable,
- preferredVendorId: payload.preferredVendorId,
- defaultCost: payload.defaultCost,
- defaultPrice: payload.defaultPrice,
- notes: payload.notes,
- bomLines: {
- deleteMany: {},
- create: validatedBom.bomLines,
+ const item = await prisma.$transaction(async (transaction) => {
+ const shouldKeepExistingGeneratedSku =
+ validatedSku.family &&
+ existingItem.skuFamilyId === validatedSku.family.id &&
+ existingItem.skuNodeId === (validatedSku.node?.id ?? null) &&
+ existingItem.skuSequenceNumber != null;
+
+ let sku = payload.sku;
+ let skuFamilyId: string | null = null;
+ let skuNodeId: string | null = null;
+ let skuSequenceNumber: number | null = null;
+
+ if (validatedSku.family) {
+ skuFamilyId = validatedSku.family.id;
+ skuNodeId = validatedSku.node?.id ?? null;
+
+ if (shouldKeepExistingGeneratedSku) {
+ sku = existingItem.sku;
+ skuSequenceNumber = existingItem.skuSequenceNumber;
+ } else {
+ skuSequenceNumber = validatedSku.family.nextSequenceNumber;
+ sku = formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, skuSequenceNumber);
+ await transaction.inventorySkuFamily.update({
+ where: { id: validatedSku.family.id },
+ data: {
+ nextSequenceNumber: {
+ increment: 1,
+ },
+ },
+ });
+ }
+ }
+
+ return transaction.inventoryItem.update({
+ where: { id: itemId },
+ data: {
+ sku,
+ skuFamilyId,
+ skuNodeId,
+ skuSequenceNumber,
+ name: payload.name,
+ description: payload.description,
+ type: payload.type,
+ status: payload.status,
+ unitOfMeasure: payload.unitOfMeasure,
+ isSellable: payload.isSellable,
+ isPurchasable: payload.isPurchasable,
+ preferredVendorId: payload.preferredVendorId,
+ defaultCost: payload.defaultCost,
+ defaultPrice: payload.defaultPrice,
+ notes: payload.notes,
+ bomLines: {
+ deleteMany: {},
+ create: validatedBom.bomLines,
+ },
+ operations: {
+ deleteMany: {},
+ create: validatedOperations.operations,
+ },
},
- operations: {
- deleteMany: {},
- create: validatedOperations.operations,
+ select: {
+ id: true,
},
- },
- select: {
- id: true,
- },
+ });
});
await logAuditEvent({
@@ -1227,6 +1777,8 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
summary: `Updated inventory item ${payload.sku}.`,
metadata: {
sku: payload.sku,
+ skuFamilyId: validatedSku.family?.id ?? null,
+ skuNodeId: validatedSku.node?.id ?? null,
name: payload.name,
type: payload.type,
status: payload.status,
diff --git a/shared/src/inventory/types.ts b/shared/src/inventory/types.ts
index e7e0bd6..95a6aef 100644
--- a/shared/src/inventory/types.ts
+++ b/shared/src/inventory/types.ts
@@ -10,6 +10,85 @@ export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number];
export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number];
export type InventoryReservationStatus = (typeof inventoryReservationStatuses)[number];
+export interface InventorySkuFamilyDto {
+ id: string;
+ code: string;
+ sequenceCode: string;
+ name: string;
+ description: string;
+ nextSequenceNumber: number;
+ isActive: boolean;
+ childNodeCount: number;
+ itemCount: number;
+}
+
+export interface InventorySkuFamilyInput {
+ code: string;
+ sequenceCode: string;
+ name: string;
+ description: string;
+ isActive: boolean;
+}
+
+export interface InventorySkuNodeDto {
+ id: string;
+ familyId: string;
+ parentNodeId: string | null;
+ code: string;
+ label: string;
+ description: string;
+ path: string;
+ level: number;
+ sortOrder: number;
+ isActive: boolean;
+ childCount: number;
+}
+
+export interface InventorySkuNodeInput {
+ familyId: string;
+ parentNodeId: string | null;
+ code: string;
+ label: string;
+ description: string;
+ sortOrder: number;
+ isActive: boolean;
+}
+
+export interface InventorySkuCatalogTreeDto {
+ families: InventorySkuFamilyDto[];
+ nodes: InventorySkuNodeDto[];
+}
+
+export interface InventorySkuBuilderInput {
+ familyId: string;
+ nodeId: string | null;
+}
+
+export interface InventorySkuNodePathEntryDto {
+ id: string;
+ code: string;
+ label: string;
+ level: number;
+}
+
+export interface InventorySkuBuilderSelectionDto {
+ familyId: string;
+ familyCode: string;
+ familyName: string;
+ sequenceCode: string;
+ nodeId: string | null;
+ nodePath: InventorySkuNodePathEntryDto[];
+ sequenceNumber: number | null;
+ generatedSku: string;
+ segments: string[];
+}
+
+export interface InventorySkuBuilderPreviewDto extends InventorySkuBuilderSelectionDto {
+ nextSequenceNumber: number;
+ availableLevels: number;
+ hasChildren: boolean;
+}
+
export interface InventoryBomLineDto {
id: string;
componentItemId: string;
@@ -217,6 +296,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
defaultPrice: number | null;
preferredVendorId: string | null;
preferredVendorName: string | null;
+ skuBuilder: InventorySkuBuilderSelectionDto | null;
notes: string;
createdAt: string;
bomLines: InventoryBomLineDto[];
@@ -232,6 +312,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
export interface InventoryItemInput {
sku: string;
+ skuBuilder: InventorySkuBuilderInput | null;
name: string;
description: string;
type: InventoryItemType;