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