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
|
- CRM customers/vendors, hierarchy, contacts, lifecycle metadata, and attachments
|
||||||
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
|
- inventory items, BOMs, warehouses, locations, transactions, item attachments, and item pricing
|
||||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
- 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
|
- 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 revision history and revision comparison across document and receipt changes
|
||||||
- purchase-order supporting documents and vendor-side purchasing visibility
|
- 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
|
### 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
|
- 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
|
- 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
|
- 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
|
- CRM foundation through reseller hierarchy, contacts, attachments, and lifecycle metadata
|
||||||
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
- inventory master data, BOM, warehouse, stock-location, transactions, and item attachments
|
||||||
- inventory transfers, reservations, available-stock visibility, and work-order reservation automation
|
- 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 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
|
- 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
|
- 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
|
- CRM contact history, account contacts, and shared attachments
|
||||||
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
- inventory item master, BOM, warehouse, stock-location, and stock-transaction flows
|
||||||
- inventory transfers, reservations, and available-stock visibility
|
- 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 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
|
- 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
|
- purchase orders with searchable vendor and SKU entry, restricted to purchasable inventory items
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ import type {
|
|||||||
InventoryItemDetailDto,
|
InventoryItemDetailDto,
|
||||||
InventoryItemInput,
|
InventoryItemInput,
|
||||||
InventoryItemOptionDto,
|
InventoryItemOptionDto,
|
||||||
|
InventorySkuBuilderPreviewDto,
|
||||||
|
InventorySkuCatalogTreeDto,
|
||||||
|
InventorySkuFamilyDto,
|
||||||
|
InventorySkuFamilyInput,
|
||||||
|
InventorySkuNodeDto,
|
||||||
|
InventorySkuNodeInput,
|
||||||
InventoryReservationInput,
|
InventoryReservationInput,
|
||||||
InventoryItemStatus,
|
InventoryItemStatus,
|
||||||
InventoryItemSummaryDto,
|
InventoryItemSummaryDto,
|
||||||
@@ -430,6 +436,38 @@ export const api = {
|
|||||||
getInventoryItemOptions(token: string) {
|
getInventoryItemOptions(token: string) {
|
||||||
return request<InventoryItemOptionDto[]>("/api/v1/inventory/items/options", undefined, token);
|
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) {
|
getWarehouseLocationOptions(token: string) {
|
||||||
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
|
return request<WarehouseLocationOptionDto[]>("/api/v1/inventory/locations/options", undefined, token);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const InventoryDetailPage = React.lazy(() =>
|
|||||||
const InventoryFormPage = React.lazy(() =>
|
const InventoryFormPage = React.lazy(() =>
|
||||||
import("./modules/inventory/InventoryFormPage").then((module) => ({ default: module.InventoryFormPage }))
|
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(() =>
|
const WarehousesPage = React.lazy(() =>
|
||||||
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
|
import("./modules/inventory/WarehousesPage").then((module) => ({ default: module.WarehousesPage }))
|
||||||
);
|
);
|
||||||
@@ -148,6 +151,7 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
|
{ path: "/inventory/items", element: lazyElement(<InventoryItemsPage />) },
|
||||||
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
|
{ path: "/inventory/items/:itemId", element: lazyElement(<InventoryDetailPage />) },
|
||||||
|
{ path: "/inventory/sku-master", element: lazyElement(<InventorySkuMasterPage />) },
|
||||||
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
|
{ path: "/inventory/warehouses", element: lazyElement(<WarehousesPage />) },
|
||||||
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
|
{ path: "/inventory/warehouses/:warehouseId", element: lazyElement(<WarehouseDetailPage />) },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import type { PurchaseVendorOptionDto } from "@mrp/shared";
|
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 type { ManufacturingStationDto } from "@mrp/shared";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
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 [status, setStatus] = useState(mode === "create" ? "Create a new inventory item." : "Loading inventory item...");
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [pendingRemoval, setPendingRemoval] = useState<{ kind: "operation" | "bom-line"; index: number } | null>(null);
|
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) {
|
function getComponentOption(componentItemId: string) {
|
||||||
return componentOptions.find((option) => option.id === componentItemId) ?? null;
|
return componentOptions.find((option) => option.id === componentItemId) ?? null;
|
||||||
@@ -37,6 +49,14 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
return getComponentOption(componentItemId)?.sku ?? "";
|
return getComponentOption(componentItemId)?.sku ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSkuFamilyName(familyId: string | null) {
|
||||||
|
if (!familyId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return skuFamilies.find((family) => family.id === familyId)?.name ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return;
|
return;
|
||||||
@@ -71,6 +91,7 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
.then((item) => {
|
.then((item) => {
|
||||||
setForm({
|
setForm({
|
||||||
sku: item.sku,
|
sku: item.sku,
|
||||||
|
skuBuilder: item.skuBuilder ? { familyId: item.skuBuilder.familyId, nodeId: item.skuBuilder.nodeId } : null,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
@@ -98,6 +119,8 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
notes: operation.notes,
|
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));
|
setComponentSearchTerms(item.bomLines.map((line) => line.componentSku));
|
||||||
setStatus("Inventory item loaded.");
|
setStatus("Inventory item loaded.");
|
||||||
setVendorSearchTerm(item.preferredVendorName ?? "");
|
setVendorSearchTerm(item.preferredVendorName ?? "");
|
||||||
@@ -115,12 +138,104 @@ export function InventoryFormPage({ mode }: InventoryFormPageProps) {
|
|||||||
|
|
||||||
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
|
api.getManufacturingStations(token).then(setStations).catch(() => setStations([]));
|
||||||
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
|
api.getPurchaseVendors(token).then(setVendorOptions).catch(() => setVendorOptions([]));
|
||||||
|
api.getInventorySkuFamilies(token).then(setSkuFamilies).catch(() => setSkuFamilies([]));
|
||||||
}, [token]);
|
}, [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]) {
|
function updateField<Key extends keyof InventoryItemInput>(key: Key, value: InventoryItemInput[Key]) {
|
||||||
setForm((current) => ({ ...current, [key]: value }));
|
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) {
|
function getSelectedVendorName(vendorId: string | null) {
|
||||||
if (!vendorId) {
|
if (!vendorId) {
|
||||||
return "";
|
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.
|
Define item master data and the first revision of the bill of materials for assemblies and manufactured items.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<div className="flex flex-wrap gap-2">
|
||||||
to={mode === "create" ? "/inventory/items" : `/inventory/items/${itemId}`}
|
<Link
|
||||||
className="inline-flex items-center justify-center rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text"
|
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"
|
||||||
Cancel
|
>
|
||||||
</Link>
|
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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="space-y-4 rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
|
<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">
|
<div className="grid gap-3 xl:grid-cols-2 2xl:grid-cols-4">
|
||||||
<label className="block">
|
<div className="block 2xl:col-span-2">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">SKU</span>
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<input
|
<span className="block text-sm font-semibold text-text">SKU builder</span>
|
||||||
value={form.sku}
|
<Link to="/inventory/sku-master" className="text-xs font-semibold text-brand">
|
||||||
onChange={(event) => updateField("sku", event.target.value)}
|
Manage SKU tree
|
||||||
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-text outline-none transition focus:border-brand"
|
</Link>
|
||||||
/>
|
</div>
|
||||||
</label>
|
<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">
|
<label className="block 2xl:col-span-2">
|
||||||
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
|
<span className="mb-2 block text-sm font-semibold text-text">Item name</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -51,9 +51,14 @@ export function InventoryListPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{canManage ? (
|
{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">
|
<div className="flex flex-wrap gap-2">
|
||||||
New item
|
<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">
|
||||||
</Link>
|
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}
|
) : null}
|
||||||
</div>
|
</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]">
|
<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 = {
|
export const emptyInventoryItemInput: InventoryItemInput = {
|
||||||
sku: "",
|
sku: "",
|
||||||
|
skuBuilder: null,
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
type: "PURCHASED",
|
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 {
|
model InventoryItem {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
sku String @unique
|
sku String @unique
|
||||||
|
skuFamilyId String?
|
||||||
|
skuNodeId String?
|
||||||
|
skuSequenceNumber Int?
|
||||||
name String
|
name String
|
||||||
description String
|
description String
|
||||||
type String
|
type String
|
||||||
@@ -163,8 +166,48 @@ model InventoryItem {
|
|||||||
reservations InventoryReservation[]
|
reservations InventoryReservation[]
|
||||||
transfers InventoryTransfer[]
|
transfers InventoryTransfer[]
|
||||||
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
|
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([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 {
|
model Warehouse {
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ import { requirePermissions } from "../../lib/rbac.js";
|
|||||||
import {
|
import {
|
||||||
createInventoryItem,
|
createInventoryItem,
|
||||||
createInventoryReservation,
|
createInventoryReservation,
|
||||||
|
createInventorySkuFamily,
|
||||||
|
createInventorySkuNode,
|
||||||
createInventoryTransfer,
|
createInventoryTransfer,
|
||||||
createInventoryTransaction,
|
createInventoryTransaction,
|
||||||
createWarehouse,
|
createWarehouse,
|
||||||
getInventoryItemById,
|
getInventoryItemById,
|
||||||
|
listInventorySkuCatalog,
|
||||||
|
listInventorySkuFamilies,
|
||||||
|
listInventorySkuNodeOptions,
|
||||||
|
previewInventorySku,
|
||||||
getWarehouseById,
|
getWarehouseById,
|
||||||
listInventoryItemOptions,
|
listInventoryItemOptions,
|
||||||
listInventoryItems,
|
listInventoryItems,
|
||||||
@@ -40,6 +46,12 @@ const operationSchema = z.object({
|
|||||||
|
|
||||||
const inventoryItemSchema = z.object({
|
const inventoryItemSchema = z.object({
|
||||||
sku: z.string().trim().min(1).max(64),
|
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),
|
name: z.string().trim().min(1).max(160),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
type: z.enum(inventoryItemTypes),
|
type: z.enum(inventoryItemTypes),
|
||||||
@@ -99,6 +111,34 @@ const warehouseSchema = z.object({
|
|||||||
locations: z.array(warehouseLocationSchema),
|
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) {
|
function getRouteParam(value: unknown) {
|
||||||
return typeof value === "string" ? value : null;
|
return typeof value === "string" ? value : null;
|
||||||
}
|
}
|
||||||
@@ -125,6 +165,46 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
|
|||||||
return ok(response, await listInventoryItemOptions());
|
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) => {
|
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
|
||||||
return ok(response, await listWarehouseLocationOptions());
|
return ok(response, await listWarehouseLocationOptions());
|
||||||
});
|
});
|
||||||
@@ -157,6 +237,34 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
|
|||||||
return ok(response, item, 201);
|
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) => {
|
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
|
||||||
const itemId = getRouteParam(request.params.itemId);
|
const itemId = getRouteParam(request.params.itemId);
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import type {
|
|||||||
InventoryItemDetailDto,
|
InventoryItemDetailDto,
|
||||||
InventoryItemInput,
|
InventoryItemInput,
|
||||||
InventoryItemOperationDto,
|
InventoryItemOperationDto,
|
||||||
|
InventorySkuBuilderInput,
|
||||||
|
InventorySkuBuilderPreviewDto,
|
||||||
|
InventorySkuBuilderSelectionDto,
|
||||||
|
InventorySkuCatalogTreeDto,
|
||||||
|
InventorySkuFamilyDto,
|
||||||
|
InventorySkuFamilyInput,
|
||||||
|
InventorySkuNodeDto,
|
||||||
|
InventorySkuNodeInput,
|
||||||
InventoryReservationDto,
|
InventoryReservationDto,
|
||||||
InventoryReservationInput,
|
InventoryReservationInput,
|
||||||
InventoryReservationStatus,
|
InventoryReservationStatus,
|
||||||
@@ -58,6 +66,9 @@ type OperationRecord = {
|
|||||||
type InventoryDetailRecord = {
|
type InventoryDetailRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
|
skuFamilyId: string | null;
|
||||||
|
skuNodeId: string | null;
|
||||||
|
skuSequenceNumber: number | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -74,6 +85,20 @@ type InventoryDetailRecord = {
|
|||||||
notes: string;
|
notes: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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[];
|
bomLines: BomLineRecord[];
|
||||||
operations: OperationRecord[];
|
operations: OperationRecord[];
|
||||||
inventoryTransactions: InventoryTransactionRecord[];
|
inventoryTransactions: InventoryTransactionRecord[];
|
||||||
@@ -176,6 +201,36 @@ type InventoryTransferRecord = {
|
|||||||
} | null;
|
} | 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 {
|
function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
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[] {
|
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
|
||||||
const grouped = new Map<string, 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
|
const recentTransactions = record.inventoryTransactions
|
||||||
.slice()
|
.slice()
|
||||||
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
|
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
|
||||||
@@ -429,6 +522,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
|
|||||||
defaultPrice: record.defaultPrice,
|
defaultPrice: record.defaultPrice,
|
||||||
preferredVendorId: record.preferredVendor?.id ?? null,
|
preferredVendorId: record.preferredVendor?.id ?? null,
|
||||||
preferredVendorName: record.preferredVendor?.name ?? null,
|
preferredVendorName: record.preferredVendor?.name ?? null,
|
||||||
|
skuBuilder,
|
||||||
notes: record.notes,
|
notes: record.notes,
|
||||||
createdAt: record.createdAt.toISOString(),
|
createdAt: record.createdAt.toISOString(),
|
||||||
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
|
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
|
||||||
@@ -533,6 +627,156 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
|
|||||||
.filter((location) => location.code.length > 0 && location.name.length > 0);
|
.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) {
|
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
|
||||||
const transactions = await prisma.inventoryTransaction.findMany({
|
const transactions = await prisma.inventoryTransaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -672,6 +916,188 @@ async function getActiveReservedQuantity(itemId: string, warehouseId: string, lo
|
|||||||
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
|
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 = {}) {
|
export async function listInventoryItems(filters: InventoryListFilters = {}) {
|
||||||
const items = await prisma.inventoryItem.findMany({
|
const items = await prisma.inventoryItem.findMany({
|
||||||
where: buildWhereClause(filters),
|
where: buildWhereClause(filters),
|
||||||
@@ -740,6 +1166,24 @@ export async function getInventoryItemById(itemId: string) {
|
|||||||
const item = await prisma.inventoryItem.findUnique({
|
const item = await prisma.inventoryItem.findUnique({
|
||||||
where: { id: itemId },
|
where: { id: itemId },
|
||||||
include: {
|
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: {
|
bomLines: {
|
||||||
include: {
|
include: {
|
||||||
componentItem: {
|
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() {
|
export async function listWarehouseLocationOptions() {
|
||||||
@@ -1120,35 +1573,89 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
|
|||||||
if (!validatedPreferredVendor.ok) {
|
if (!validatedPreferredVendor.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
|
||||||
|
if (!validatedSku.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await prisma.inventoryItem.create({
|
const item = await prisma.$transaction(async (transaction) => {
|
||||||
data: {
|
if (validatedSku.family) {
|
||||||
sku: payload.sku,
|
const sequenceNumber = validatedSku.family.nextSequenceNumber;
|
||||||
name: payload.name,
|
await transaction.inventorySkuFamily.update({
|
||||||
description: payload.description,
|
where: { id: validatedSku.family.id },
|
||||||
type: payload.type,
|
data: {
|
||||||
status: payload.status,
|
nextSequenceNumber: {
|
||||||
unitOfMeasure: payload.unitOfMeasure,
|
increment: 1,
|
||||||
isSellable: payload.isSellable,
|
},
|
||||||
isPurchasable: payload.isPurchasable,
|
},
|
||||||
preferredVendorId: payload.preferredVendorId,
|
});
|
||||||
defaultCost: payload.defaultCost,
|
|
||||||
defaultPrice: payload.defaultPrice,
|
return transaction.inventoryItem.create({
|
||||||
notes: payload.notes,
|
data: {
|
||||||
bomLines: validatedBom.bomLines.length
|
sku: formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, sequenceNumber),
|
||||||
? {
|
skuFamilyId: validatedSku.family.id,
|
||||||
create: validatedBom.bomLines,
|
skuNodeId: validatedSku.node?.id ?? null,
|
||||||
}
|
skuSequenceNumber: sequenceNumber,
|
||||||
: undefined,
|
name: payload.name,
|
||||||
operations: validatedOperations.operations.length
|
description: payload.description,
|
||||||
? {
|
type: payload.type,
|
||||||
create: validatedOperations.operations,
|
status: payload.status,
|
||||||
}
|
unitOfMeasure: payload.unitOfMeasure,
|
||||||
: undefined,
|
isSellable: payload.isSellable,
|
||||||
},
|
isPurchasable: payload.isPurchasable,
|
||||||
select: {
|
preferredVendorId: payload.preferredVendorId,
|
||||||
id: true,
|
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({
|
await logAuditEvent({
|
||||||
@@ -1156,9 +1663,11 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
|
|||||||
entityType: "inventory-item",
|
entityType: "inventory-item",
|
||||||
entityId: item.id,
|
entityId: item.id,
|
||||||
action: "created",
|
action: "created",
|
||||||
summary: `Created inventory item ${payload.sku}.`,
|
summary: `Created inventory item ${validatedSku.family ? "generated SKU" : payload.sku}.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
sku: payload.sku,
|
sku: payload.sku,
|
||||||
|
skuFamilyId: validatedSku.family?.id ?? null,
|
||||||
|
skuNodeId: validatedSku.node?.id ?? null,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
@@ -1189,34 +1698,75 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
|||||||
if (!validatedPreferredVendor.ok) {
|
if (!validatedPreferredVendor.ok) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
|
||||||
|
if (!validatedSku.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const item = await prisma.inventoryItem.update({
|
const item = await prisma.$transaction(async (transaction) => {
|
||||||
where: { id: itemId },
|
const shouldKeepExistingGeneratedSku =
|
||||||
data: {
|
validatedSku.family &&
|
||||||
sku: payload.sku,
|
existingItem.skuFamilyId === validatedSku.family.id &&
|
||||||
name: payload.name,
|
existingItem.skuNodeId === (validatedSku.node?.id ?? null) &&
|
||||||
description: payload.description,
|
existingItem.skuSequenceNumber != null;
|
||||||
type: payload.type,
|
|
||||||
status: payload.status,
|
let sku = payload.sku;
|
||||||
unitOfMeasure: payload.unitOfMeasure,
|
let skuFamilyId: string | null = null;
|
||||||
isSellable: payload.isSellable,
|
let skuNodeId: string | null = null;
|
||||||
isPurchasable: payload.isPurchasable,
|
let skuSequenceNumber: number | null = null;
|
||||||
preferredVendorId: payload.preferredVendorId,
|
|
||||||
defaultCost: payload.defaultCost,
|
if (validatedSku.family) {
|
||||||
defaultPrice: payload.defaultPrice,
|
skuFamilyId = validatedSku.family.id;
|
||||||
notes: payload.notes,
|
skuNodeId = validatedSku.node?.id ?? null;
|
||||||
bomLines: {
|
|
||||||
deleteMany: {},
|
if (shouldKeepExistingGeneratedSku) {
|
||||||
create: validatedBom.bomLines,
|
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: {
|
select: {
|
||||||
deleteMany: {},
|
id: true,
|
||||||
create: validatedOperations.operations,
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await logAuditEvent({
|
await logAuditEvent({
|
||||||
@@ -1227,6 +1777,8 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
|
|||||||
summary: `Updated inventory item ${payload.sku}.`,
|
summary: `Updated inventory item ${payload.sku}.`,
|
||||||
metadata: {
|
metadata: {
|
||||||
sku: payload.sku,
|
sku: payload.sku,
|
||||||
|
skuFamilyId: validatedSku.family?.id ?? null,
|
||||||
|
skuNodeId: validatedSku.node?.id ?? null,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
status: payload.status,
|
status: payload.status,
|
||||||
|
|||||||
@@ -10,6 +10,85 @@ export type InventoryUnitOfMeasure = (typeof inventoryUnitsOfMeasure)[number];
|
|||||||
export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number];
|
export type InventoryTransactionType = (typeof inventoryTransactionTypes)[number];
|
||||||
export type InventoryReservationStatus = (typeof inventoryReservationStatuses)[number];
|
export 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 {
|
export interface InventoryBomLineDto {
|
||||||
id: string;
|
id: string;
|
||||||
componentItemId: string;
|
componentItemId: string;
|
||||||
@@ -217,6 +296,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
|
|||||||
defaultPrice: number | null;
|
defaultPrice: number | null;
|
||||||
preferredVendorId: string | null;
|
preferredVendorId: string | null;
|
||||||
preferredVendorName: string | null;
|
preferredVendorName: string | null;
|
||||||
|
skuBuilder: InventorySkuBuilderSelectionDto | null;
|
||||||
notes: string;
|
notes: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
bomLines: InventoryBomLineDto[];
|
bomLines: InventoryBomLineDto[];
|
||||||
@@ -232,6 +312,7 @@ export interface InventoryItemDetailDto extends InventoryItemSummaryDto {
|
|||||||
|
|
||||||
export interface InventoryItemInput {
|
export interface InventoryItemInput {
|
||||||
sku: string;
|
sku: string;
|
||||||
|
skuBuilder: InventorySkuBuilderInput | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
type: InventoryItemType;
|
type: InventoryItemType;
|
||||||
|
|||||||
Reference in New Issue
Block a user