sku builder first test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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 />) },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]">
|
||||
|
||||
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal file
298
client/src/modules/inventory/InventorySkuMasterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const emptyInventoryOperationInput: InventoryItemOperationInput = {
|
||||
|
||||
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||
sku: "",
|
||||
skuBuilder: null,
|
||||
name: "",
|
||||
description: "",
|
||||
type: "PURCHASED",
|
||||
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user