import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { billOfMaterialLifecycle } from "../db/billOfMaterial.lifecycle.generated"; import type { Transaction } from "../generated/kysely-tailordb"; import { BomNotFoundError, BomNotActivatableError, ComponentLineRequiredError, ComponentItemInactiveError, CircularBomReferenceError, EffectivityConflictError, } from "../lib/errors.generated"; import type { ItemManagementQueries } from "../module"; export interface ActivateBillOfMaterialInput { id: string; } /** * Function: activateBillOfMaterial * * Validates a draft BOM version and makes it selectable for production-order * release. Activation enforces circular-reference checks, effectivity * conflicts, and component readiness. */ export async function run( db: Transaction, input: ActivateBillOfMaterialInput, ctx: CommandContext, itemManagementQueries: Pick, ) { const { id } = input; // 1. Fetch BOM with lock const bom = await db .selectFrom("BillOfMaterial") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!bom) { return err(new BomNotFoundError(id)); } // 2. Validate status is activatable const nextStatus = billOfMaterialLifecycle.tryTransition(bom.status, "activate"); if (!nextStatus) { return err(new BomNotActivatableError(id)); } // 3. Validate at least one component line exists const lines = await db .selectFrom("BillOfMaterialLine") .selectAll() .where("billOfMaterialId", "=", id) .execute(); if (!lines || lines.length === 0) { return err(new ComponentLineRequiredError(id)); } // 4. Validate all component items are active for (const line of lines) { const { item: componentItem } = ( await itemManagementQueries.getItem(db, { id: line.itemId }, ctx) ).value; if (!componentItem) { return err(new ComponentItemInactiveError(line.itemId)); } } // 5. Check for circular references using DFS traversal const circularResult = await detectCircularReference( db, lines, new Set([bom.parentItemId]), ); if (circularResult) { return err(new CircularBomReferenceError(id)); } // 6. Check effectivity conflicts — overlapping active effectivity windows // for the same parent item and scope const conflictingBom = await db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", bom.parentItemId) .where("companyId", "=", bom.companyId) .where("id", "!=", id) .where("status", "=", "ACTIVE") .executeTakeFirst(); if (conflictingBom) { // Check date overlap const bomStart = bom.effectivityStartDate; const bomEnd = bom.effectivityEndDate; const conflictStart = conflictingBom.effectivityStartDate; const conflictEnd = conflictingBom.effectivityEndDate; const hasOverlap = (!bomStart || !conflictEnd || bomStart <= conflictEnd) && (!bomEnd || !conflictStart || bomEnd >= conflictStart); if (hasOverlap && bom.defaultSelection && conflictingBom.defaultSelection) { return err(new EffectivityConflictError(id)); } } // 7. Set status to ACTIVE const updatedBom = await db .updateTable("BillOfMaterial") .set({ status: nextStatus, }) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); return ok({ billOfMaterial: updatedBom }); } async function detectCircularReference( db: Transaction, lines: { itemId: string; isSubassembly: boolean | null }[], visitedItems: Set, ): Promise { for (const line of lines) { // If this item has already been visited in the path, we have a cycle if (visitedItems.has(line.itemId)) { return true; } if (!line.isSubassembly) continue; // Find ALL child BOMs for this item (multiple versions may exist) const childBoms = await db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", line.itemId) .where("status", "in", ["DRAFT", "ACTIVE"]) .execute(); if (childBoms.length > 0) { visitedItems.add(line.itemId); for (const childBom of childBoms) { const childLines = await db .selectFrom("BillOfMaterialLine") .selectAll() .where("billOfMaterialId", "=", childBom.id) .execute(); const hasCycle = await detectCircularReference(db, childLines, visitedItems); if (hasCycle) return true; } visitedItems.delete(line.itemId); } } return false; }