import { ok, err, type ReadonlyDB } from "@tailor-platform/erp-kit/core"; import type { DB } from "../generated/kysely-tailordb"; import { BomReferenceRequiredError, EffectivityDateRequiredError, BomNotFoundError, BomNotEffectiveOnDateError, ChildBomNotResolvedError, BomCircularReferenceDetectedError, } from "../lib/errors.generated"; export interface ExplodeBillOfMaterialInput { bomId: string; effectivityDate: Date; } interface ExplodedComponent { itemId: string; requiredQuantity: number; unitOfMeasure: string | null; depth: number; bomPath: string[]; isSubassembly: boolean; } /** * Function: explodeBillOfMaterial * * Expands one BOM revision into recursive component requirements using the * requested effectivity date and site-applicable child BOM scope. */ export async function run(db: ReadonlyDB, input: ExplodeBillOfMaterialInput) { if (!input.bomId) { return err(new BomReferenceRequiredError("bomId")); } if (!input.effectivityDate) { return err(new EffectivityDateRequiredError("effectivityDate")); } const rootBom = await db .selectFrom("BillOfMaterial") .selectAll() .where("id", "=", input.bomId) .executeTakeFirst(); if (!rootBom) { return err(new BomNotFoundError(input.bomId)); } if (!isEffectiveOnDate(rootBom, input.effectivityDate)) { return err(new BomNotEffectiveOnDateError(input.bomId)); } const components: ExplodedComponent[] = []; const visitedItems = new Set(); const result = await expandBom( db, rootBom.id, rootBom.companyId, rootBom.siteId, rootBom.bomType as string, input.effectivityDate, 1, // multiplier 0, // depth [rootBom.id], visitedItems, components, ); if (result) { return result; // error result } return ok({ components }); } function isEffectiveOnDate( bom: { effectivityStartDate: unknown; effectivityEndDate: unknown }, date: Date, ): boolean { const dateMs = date.getTime(); if (bom.effectivityStartDate) { const start = new Date(bom.effectivityStartDate as string); if (dateMs < start.getTime()) { return false; } } if (bom.effectivityEndDate) { const end = new Date(bom.effectivityEndDate as string); if (dateMs > end.getTime()) { return false; } } return true; } function resolveApplicableChildBom( childBoms: { id: string; siteId?: string | null; bomType: string; effectivityStartDate: unknown; effectivityEndDate: unknown; }[], effectivityDate: Date, applicableSiteId: string | null, ) { const effectiveBoms = childBoms.filter((bom) => { if (!isEffectiveOnDate(bom, effectivityDate)) { return false; } if (applicableSiteId == null) { return bom.siteId == null; } return bom.siteId == null || bom.siteId === applicableSiteId; }); return ( effectiveBoms.find((bom) => bom.siteId === applicableSiteId) ?? effectiveBoms.find((bom) => bom.siteId == null) ); } async function expandBom( db: ReadonlyDB, bomId: string, companyId: string, applicableSiteId: string | null, bomType: string, effectivityDate: Date, parentQuantityMultiplier: number, depth: number, bomPath: string[], visitedItems: Set, components: ExplodedComponent[], ): Promise<{ ok: false; error: Error } | undefined> { const lines = await db .selectFrom("BillOfMaterialLine") .selectAll() .where("billOfMaterialId", "=", bomId) .execute(); for (const line of lines) { const requiredQuantity = line.requiredQuantity * parentQuantityMultiplier; const isSubassembly = line.isSubassembly === true; // KIT: issue-only component demand — no recursive expansion if (bomType === "KIT") { components.push({ itemId: line.itemId, requiredQuantity, unitOfMeasure: line.unitOfMeasure, depth: depth + 1, bomPath: [...bomPath], isSubassembly: false, // KIT components are always issue-only }); continue; } // PHANTOM: flatten lines into parent path if (bomType === "PHANTOM") { if (isSubassembly) { // Resolve child BOM and expand at SAME depth (no depth increment) if (visitedItems.has(line.itemId)) { return err(new BomCircularReferenceDetectedError(line.itemId)); } visitedItems.add(line.itemId); const childBoms = await db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", line.itemId) .where("companyId", "=", companyId) .where("status", "=", "ACTIVE") .execute(); const effectiveChildBom = resolveApplicableChildBom( childBoms, effectivityDate, applicableSiteId, ); if (!effectiveChildBom) { return err(new ChildBomNotResolvedError(line.itemId)); } const childResult = await expandBom( db, effectiveChildBom.id, companyId, applicableSiteId, effectiveChildBom.bomType, effectivityDate, requiredQuantity, depth, // same depth — flattening [...bomPath, effectiveChildBom.id], visitedItems, components, ); if (childResult) return childResult; visitedItems.delete(line.itemId); } else { // Non-subassembly in phantom: push at current depth (flattened) components.push({ itemId: line.itemId, requiredQuantity, unitOfMeasure: line.unitOfMeasure, depth: depth + 1, bomPath: [...bomPath], isSubassembly: false, }); } continue; } // MANUFACTURE (default): add the component and recurse if subassembly if (isSubassembly) { if (visitedItems.has(line.itemId)) { return err(new BomCircularReferenceDetectedError(line.itemId)); } visitedItems.add(line.itemId); // Find an active child BOM for this item effective on the date const childBoms = await db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", line.itemId) .where("companyId", "=", companyId) .where("status", "=", "ACTIVE") .execute(); const effectiveChildBom = resolveApplicableChildBom( childBoms, effectivityDate, applicableSiteId, ); if (!effectiveChildBom) { return err(new ChildBomNotResolvedError(line.itemId)); } const childBomType = effectiveChildBom.bomType; if (childBomType === "PHANTOM") { // PHANTOM child: skip the phantom item, flatten its children to current depth const childResult = await expandBom( db, effectiveChildBom.id, companyId, applicableSiteId, childBomType, effectivityDate, requiredQuantity, depth, // same depth — flattening [...bomPath, effectiveChildBom.id], visitedItems, components, ); if (childResult) return childResult; } else { // Non-phantom child: push the subassembly and recurse deeper components.push({ itemId: line.itemId, requiredQuantity, unitOfMeasure: line.unitOfMeasure, depth: depth + 1, bomPath: [...bomPath], isSubassembly: true, }); const childResult = await expandBom( db, effectiveChildBom.id, companyId, applicableSiteId, childBomType, effectivityDate, requiredQuantity, depth + 1, [...bomPath, effectiveChildBom.id], visitedItems, components, ); if (childResult) return childResult; } visitedItems.delete(line.itemId); } else { components.push({ itemId: line.itemId, requiredQuantity, unitOfMeasure: line.unitOfMeasure, depth: depth + 1, bomPath: [...bomPath], isSubassembly: false, }); } } return undefined; // success, no error }