import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { BillOfMaterialBomType } from "../generated/enums"; import type { Transaction } from "../generated/kysely-tailordb"; import { ParentItemNotFoundError, ParentItemNotManufacturedError, ComponentLineRequiredError, InvalidComponentQuantityError, ComponentItemInactiveError, BomVersionConflictError, } from "../lib/errors.generated"; import type { ItemManagementQueries } from "../module"; export interface CreateBillOfMaterialLineInput { itemId: string; requiredQuantity: number; unitOfMeasure?: string | null; scrapAssumption?: number | null; isSubassembly?: boolean | null; } export interface CreateBillOfMaterialInput { parentItemId: string; companyId: string; siteId?: string | null; bomType: BillOfMaterialBomType; effectivityStartDate?: Date | null; effectivityEndDate?: Date | null; defaultSelection?: boolean | null; revisionNumber?: string | null; lines: CreateBillOfMaterialLineInput[]; } /** * Function: createBillOfMaterial * * Creates a draft BOM version for one manufactured parent item at company or * site scope. Establishes the initial bomType, effectivity window, * default-selection intent, and component-line payload. */ export async function run>( db: Transaction, input: CreateBillOfMaterialInput & CF, ctx: CommandContext, itemManagementQueries: Pick, ) { const { parentItemId, companyId, siteId, bomType, effectivityStartDate, effectivityEndDate, defaultSelection, revisionNumber, lines, ...customFields } = input; // 1. Validate parent item exists const { item: parentItem } = (await itemManagementQueries.getItem(db, { id: parentItemId }, ctx)) .value; if (!parentItem) { return err(new ParentItemNotFoundError(parentItemId)); } // 2. Validate parent item is active and manufacturable. const parentItemRecord = parentItem as Record; const isInactive = "status" in parentItemRecord && parentItemRecord.status !== "ACTIVE"; const isNotManufacturable = "isManufacturable" in parentItemRecord && parentItemRecord.isManufacturable === false; if (isInactive || isNotManufacturable) { return err(new ParentItemNotManufacturedError(parentItemId)); } // 3. Validate at least one component line if (!lines || lines.length === 0) { return err(new ComponentLineRequiredError(parentItemId)); } // 4. Validate each component line for (const line of lines) { if (line.requiredQuantity <= 0) { return err(new InvalidComponentQuantityError(line.itemId)); } // Check component item is not inactive (exists in Item table) const { item: componentItem } = ( await itemManagementQueries.getItem(db, { id: line.itemId }, ctx) ).value; if (!componentItem) { return err(new ComponentItemInactiveError(line.itemId)); } // Check component item is active if ("status" in componentItem && componentItem.status !== "ACTIVE") { return err(new ComponentItemInactiveError(line.itemId)); } } // 5. Check version identity uniqueness within same parent item and scope const existingBomQuery = db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", parentItemId) .where("companyId", "=", companyId); const existingBom = await ( siteId ? existingBomQuery.where("siteId", "=", siteId) : existingBomQuery ) .where("revisionNumber", "=", revisionNumber ?? null) .forUpdate() .executeTakeFirst(); if (existingBom) { return err(new BomVersionConflictError(parentItemId)); } // 6. Create draft BOM const billOfMaterial = await db .insertInto("BillOfMaterial") .values({ ...(customFields as Record), parentItemId, companyId, siteId: siteId ?? null, bomType, effectivityStartDate: effectivityStartDate ?? null, effectivityEndDate: effectivityEndDate ?? null, defaultSelection: defaultSelection ?? null, revisionNumber: revisionNumber ?? null, status: "DRAFT", }) .returningAll() .executeTakeFirstOrThrow(); // 7. Create component lines (bulk insert) if (lines.length > 0) { await db .insertInto("BillOfMaterialLine") .values( lines.map((line) => ({ billOfMaterialId: billOfMaterial.id, itemId: line.itemId, requiredQuantity: line.requiredQuantity, unitOfMeasure: line.unitOfMeasure ?? null, scrapAssumption: line.scrapAssumption ?? null, isSubassembly: line.isSubassembly ?? null, })), ) .execute(); } return ok({ billOfMaterial }); }