import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { BillOfMaterialBomType } from "../generated/enums"; import type { Transaction, Updateable } from "../generated/kysely-tailordb"; import { BomNotFoundError, BomNotMutableError, InvalidComponentQuantityError, ComponentItemInactiveError, AmbiguousEffectivityRuleError, } from "../lib/errors.generated"; import type { ItemManagementQueries } from "../module"; export interface UpdateBillOfMaterialLineInput { itemId: string; requiredQuantity: number; unitOfMeasure?: string | null; scrapAssumption?: number | null; isSubassembly?: boolean | null; } export type UpdateBillOfMaterialInput = { id: string; bomType?: BillOfMaterialBomType; effectivityStartDate?: Date | null; effectivityEndDate?: Date | null; defaultSelection?: boolean | null; revisionNumber?: string | null; lines?: UpdateBillOfMaterialLineInput[]; }; /** * Function: updateBillOfMaterial * * Revises mutable draft BOM content before activation. Supports changes to * effectivity, default flags, bomType, and component lines while preserving * released production-order snapshots. */ export async function run>( db: Transaction, input: UpdateBillOfMaterialInput & Omit, "status">, ctx: CommandContext, itemManagementQueries: Pick, ) { const { id, bomType, effectivityStartDate, effectivityEndDate, defaultSelection, revisionNumber, lines, ...customFields } = 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. Only DRAFT BOMs are mutable if (bom.status !== "DRAFT") { return err(new BomNotMutableError(id)); } // 3. Validate revised component lines if provided if (lines) { for (const line of lines) { if (line.requiredQuantity <= 0) { return err(new InvalidComponentQuantityError(line.itemId)); } // Check component item is not inactive const { item: componentItem } = ( await itemManagementQueries.getItem(db, { id: line.itemId }, ctx) ).value; if (!componentItem) { return err(new ComponentItemInactiveError(line.itemId)); } } } // 4. Check effectivity ambiguity — if effectivity or default selection changed, // verify no ambiguous active-selection plan would result once activated const effectiveDefaultSelection = defaultSelection !== undefined ? defaultSelection : bom.defaultSelection; const effectiveStartDate = effectivityStartDate !== undefined ? effectivityStartDate : bom.effectivityStartDate; const effectiveEndDate = effectivityEndDate !== undefined ? effectivityEndDate : bom.effectivityEndDate; if ( defaultSelection !== undefined || effectivityStartDate !== undefined || effectivityEndDate !== undefined ) { // Find other active BOMs for same parent item and scope that could conflict const conflictingBom = await db .selectFrom("BillOfMaterial") .selectAll() .where("parentItemId", "=", bom.parentItemId) .where("companyId", "=", bom.companyId) .where("id", "!=", id) .where("status", "=", "ACTIVE") .where("defaultSelection", "=", effectiveDefaultSelection ?? false) .executeTakeFirst(); if (conflictingBom && effectiveDefaultSelection) { // Check date overlap const hasOverlap = !effectiveStartDate || !conflictingBom.effectivityEndDate || effectiveStartDate <= conflictingBom.effectivityEndDate; const hasOverlap2 = !effectiveEndDate || !conflictingBom.effectivityStartDate || effectiveEndDate >= conflictingBom.effectivityStartDate; if (hasOverlap && hasOverlap2) { return err(new AmbiguousEffectivityRuleError(id)); } } } // 5. Build update set for BOM header const updateSet: Updateable<"BillOfMaterial"> = { ...(customFields as Updateable<"BillOfMaterial">), }; if (bomType !== undefined) updateSet.bomType = bomType; if (effectivityStartDate !== undefined) updateSet.effectivityStartDate = effectivityStartDate; if (effectivityEndDate !== undefined) updateSet.effectivityEndDate = effectivityEndDate; if (defaultSelection !== undefined) updateSet.defaultSelection = defaultSelection; if (revisionNumber !== undefined) updateSet.revisionNumber = revisionNumber; // 6. Persist BOM header changes const updatedBom = Object.keys(updateSet).length === 0 ? bom : await db .updateTable("BillOfMaterial") .set(updateSet) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); // 7. Replace lines if provided (delete old, bulk insert new) if (lines) { await db.deleteFrom("BillOfMaterialLine").where("billOfMaterialId", "=", id).execute(); if (lines.length > 0) { await db .insertInto("BillOfMaterialLine") .values( lines.map((line) => ({ billOfMaterialId: id, itemId: line.itemId, requiredQuantity: line.requiredQuantity, unitOfMeasure: line.unitOfMeasure ?? null, scrapAssumption: line.scrapAssumption ?? null, isSubassembly: line.isSubassembly ?? null, })), ) .execute(); } } return ok({ billOfMaterial: updatedBom }); }