import { ok, err, type ReadonlyDB } from "@tailor-platform/erp-kit/core"; import type { DB } from "../generated/kysely-tailordb"; import { CompanyScopeRequiredError, ItemReferenceRequiredError } from "../lib/errors.generated"; export interface DetectBillOfMaterialCircularReferenceInput { companyId: string; parentItemId: string; childItemId: string; } export async function run(db: ReadonlyDB, input: DetectBillOfMaterialCircularReferenceInput) { if (!input.companyId) { return err(new CompanyScopeRequiredError("companyId")); } if (!input.parentItemId || !input.childItemId) { return err(new ItemReferenceRequiredError("parentItemId and childItemId")); } // If child === parent, immediate circular reference if (input.childItemId === input.parentItemId) { return ok({ hasCircularReference: true, path: [input.parentItemId, input.childItemId], }); } // BFS from childItemId through active BOMs to see if any path reaches parentItemId type BfsNode = { itemId: string; path: string[] }; const visited = new Set(); const queue: BfsNode[] = []; let current: BfsNode | undefined = { itemId: input.childItemId, path: [input.parentItemId, input.childItemId], }; do { if (visited.has(current.itemId)) { continue; } visited.add(current.itemId); // Find active BOMs where this item is the parent const activeBoms = await db .selectFrom("BillOfMaterial") .select("id") .where("parentItemId", "=", current.itemId) .where("companyId", "=", input.companyId) .where("status", "=", "ACTIVE") .execute(); if (activeBoms.length === 0) { continue; } const bomIds = activeBoms.map((b) => b.id); // Find subassembly lines for these BOMs const subassemblyLines = await db .selectFrom("BillOfMaterialLine") .select("itemId") .where("billOfMaterialId", "in", bomIds) .where("isSubassembly", "=", true) .execute(); for (const line of subassemblyLines) { const newPath = [...current.path, line.itemId]; if (line.itemId === input.parentItemId) { return ok({ hasCircularReference: true, path: newPath }); } if (!visited.has(line.itemId)) { queue.push({ itemId: line.itemId, path: newPath }); } } } while ((current = queue.shift()) != null); return ok({ hasCircularReference: false, path: null }); }