sku builder first test

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

View File

@@ -0,0 +1,78 @@
-- CreateTable
CREATE TABLE "InventorySkuFamily" (
"id" TEXT NOT NULL PRIMARY KEY,
"code" TEXT NOT NULL,
"sequenceCode" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"nextSequenceNumber" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "InventorySkuNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"familyId" TEXT NOT NULL,
"parentNodeId" TEXT,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT NOT NULL,
"path" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventorySkuNode_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "InventorySkuNode_parentNodeId_fkey" FOREIGN KEY ("parentNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_InventoryItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"sku" TEXT NOT NULL,
"skuFamilyId" TEXT,
"skuNodeId" TEXT,
"skuSequenceNumber" INTEGER,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"unitOfMeasure" TEXT NOT NULL,
"isSellable" BOOLEAN NOT NULL DEFAULT true,
"isPurchasable" BOOLEAN NOT NULL DEFAULT true,
"preferredVendorId" TEXT,
"defaultCost" REAL,
"defaultPrice" REAL,
"notes" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "InventoryItem_preferredVendorId_fkey" FOREIGN KEY ("preferredVendorId") REFERENCES "Vendor" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuFamilyId_fkey" FOREIGN KEY ("skuFamilyId") REFERENCES "InventorySkuFamily" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "InventoryItem_skuNodeId_fkey" FOREIGN KEY ("skuNodeId") REFERENCES "InventorySkuNode" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_InventoryItem" ("createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt") SELECT "createdAt", "defaultCost", "defaultPrice", "description", "id", "isPurchasable", "isSellable", "name", "notes", "preferredVendorId", "sku", "status", "type", "unitOfMeasure", "updatedAt" FROM "InventoryItem";
DROP TABLE "InventoryItem";
ALTER TABLE "new_InventoryItem" RENAME TO "InventoryItem";
CREATE UNIQUE INDEX "InventoryItem_sku_key" ON "InventoryItem"("sku");
CREATE INDEX "InventoryItem_preferredVendorId_idx" ON "InventoryItem"("preferredVendorId");
CREATE INDEX "InventoryItem_skuFamilyId_idx" ON "InventoryItem"("skuFamilyId");
CREATE INDEX "InventoryItem_skuNodeId_idx" ON "InventoryItem"("skuNodeId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_code_key" ON "InventorySkuFamily"("code");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuFamily_sequenceCode_key" ON "InventorySkuFamily"("sequenceCode");
-- CreateIndex
CREATE UNIQUE INDEX "InventorySkuNode_familyId_path_key" ON "InventorySkuNode"("familyId", "path");
-- CreateIndex
CREATE INDEX "InventorySkuNode_familyId_parentNodeId_sortOrder_idx" ON "InventorySkuNode"("familyId", "parentNodeId", "sortOrder");

View File

@@ -138,6 +138,9 @@ model FileAttachment {
model InventoryItem {
id String @id @default(cuid())
sku String @unique
skuFamilyId String?
skuNodeId String?
skuSequenceNumber Int?
name String
description String
type String
@@ -163,8 +166,48 @@ model InventoryItem {
reservations InventoryReservation[]
transfers InventoryTransfer[]
preferredVendor Vendor? @relation(fields: [preferredVendorId], references: [id], onDelete: SetNull)
skuFamily InventorySkuFamily? @relation(fields: [skuFamilyId], references: [id], onDelete: SetNull)
skuNode InventorySkuNode? @relation(fields: [skuNodeId], references: [id], onDelete: SetNull)
@@index([preferredVendorId])
@@index([skuFamilyId])
@@index([skuNodeId])
}
model InventorySkuFamily {
id String @id @default(cuid())
code String @unique
sequenceCode String @unique
name String
description String
nextSequenceNumber Int @default(1)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
nodes InventorySkuNode[]
items InventoryItem[]
}
model InventorySkuNode {
id String @id @default(cuid())
familyId String
parentNodeId String?
code String
label String
description String
path String
level Int
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
family InventorySkuFamily @relation(fields: [familyId], references: [id], onDelete: Cascade)
parentNode InventorySkuNode? @relation("InventorySkuNodeTree", fields: [parentNodeId], references: [id], onDelete: Cascade)
childNodes InventorySkuNode[] @relation("InventorySkuNodeTree")
items InventoryItem[]
@@unique([familyId, path])
@@index([familyId, parentNodeId, sortOrder])
}
model Warehouse {

View File

@@ -8,10 +8,16 @@ import { requirePermissions } from "../../lib/rbac.js";
import {
createInventoryItem,
createInventoryReservation,
createInventorySkuFamily,
createInventorySkuNode,
createInventoryTransfer,
createInventoryTransaction,
createWarehouse,
getInventoryItemById,
listInventorySkuCatalog,
listInventorySkuFamilies,
listInventorySkuNodeOptions,
previewInventorySku,
getWarehouseById,
listInventoryItemOptions,
listInventoryItems,
@@ -40,6 +46,12 @@ const operationSchema = z.object({
const inventoryItemSchema = z.object({
sku: z.string().trim().min(1).max(64),
skuBuilder: z
.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable(),
})
.nullable(),
name: z.string().trim().min(1).max(160),
description: z.string(),
type: z.enum(inventoryItemTypes),
@@ -99,6 +111,34 @@ const warehouseSchema = z.object({
locations: z.array(warehouseLocationSchema),
});
const skuFamilySchema = z.object({
code: z.string().trim().min(2).max(12),
sequenceCode: z.string().trim().min(2).max(2),
name: z.string().trim().min(1).max(160),
description: z.string(),
isActive: z.boolean(),
});
const skuNodeSchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable(),
code: z.string().trim().min(1).max(32),
label: z.string().trim().min(1).max(160),
description: z.string(),
sortOrder: z.number().int().nonnegative(),
isActive: z.boolean(),
});
const skuPreviewQuerySchema = z.object({
familyId: z.string().trim().min(1),
nodeId: z.string().trim().min(1).nullable().optional(),
});
const skuNodeOptionsQuerySchema = z.object({
familyId: z.string().trim().min(1),
parentNodeId: z.string().trim().min(1).nullable().optional(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -125,6 +165,46 @@ inventoryRouter.get("/items/options", requirePermissions([permissions.inventoryR
return ok(response, await listInventoryItemOptions());
});
inventoryRouter.get("/sku/families", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuFamilies());
});
inventoryRouter.get("/sku/catalog", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listInventorySkuCatalog());
});
inventoryRouter.get("/sku/nodes", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuNodeOptionsQuerySchema.safeParse({
familyId: request.query.familyId,
parentNodeId: request.query.parentNodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU node filters are invalid.");
}
return ok(response, await listInventorySkuNodeOptions(parsed.data.familyId, parsed.data.parentNodeId ?? null));
});
inventoryRouter.get("/sku/preview", requirePermissions([permissions.inventoryRead]), async (request, response) => {
const parsed = skuPreviewQuerySchema.safeParse({
familyId: request.query.familyId,
nodeId: request.query.nodeId ?? null,
});
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
const preview = await previewInventorySku({
familyId: parsed.data.familyId,
nodeId: parsed.data.nodeId ?? null,
});
if (!preview) {
return fail(response, 400, "INVALID_INPUT", "SKU preview request is invalid.");
}
return ok(response, preview);
});
inventoryRouter.get("/locations/options", requirePermissions([permissions.inventoryRead]), async (_request, response) => {
return ok(response, await listWarehouseLocationOptions());
});
@@ -157,6 +237,34 @@ inventoryRouter.post("/items", requirePermissions([permissions.inventoryWrite]),
return ok(response, item, 201);
});
inventoryRouter.post("/sku/families", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuFamilySchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
const family = await createInventorySkuFamily(parsed.data);
if (!family) {
return fail(response, 400, "INVALID_INPUT", "SKU family payload is invalid.");
}
return ok(response, family, 201);
});
inventoryRouter.post("/sku/nodes", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const parsed = skuNodeSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
const node = await createInventorySkuNode(parsed.data);
if (!node) {
return fail(response, 400, "INVALID_INPUT", "SKU branch payload is invalid.");
}
return ok(response, node, 201);
});
inventoryRouter.put("/items/:itemId", requirePermissions([permissions.inventoryWrite]), async (request, response) => {
const itemId = getRouteParam(request.params.itemId);
if (!itemId) {

View File

@@ -4,6 +4,14 @@ import type {
InventoryItemDetailDto,
InventoryItemInput,
InventoryItemOperationDto,
InventorySkuBuilderInput,
InventorySkuBuilderPreviewDto,
InventorySkuBuilderSelectionDto,
InventorySkuCatalogTreeDto,
InventorySkuFamilyDto,
InventorySkuFamilyInput,
InventorySkuNodeDto,
InventorySkuNodeInput,
InventoryReservationDto,
InventoryReservationInput,
InventoryReservationStatus,
@@ -58,6 +66,9 @@ type OperationRecord = {
type InventoryDetailRecord = {
id: string;
sku: string;
skuFamilyId: string | null;
skuNodeId: string | null;
skuSequenceNumber: number | null;
name: string;
description: string;
type: string;
@@ -74,6 +85,20 @@ type InventoryDetailRecord = {
notes: string;
createdAt: Date;
updatedAt: Date;
skuFamily: {
id: string;
code: string;
sequenceCode: string;
name: string;
} | null;
skuNode: {
id: string;
code: string;
label: string;
path: string;
level: number;
parentNodeId: string | null;
} | null;
bomLines: BomLineRecord[];
operations: OperationRecord[];
inventoryTransactions: InventoryTransactionRecord[];
@@ -176,6 +201,36 @@ type InventoryTransferRecord = {
} | null;
};
type InventorySkuFamilyRecord = {
id: string;
code: string;
sequenceCode: string;
name: string;
description: string;
nextSequenceNumber: number;
isActive: boolean;
_count: {
nodes: number;
items: number;
};
};
type InventorySkuNodeRecord = {
id: string;
familyId: string;
parentNodeId: string | null;
code: string;
label: string;
description: string;
path: string;
level: number;
sortOrder: number;
isActive: boolean;
_count: {
childNodes: number;
};
};
function mapBomLine(record: BomLineRecord): InventoryBomLineDto {
return {
id: record.id,
@@ -280,6 +335,44 @@ function mapTransfer(record: InventoryTransferRecord): InventoryTransferDto {
};
}
function mapSkuFamily(record: InventorySkuFamilyRecord): InventorySkuFamilyDto {
return {
id: record.id,
code: record.code,
sequenceCode: record.sequenceCode,
name: record.name,
description: record.description,
nextSequenceNumber: record.nextSequenceNumber,
isActive: record.isActive,
childNodeCount: record._count.nodes,
itemCount: record._count.items,
};
}
function mapSkuNode(record: InventorySkuNodeRecord): InventorySkuNodeDto {
return {
id: record.id,
familyId: record.familyId,
parentNodeId: record.parentNodeId,
code: record.code,
label: record.label,
description: record.description,
path: record.path,
level: record.level,
sortOrder: record.sortOrder,
isActive: record.isActive,
childCount: record._count.childNodes,
};
}
function formatSkuSequence(sequenceNumber: number) {
return sequenceNumber.toString().padStart(4, "0");
}
function formatGeneratedSku(sequenceCode: string, segments: string[], sequenceNumber: number) {
return [...segments, `${sequenceCode}${formatSkuSequence(sequenceNumber)}`].join("-");
}
function buildStockBalances(transactions: InventoryTransactionRecord[], reservations: InventoryReservationRecord[]): InventoryStockBalanceDto[] {
const grouped = new Map<string, InventoryStockBalanceDto>();
@@ -390,7 +483,7 @@ function mapSummary(record: {
};
}
function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
function mapDetail(record: InventoryDetailRecord, skuBuilder: InventorySkuBuilderSelectionDto | null): InventoryItemDetailDto {
const recentTransactions = record.inventoryTransactions
.slice()
.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
@@ -429,6 +522,7 @@ function mapDetail(record: InventoryDetailRecord): InventoryItemDetailDto {
defaultPrice: record.defaultPrice,
preferredVendorId: record.preferredVendor?.id ?? null,
preferredVendorName: record.preferredVendor?.name ?? null,
skuBuilder,
notes: record.notes,
createdAt: record.createdAt.toISOString(),
bomLines: record.bomLines.slice().sort((a, b) => a.position - b.position).map(mapBomLine),
@@ -533,6 +627,156 @@ function normalizeWarehouseLocations(locations: WarehouseLocationInput[]) {
.filter((location) => location.code.length > 0 && location.name.length > 0);
}
function normalizeSkuFamilyInput(input: InventorySkuFamilyInput) {
return {
code: input.code.trim().toUpperCase(),
sequenceCode: input.sequenceCode.trim().toUpperCase(),
name: input.name.trim(),
description: input.description.trim(),
isActive: input.isActive,
};
}
function normalizeSkuNodeInput(input: InventorySkuNodeInput) {
return {
familyId: input.familyId,
parentNodeId: input.parentNodeId,
code: input.code.trim(),
label: input.label.trim(),
description: input.description.trim(),
sortOrder: Number(input.sortOrder ?? 0),
isActive: input.isActive,
};
}
async function getSkuNodeLineage(nodeId: string) {
const lineage: Array<{
id: string;
familyId: string;
parentNodeId: string | null;
code: string;
label: string;
path: string;
level: number;
}> = [];
type SkuLineageNode = (typeof lineage)[number];
let currentId: string | null = nodeId;
while (currentId) {
const currentNode: SkuLineageNode | null = await prisma.inventorySkuNode.findUnique({
where: { id: currentId },
select: {
id: true,
familyId: true,
parentNodeId: true,
code: true,
label: true,
path: true,
level: true,
},
});
if (!currentNode) {
return null;
}
lineage.unshift(currentNode);
currentId = currentNode.parentNodeId;
}
return lineage;
}
async function buildSkuBuilderSelection(
family: { id: string; code: string; name: string; sequenceCode: string },
nodeId: string | null,
sequenceNumber: number | null
): Promise<InventorySkuBuilderSelectionDto> {
const lineage = nodeId ? await getSkuNodeLineage(nodeId) : [];
const segments = [family.code, ...(lineage ?? []).map((node) => node.code)];
const effectiveSequenceNumber = sequenceNumber ?? 0;
return {
familyId: family.id,
familyCode: family.code,
familyName: family.name,
sequenceCode: family.sequenceCode,
nodeId,
nodePath: (lineage ?? []).map((node) => ({
id: node.id,
code: node.code,
label: node.label,
level: node.level,
})),
sequenceNumber,
generatedSku: formatGeneratedSku(family.sequenceCode, segments, effectiveSequenceNumber),
segments,
};
}
async function validateSkuBuilder(input: InventorySkuBuilderInput | null) {
if (!input) {
return { ok: true as const, family: null, node: null, segments: [] as string[] };
}
const family = await prisma.inventorySkuFamily.findUnique({
where: { id: input.familyId },
select: {
id: true,
code: true,
name: true,
sequenceCode: true,
nextSequenceNumber: true,
isActive: true,
},
});
if (!family || !family.isActive) {
return { ok: false as const, reason: "Selected SKU family was not found or is inactive." };
}
let node:
| {
id: string;
familyId: string;
code: string;
label: string;
path: string;
level: number;
isActive: boolean;
}
| null = null;
let segments = [family.code];
if (input.nodeId) {
const lineage = await getSkuNodeLineage(input.nodeId);
if (!lineage || lineage.length === 0) {
return { ok: false as const, reason: "Selected SKU branch was not found." };
}
node = await prisma.inventorySkuNode.findUnique({
where: { id: input.nodeId },
select: {
id: true,
familyId: true,
code: true,
label: true,
path: true,
level: true,
isActive: true,
},
});
if (!node || node.familyId !== family.id || !node.isActive || lineage.some((entry) => entry.familyId !== family.id)) {
return { ok: false as const, reason: "Selected SKU branch is invalid for the chosen family." };
}
segments = [family.code, ...lineage.map((entry) => entry.code)];
}
return { ok: true as const, family, node, segments };
}
async function getItemLocationOnHand(itemId: string, warehouseId: string, locationId: string) {
const transactions = await prisma.inventoryTransaction.findMany({
where: {
@@ -672,6 +916,188 @@ async function getActiveReservedQuantity(itemId: string, warehouseId: string, lo
return reservations.reduce((sum, reservation) => sum + reservation.quantity, 0);
}
export async function listInventorySkuFamilies() {
const families = await prisma.inventorySkuFamily.findMany({
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
orderBy: [{ code: "asc" }],
});
return families.map(mapSkuFamily);
}
export async function listInventorySkuCatalog(): Promise<InventorySkuCatalogTreeDto> {
const [families, nodes] = await Promise.all([
prisma.inventorySkuFamily.findMany({
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
orderBy: [{ code: "asc" }],
}),
prisma.inventorySkuNode.findMany({
include: {
_count: {
select: {
childNodes: true,
},
},
},
orderBy: [{ level: "asc" }, { sortOrder: "asc" }, { code: "asc" }],
}),
]);
return {
families: families.map(mapSkuFamily),
nodes: nodes.map(mapSkuNode),
};
}
export async function listInventorySkuNodeOptions(familyId: string, parentNodeId: string | null = null) {
const nodes = await prisma.inventorySkuNode.findMany({
where: {
familyId,
parentNodeId,
isActive: true,
},
include: {
_count: {
select: {
childNodes: true,
},
},
},
orderBy: [{ sortOrder: "asc" }, { code: "asc" }],
});
return nodes.map(mapSkuNode);
}
export async function previewInventorySku(input: InventorySkuBuilderInput): Promise<InventorySkuBuilderPreviewDto | null> {
const validated = await validateSkuBuilder(input);
if (!validated.ok || !validated.family) {
return null;
}
const childCount = validated.node
? await prisma.inventorySkuNode.count({
where: {
parentNodeId: validated.node.id,
isActive: true,
},
})
: await prisma.inventorySkuNode.count({
where: {
familyId: validated.family.id,
parentNodeId: null,
isActive: true,
},
});
return {
...(await buildSkuBuilderSelection(validated.family, validated.node?.id ?? null, validated.family.nextSequenceNumber)),
nextSequenceNumber: validated.family.nextSequenceNumber,
availableLevels: Math.max(0, 6 - validated.segments.length),
hasChildren: childCount > 0,
};
}
export async function createInventorySkuFamily(input: InventorySkuFamilyInput) {
const payload = normalizeSkuFamilyInput(input);
if (!/^[A-Z0-9]{2,12}$/.test(payload.code) || !/^[A-Z]{2}$/.test(payload.sequenceCode) || payload.name.length === 0) {
return null;
}
const family = await prisma.inventorySkuFamily.create({
data: payload,
include: {
_count: {
select: {
nodes: true,
items: true,
},
},
},
});
return mapSkuFamily(family);
}
export async function createInventorySkuNode(input: InventorySkuNodeInput) {
const payload = normalizeSkuNodeInput(input);
if (!payload.familyId || payload.code.length === 0 || payload.label.length === 0 || payload.code.includes("-")) {
return null;
}
const family = await prisma.inventorySkuFamily.findUnique({
where: { id: payload.familyId },
select: { id: true, code: true, isActive: true },
});
if (!family || !family.isActive) {
return null;
}
let level = 2;
let path = payload.code;
if (payload.parentNodeId) {
const parentNode = await prisma.inventorySkuNode.findUnique({
where: { id: payload.parentNodeId },
select: {
id: true,
familyId: true,
path: true,
level: true,
isActive: true,
},
});
if (!parentNode || parentNode.familyId !== family.id || !parentNode.isActive || parentNode.level >= 6) {
return null;
}
level = parentNode.level + 1;
path = `${parentNode.path}/${payload.code}`;
}
if (level > 6) {
return null;
}
const node = await prisma.inventorySkuNode.create({
data: {
familyId: family.id,
parentNodeId: payload.parentNodeId,
code: payload.code,
label: payload.label,
description: payload.description,
path,
level,
sortOrder: payload.sortOrder,
isActive: payload.isActive,
},
include: {
_count: {
select: {
childNodes: true,
},
},
},
});
return mapSkuNode(node);
}
export async function listInventoryItems(filters: InventoryListFilters = {}) {
const items = await prisma.inventoryItem.findMany({
where: buildWhereClause(filters),
@@ -740,6 +1166,24 @@ export async function getInventoryItemById(itemId: string) {
const item = await prisma.inventoryItem.findUnique({
where: { id: itemId },
include: {
skuFamily: {
select: {
id: true,
code: true,
sequenceCode: true,
name: true,
},
},
skuNode: {
select: {
id: true,
code: true,
label: true,
path: true,
level: true,
parentNodeId: true,
},
},
bomLines: {
include: {
componentItem: {
@@ -862,7 +1306,16 @@ export async function getInventoryItemById(itemId: string) {
},
});
return item ? mapDetail(item) : null;
if (!item) {
return null;
}
const skuBuilder =
item.skuFamily && item.skuSequenceNumber
? await buildSkuBuilderSelection(item.skuFamily, item.skuNode?.id ?? null, item.skuSequenceNumber)
: null;
return mapDetail(item as InventoryDetailRecord, skuBuilder);
}
export async function listWarehouseLocationOptions() {
@@ -1120,35 +1573,89 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
if (!validatedPreferredVendor.ok) {
return null;
}
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
if (!validatedSku.ok) {
return null;
}
const item = await prisma.inventoryItem.create({
data: {
sku: payload.sku,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: validatedBom.bomLines.length
? {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
},
const item = await prisma.$transaction(async (transaction) => {
if (validatedSku.family) {
const sequenceNumber = validatedSku.family.nextSequenceNumber;
await transaction.inventorySkuFamily.update({
where: { id: validatedSku.family.id },
data: {
nextSequenceNumber: {
increment: 1,
},
},
});
return transaction.inventoryItem.create({
data: {
sku: formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, sequenceNumber),
skuFamilyId: validatedSku.family.id,
skuNodeId: validatedSku.node?.id ?? null,
skuSequenceNumber: sequenceNumber,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: validatedBom.bomLines.length
? {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
},
});
}
return transaction.inventoryItem.create({
data: {
sku: payload.sku,
skuFamilyId: null,
skuNodeId: null,
skuSequenceNumber: null,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: validatedBom.bomLines.length
? {
create: validatedBom.bomLines,
}
: undefined,
operations: validatedOperations.operations.length
? {
create: validatedOperations.operations,
}
: undefined,
},
select: {
id: true,
},
});
});
await logAuditEvent({
@@ -1156,9 +1663,11 @@ export async function createInventoryItem(payload: InventoryItemInput, actorId?:
entityType: "inventory-item",
entityId: item.id,
action: "created",
summary: `Created inventory item ${payload.sku}.`,
summary: `Created inventory item ${validatedSku.family ? "generated SKU" : payload.sku}.`,
metadata: {
sku: payload.sku,
skuFamilyId: validatedSku.family?.id ?? null,
skuNodeId: validatedSku.node?.id ?? null,
name: payload.name,
type: payload.type,
status: payload.status,
@@ -1189,34 +1698,75 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
if (!validatedPreferredVendor.ok) {
return null;
}
const validatedSku = await validateSkuBuilder(payload.skuBuilder);
if (!validatedSku.ok) {
return null;
}
const item = await prisma.inventoryItem.update({
where: { id: itemId },
data: {
sku: payload.sku,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: {
deleteMany: {},
create: validatedBom.bomLines,
const item = await prisma.$transaction(async (transaction) => {
const shouldKeepExistingGeneratedSku =
validatedSku.family &&
existingItem.skuFamilyId === validatedSku.family.id &&
existingItem.skuNodeId === (validatedSku.node?.id ?? null) &&
existingItem.skuSequenceNumber != null;
let sku = payload.sku;
let skuFamilyId: string | null = null;
let skuNodeId: string | null = null;
let skuSequenceNumber: number | null = null;
if (validatedSku.family) {
skuFamilyId = validatedSku.family.id;
skuNodeId = validatedSku.node?.id ?? null;
if (shouldKeepExistingGeneratedSku) {
sku = existingItem.sku;
skuSequenceNumber = existingItem.skuSequenceNumber;
} else {
skuSequenceNumber = validatedSku.family.nextSequenceNumber;
sku = formatGeneratedSku(validatedSku.family.sequenceCode, validatedSku.segments, skuSequenceNumber);
await transaction.inventorySkuFamily.update({
where: { id: validatedSku.family.id },
data: {
nextSequenceNumber: {
increment: 1,
},
},
});
}
}
return transaction.inventoryItem.update({
where: { id: itemId },
data: {
sku,
skuFamilyId,
skuNodeId,
skuSequenceNumber,
name: payload.name,
description: payload.description,
type: payload.type,
status: payload.status,
unitOfMeasure: payload.unitOfMeasure,
isSellable: payload.isSellable,
isPurchasable: payload.isPurchasable,
preferredVendorId: payload.preferredVendorId,
defaultCost: payload.defaultCost,
defaultPrice: payload.defaultPrice,
notes: payload.notes,
bomLines: {
deleteMany: {},
create: validatedBom.bomLines,
},
operations: {
deleteMany: {},
create: validatedOperations.operations,
},
},
operations: {
deleteMany: {},
create: validatedOperations.operations,
select: {
id: true,
},
},
select: {
id: true,
},
});
});
await logAuditEvent({
@@ -1227,6 +1777,8 @@ export async function updateInventoryItem(itemId: string, payload: InventoryItem
summary: `Updated inventory item ${payload.sku}.`,
metadata: {
sku: payload.sku,
skuFamilyId: validatedSku.family?.id ?? null,
skuNodeId: validatedSku.node?.id ?? null,
name: payload.name,
type: payload.type,
status: payload.status,