sku builder first test

This commit is contained in:
2026-03-15 22:17:58 -05:00
parent f2b820746a
commit 2718e8b4b1
15 changed files with 1463 additions and 74 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
},
getInventorySkuFamilies(token: string) {
return request<InventorySkuFamilyDto[]>("/api/v1/inventory/sku/families", undefined, token);
},
getInventorySkuCatalog(token: string) {
return request<InventorySkuCatalogTreeDto>("/api/v1/inventory/sku/catalog", undefined, token);
},
getInventorySkuNodes(token: string, familyId: string, parentNodeId?: string | null) {
return request<InventorySkuNodeDto[]>(
`/api/v1/inventory/sku/nodes${buildQueryString({
familyId,
parentNodeId: parentNodeId ?? undefined,
})}`,
undefined,
token
);
},
getInventorySkuPreview(token: string, familyId: string, nodeId?: string | null) {
return request<InventorySkuBuilderPreviewDto>(
`/api/v1/inventory/sku/preview${buildQueryString({
familyId,
nodeId: nodeId ?? undefined,
})}`,
undefined,
token
);
},
createInventorySkuFamily(token: string, payload: InventorySkuFamilyInput) {
return request<InventorySkuFamilyDto>("/api/v1/inventory/sku/families", { method: "POST", body: JSON.stringify(payload) }, token);
},
createInventorySkuNode(token: string, payload: InventorySkuNodeInput) {
return request<InventorySkuNodeDto>("/api/v1/inventory/sku/nodes", { method: "POST", body: JSON.stringify(payload) }, token);
},
getWarehouseLocationOptions(token: string) {
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
},

View File

@@ -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(<InventoryItemsPage />) },
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
],

View File

