import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Selectable, Transaction, Updateable } from "../generated/kysely-tailordb"; import { isPositiveDecimal } from "../internal/transferOrderProgress"; import { CrossCompanyTransferNotSupportedError, EmptyTransferLinesError, InvalidQuantityError, InvalidStateTransitionError, InvalidTransferLocationsError, ItemNotFoundError, SiteNotFoundError, TransferOrderLineNotFoundError, TransferOrderNotFoundError, } from "../lib/errors.generated"; import type { ItemManagementQueries, OrganizationQueries } from "../module"; import type { TransferOrderLineInput } from "./createTransferOrder"; export interface TransferOrderHeaderPatch { sourceSiteId?: string; destinationSiteId?: string; plannedShipmentDate?: Date; expectedReceiptDate?: Date; } export interface TransferOrderLinePatch { itemId?: string; orderedQuantity?: string; } export interface TransferOrderLineEdit { lineId: string; linePatch: TransferOrderLinePatch; } export type UpdateTransferOrderInput = { id: string; headerPatch?: TransferOrderHeaderPatch; addLines?: TransferOrderLineInput[]; updateLines?: TransferOrderLineEdit[]; removeLineIds?: string[]; }; // The order's domain-owned state that invariants are checked over, minus DB-assigned fields. type TransferOrderHeaderState = Omit, "id" | "createdAt" | "updatedAt">; type TransferOrderLineState = Omit< Selectable<"TransferOrderLine">, "id" | "createdAt" | "updatedAt" | "transferOrderId" >; function checkTransferOrderInvariants( id: string, header: TransferOrderHeaderState, lines: TransferOrderLineState[], ) { // Source and destination sites must differ. if (header.sourceSiteId === header.destinationSiteId) { return new InvalidTransferLocationsError(id); } if (lines.length === 0) { return new EmptyTransferLinesError(id); } for (const line of lines) { if (!isPositiveDecimal(line.orderedQuantity)) { return new InvalidQuantityError(line.orderedQuantity); } } return null; } export async function run( db: Transaction, input: Omit & { headerPatch?: TransferOrderHeaderPatch & Partial; addLines?: (TransferOrderLineInput & Partial)[]; updateLines?: { lineId: string; linePatch: TransferOrderLinePatch & Partial }[]; }, ctx: CommandContext, itemManagementQueries: Pick, organizationQueries: Pick, ) { const { id, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input; // ===== 1. Load the current order and its lines ===== // Lock the order row; only a DRAFT transfer order can be edited. const currentHeader = await db .selectFrom("TransferOrder") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!currentHeader) { return err(new TransferOrderNotFoundError(id)); } if (currentHeader.status !== "DRAFT") { return err(new InvalidStateTransitionError(id)); } // Index the current lines; reject any update or removal that targets a missing line. const currentLines = await db .selectFrom("TransferOrderLine") .selectAll() .where("transferOrderId", "=", id) .execute(); const currentLineById = new Map(currentLines.map((line) => [line.id, line])); const linePatchById = new Map(updateLines.map((edit) => [edit.lineId, edit.linePatch])); for (const lineId of [...linePatchById.keys(), ...removeLineIds]) { if (!currentLineById.has(lineId)) { return err(new TransferOrderLineNotFoundError(lineId)); } } // ===== 2. Validate the referenced master data ===== // Sites — when a site changes, both merged sites are re-validated: they must exist // and belong to the same company (source ≠ destination is an invariant). const mergedSourceSiteId = headerPatch?.sourceSiteId ?? currentHeader.sourceSiteId; const mergedDestinationSiteId = headerPatch?.destinationSiteId ?? currentHeader.destinationSiteId; if (headerPatch?.sourceSiteId != null || headerPatch?.destinationSiteId != null) { const siteCompanyIds: string[] = []; for (const siteId of [mergedSourceSiteId, mergedDestinationSiteId]) { const { site } = (await organizationQueries.getSite(db, { id: siteId }, ctx)).value; if (!site) { return err(new SiteNotFoundError(siteId)); } siteCompanyIds.push(site.companyId); } // Cross-company transfers are out of scope; stock moves only within one company. if (siteCompanyIds[0] !== siteCompanyIds[1]) { return err( new CrossCompanyTransferNotSupportedError( `${mergedSourceSiteId} -> ${mergedDestinationSiteId}`, ), ); } } // Items — each newly referenced item must exist; its primary unit becomes the line unit. const itemUnitByItemId = new Map(); const itemIdsToValidate = [ ...addLines.map((line) => line.itemId), ...updateLines.flatMap((edit) => edit.linePatch.itemId === undefined ? [] : [edit.linePatch.itemId], ), ]; for (const itemId of itemIdsToValidate) { if (itemUnitByItemId.has(itemId)) { continue; } const { item } = (await itemManagementQueries.getItem(db, { id: itemId }, ctx)).value; if (!item) { return err(new ItemNotFoundError(itemId)); } itemUnitByItemId.set(itemId, item.unitId); } // Every itemId validated above is in the map, so this lookup never misses. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const unitIdFor = (itemId: string) => itemUnitByItemId.get(itemId)!; // ===== 3. Reconstitute the amended order and check its invariants ===== const mergedLines: TransferOrderLineState[] = []; for (const line of currentLines) { if (removeLineIds.includes(line.id)) { continue; } const linePatch = linePatchById.get(line.id); if (!linePatch) { mergedLines.push(line); continue; } mergedLines.push({ itemId: linePatch.itemId === undefined ? line.itemId : linePatch.itemId, unitId: linePatch.itemId === undefined ? line.unitId : unitIdFor(linePatch.itemId), orderedQuantity: linePatch.orderedQuantity === undefined ? line.orderedQuantity : linePatch.orderedQuantity, shippedQuantity: line.shippedQuantity, receivedQuantity: line.receivedQuantity, }); } for (const line of addLines) { mergedLines.push({ itemId: line.itemId, unitId: unitIdFor(line.itemId), orderedQuantity: line.orderedQuantity, shippedQuantity: "0", receivedQuantity: "0", }); } const mergedHeader: TransferOrderHeaderState = { ...currentHeader, sourceSiteId: headerPatch?.sourceSiteId ?? currentHeader.sourceSiteId, destinationSiteId: headerPatch?.destinationSiteId ?? currentHeader.destinationSiteId, plannedShipmentDate: headerPatch?.plannedShipmentDate ?? currentHeader.plannedShipmentDate, expectedReceiptDate: headerPatch?.expectedReceiptDate ?? currentHeader.expectedReceiptDate, }; const violation = checkTransferOrderInvariants(id, mergedHeader, mergedLines); if (violation) { return err(violation); } // ===== 4. Apply the order changes ===== // Header — write the patch. let transferOrder = currentHeader; if (headerPatch) { const updates: Updateable<"TransferOrder"> = { ...headerPatch, }; // Skip an empty patch; kysely can't compile a SET with no columns. if (Object.values(updates).some((value) => value !== undefined)) { transferOrder = await db .updateTable("TransferOrder") .set(updates) .where("id", "=", id) .returningAll() .executeTakeFirstOrThrow(); } } // Updated lines — patch in place so their ids and createdAt stay stable. for (const { lineId, linePatch } of updateLines) { const lineUpdates: Updateable<"TransferOrderLine"> = { ...linePatch, }; if (linePatch.itemId !== undefined) { lineUpdates.unitId = unitIdFor(linePatch.itemId); } // Skip an empty patch; kysely can't compile a SET with no columns. if (Object.values(lineUpdates).some((value) => value !== undefined)) { await db.updateTable("TransferOrderLine").set(lineUpdates).where("id", "=", lineId).execute(); } } // Removed lines — delete. if (removeLineIds.length > 0) { await db.deleteFrom("TransferOrderLine").where("id", "in", removeLineIds).execute(); } // Added lines — insert with item-derived primary units. const addRows = addLines.map((line) => { return { // Raw line first so command-set columns always win. ...(line as Record), transferOrderId: id, itemId: line.itemId, unitId: unitIdFor(line.itemId), orderedQuantity: line.orderedQuantity, shippedQuantity: "0", receivedQuantity: "0", }; }); if (addRows.length > 0) { await db.insertInto("TransferOrderLine").values(addRows).execute(); } return ok({ transferOrder }); }