@@ -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<InventorySkuFamilyDto[]>([]);
const [skuLevelOptions, setSkuLevelOptions] = useState<InventorySkuNodeDto[][]>([]);
const [selectedSkuNodeIds, setSelectedSkuNodeIds] = useState<Array<string | null>>([]);
const [skuPreview, setSkuPreview] = useState<InventorySkuBuilderPreviewDto | null>(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 extends keyof InventoryItemInput>(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.
</p>
</div>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
<div className="flex flex-wrap gap-2">
<Link
to="/inventory/sku-master"
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
SKU master
</Link>
<Link
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
>
Cancel
</Link>
</div>
</div>
</section>
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text">SKU</span>
<input
value={form.sku}
onChange={(event) => 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"
/>
</label>
<div className="block 2xl:col-span-2">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="block text-sm font-semibold text-text">SKU builder</span>
<Link to="/inventory/sku-master" className="text-xs font-semibold text-brand">
Manage SKU tree
</Link>
</div>
<div className="space-y-3 rounded-[18px] border border-line/70 bg-page/70 p-3">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select
value={form.skuBuilder?.familyId ?? ""}
onChange={(event) => 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"
>
<option value="">Select family</option>
{skuFamilies.filter((family) => family.isActive).map((family) => (
<option key={family.id} value={family.id}>
{family.code} - {family.name}
</option>
))}
</select>
</label>
{skuLevelOptions.length > 0 ? (
<div className="grid gap-3 xl:grid-cols-2">
{skuLevelOptions.map((options, levelIndex) => (
<label key={`sku-level-${levelIndex}`} className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Level {levelIndex + 2}</span>
<select
value={selectedSkuNodeIds[levelIndex] ?? ""}
onChange={(event) => 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"
>
<option value="">Stop at this level</option>
{options.map((option) => (
<option key={option.id} value={option.id}>
{option.code} - {option.label}
</option>
))}
</select>
</label>
))}
</div>
) : null}
<div className="rounded-2xl border border-line/70 bg-surface px-2 py-2">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Generated SKU</div>
<div className="mt-2 text-lg font-bold text-text">{skuPreview?.generatedSku || form.sku || "Select a family to generate SKU"}</div>
<div className="mt-2 text-xs text-muted">
{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."}
</div>
</div>
<input
value={form.sku}
readOnly
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none opacity-80"
/>
</div>
</div>
<label className="block 2xl:col-span-2">
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
<input

View File

@@ -51,9 +51,14 @@ export function InventoryListPage() {
</p>
</div>
{canManage ? (
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New item
</Link>
<div className="flex flex-wrap gap-2">
<Link to="/inventory/sku-master" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-3 py-2 text-sm font-semibold text-text">
SKU master
</Link>
<Link to="/inventory/items/new" className="inline-flex items-center justify-center rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">
New item
</Link>
</div>
) : null}
</div>
<div className="mt-6 grid gap-3 rounded-[18px] border border-line/70 bg-page/60 p-3 xl:grid-cols-[1.3fr_0.8fr_0.8fr]">

View File

@@ -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<InventorySkuCatalogTreeDto>({ families: [], nodes: [] });
const [familyForm, setFamilyForm] = useState<InventorySkuFamilyInput>(emptyFamilyForm);
const [nodeForm, setNodeForm] = useState<InventorySkuNodeInput>(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) => (
<div key={node.id} className="space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-3 py-3" style={{ marginLeft: `${depth * 16}px` }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-text">{node.code} <span className="text-muted">- {node.label}</span></div>
<div className="mt-1 text-xs text-muted">Level {node.level} {node.childCount} child branch(es)</div>
</div>
<button
type="button"
onClick={() =>
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
</button>
</div>
{node.description ? <div className="mt-2 text-xs text-muted">{node.description}</div> : null}
</div>
{renderNodes(node.id, depth + 1)}
</div>
));
}
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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<section className="space-y-6">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Inventory Master Data</p>
<h3 className="mt-2 text-xl font-bold text-text">SKU Master Builder</h3>
<p className="mt-2 max-w-3xl text-sm text-muted">Define family roots, branch-specific child codes, and the family-scoped short-code suffix that finishes each generated SKU.</p>
</div>
<Link to="/inventory/items" className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text">
Back to items
</Link>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.5fr]">
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Families</div>
<div className="mt-4 space-y-2">
{catalog.families.length === 0 ? (
<div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">No SKU families defined yet.</div>
) : (
catalog.families.map((family) => (
<button
key={family.id}
type="button"
onClick={() => {
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"
}`}
>
<div className="text-sm font-semibold text-text">{family.code} <span className="text-muted">({family.sequenceCode})</span></div>
<div className="mt-1 text-xs text-muted">{family.name}</div>
<div className="mt-2 text-xs text-muted">{family.childNodeCount} branch nodes next {family.sequenceCode}{String(family.nextSequenceNumber).padStart(4, "0")}</div>
</button>
))
)}
</div>
</section>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Add family</div>
<form className="mt-4 space-y-3" onSubmit={handleCreateFamily}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family code</span>
<input value={familyForm.code} onChange={(event) => setFamilyForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Suffix code</span>
<input value={familyForm.sequenceCode} onChange={(event) => setFamilyForm((current) => ({ ...current, sequenceCode: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Name</span>
<input value={familyForm.name} onChange={(event) => setFamilyForm((current) => ({ ...current, name: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={familyForm.description} onChange={(event) => setFamilyForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white">Create family</button>
</form>
</section>
) : null}
</div>
<div className="space-y-6">
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
<div>
<div className="text-sm font-semibold text-text">Branch tree</div>
<div className="mt-1 text-xs text-muted">{status}</div>
</div>
{selectedFamilyId ? (
<div className="text-xs text-muted">Up to 6 total SKU levels including family root.</div>
) : null}
</div>
<div className="mt-4 space-y-3">
{selectedFamilyId ? renderNodes(null) : <div className="rounded-[18px] border border-dashed border-line/70 bg-page/60 px-4 py-6 text-sm text-muted">Select a family to inspect or extend its branch tree.</div>}
</div>
</section>
{canManage ? (
<section className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
<div className="text-sm font-semibold text-text">Add branch node</div>
<form className="mt-4 space-y-3" onSubmit={handleCreateNode}>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Family</span>
<select value={nodeForm.familyId} onChange={(event) => setNodeForm((current) => ({ ...current, familyId: event.target.value, parentNodeId: null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Select family</option>
{catalog.families.map((family) => (
<option key={family.id} value={family.id}>{family.code} - {family.name}</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Parent branch</span>
<select value={nodeForm.parentNodeId ?? ""} onChange={(event) => setNodeForm((current) => ({ ...current, parentNodeId: event.target.value || null }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand">
<option value="">Family root</option>
{parentOptions.map((node) => (
<option key={node.id} value={node.id}>L{node.level} - {node.code} - {node.label}</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Code</span>
<input value={nodeForm.code} onChange={(event) => setNodeForm((current) => ({ ...current, code: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Label</span>
<input value={nodeForm.label} onChange={(event) => setNodeForm((current) => ({ ...current, label: event.target.value }))} className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand" />
</label>
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<label className="block">
<span className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Description</span>
<textarea value={nodeForm.description} onChange={(event) => setNodeForm((current) => ({ ...current, description: event.target.value }))} rows={3} className="w-full rounded-[18px] 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-xs font-semibold uppercase tracking-[0.16em] text-muted">Sort</span>
<input type="number" min={0} step={10} value={nodeForm.sortOrder} onChange={(event) => setNodeForm((current) => ({ ...current, sortOrder: Number(event.target.value) || 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>
<button type="submit" className="rounded-2xl bg-brand px-3 py-2 text-sm font-semibold text-white" disabled={!nodeForm.familyId}>Create branch</button>
</form>
</section>
) : null}
</div>
</div>
</section>
);
}

View File

@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
export const emptyInventoryItemInput: InventoryItemInput = {
sku: "",
skuBuilder: null,
name: "",
description: "",
type: "PURCHASED",

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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<string, InventoryStockBalanceDto>();
@@ -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<InventorySkuBuilderSelectionDto> {
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<InventorySkuCatalogTreeDto> {
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<InventorySkuBuilderPreviewDto | null> {
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,

View File

@@ -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;