import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { StockAdjustmentLineAdjustmentDirection, StockAdjustmentLineFromStockCategory, } from "../generated/enums"; import type { Selectable, Transaction, Updateable } from "../generated/kysely-tailordb"; import { StockAdjustmentNotFoundError, StockAdjustmentLineNotFoundError, InvalidStatusError, EmptyAdjustmentLinesError, InvalidQuantityError, ItemNotFoundError, ItemNotActiveError, StorageLocationNotFoundError, StorageLocationNotActiveError, InvalidFromStockCategoryError, } from "../lib/errors.generated"; import type { ItemManagementQueries } from "../module"; import type { StockAdjustmentLineInput } from "./createStockAdjustment"; export interface StockAdjustmentHeaderPatch { reasonCode?: string; adjustmentDate?: Date; } export interface StockAdjustmentLinePatch { itemId?: string; storageLocationId?: string; quantity?: string; adjustmentDirection?: StockAdjustmentLineAdjustmentDirection | null; fromStockCategory?: StockAdjustmentLineFromStockCategory | null; unitCost?: string | null; } export interface StockAdjustmentLineEdit { lineId: string; linePatch: StockAdjustmentLinePatch; } export type UpdateStockAdjustmentInput = { id: string; headerPatch?: StockAdjustmentHeaderPatch; addLines?: StockAdjustmentLineInput[]; updateLines?: StockAdjustmentLineEdit[]; removeLineIds?: string[]; }; // The adjustment's domain-owned line state that invariants are checked over, minus DB-assigned fields. type StockAdjustmentLineState = Omit< Selectable<"StockAdjustmentLine">, "id" | "createdAt" | "updatedAt" | "stockAdjustmentId" >; function checkStockAdjustmentInvariants( id: string, adjustmentType: Selectable<"StockAdjustment">["adjustmentType"], lines: StockAdjustmentLineState[], ) { if (lines.length === 0) { return new EmptyAdjustmentLinesError(id); } for (const line of lines) { if (new Decimal(line.quantity).lte(0)) { return new InvalidQuantityError(line.quantity); } // fromStockCategory is only valid for SCRAP adjustments. if (adjustmentType !== "SCRAP" && line.fromStockCategory) { return new InvalidFromStockCategoryError(line.itemId); } } return null; } /** * Function: updateStockAdjustment * * Revises a stock adjustment in DRAFT or REJECTED status. * When updating a REJECTED adjustment, its status transitions back to DRAFT. */ export async function run, LCF extends Record>( db: Transaction, input: Omit & { headerPatch?: StockAdjustmentHeaderPatch & Partial; addLines?: (StockAdjustmentLineInput & LCF)[]; updateLines?: { lineId: string; linePatch: StockAdjustmentLinePatch & Partial }[]; }, ctx: CommandContext, itemManagementQueries: Pick, ) { const { id, headerPatch, addLines = [], updateLines = [], removeLineIds = [] } = input; // ===== 1. Load the current adjustment and its lines ===== // Lock the adjustment row; only DRAFT or REJECTED adjustments can be edited. const currentHeader = await db .selectFrom("StockAdjustment") .selectAll() .where("id", "=", id) .forUpdate() .executeTakeFirst(); if (!currentHeader) { return err(new StockAdjustmentNotFoundError(id)); } if (currentHeader.status !== "DRAFT" && currentHeader.status !== "REJECTED") { return err(new InvalidStatusError(id)); } // Index the current lines; reject any update or removal that targets a missing line. const currentLines = await db .selectFrom("StockAdjustmentLine") .selectAll() .where("stockAdjustmentId", "=", 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 StockAdjustmentLineNotFoundError(lineId)); } } // ===== 2. Validate the referenced master data ===== // Items — each newly referenced item must exist and be ACTIVE. const itemIdsToValidate = [ ...addLines.map((line) => line.itemId), ...updateLines.flatMap((edit) => edit.linePatch.itemId === undefined ? [] : [edit.linePatch.itemId], ), ]; const checkedItemIds = new Set(); for (const itemId of itemIdsToValidate) { if (checkedItemIds.has(itemId)) { continue; } const { item } = (await itemManagementQueries.getItem(db, { id: itemId }, ctx)).value; if (!item) { return err(new ItemNotFoundError(itemId)); } if (item.status !== "ACTIVE") { return err(new ItemNotActiveError(itemId)); } checkedItemIds.add(itemId); } // Storage locations — every referenced location must exist and be ACTIVE. const locationIdsToValidate = new Set(); for (const line of addLines) { locationIdsToValidate.add(line.storageLocationId); } for (const { linePatch } of updateLines) { if (linePatch.storageLocationId !== undefined) { locationIdsToValidate.add(linePatch.storageLocationId); } } if (locationIdsToValidate.size > 0) { const existing = await db .selectFrom("StorageLocation") .selectAll() .where("id", "in", [...locationIdsToValidate]) .execute(); const byId = new Map(existing.map((row) => [row.id, row])); for (const locationId of locationIdsToValidate) { const location = byId.get(locationId); if (!location) { return err(new StorageLocationNotFoundError(locationId)); } if (location.status !== "ACTIVE") { return err(new StorageLocationNotActiveError(locationId)); } } } // ===== 3. Reconstitute the amended adjustment and check its invariants ===== const mergedLines: StockAdjustmentLineState[] = []; 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, storageLocationId: linePatch.storageLocationId === undefined ? line.storageLocationId : linePatch.storageLocationId, quantity: linePatch.quantity === undefined ? line.quantity : linePatch.quantity, adjustmentDirection: linePatch.adjustmentDirection === undefined ? line.adjustmentDirection : linePatch.adjustmentDirection, fromStockCategory: linePatch.fromStockCategory === undefined ? line.fromStockCategory : linePatch.fromStockCategory, unitCost: linePatch.unitCost === undefined ? line.unitCost : linePatch.unitCost, }); } for (const line of addLines) { mergedLines.push({ itemId: line.itemId, storageLocationId: line.storageLocationId, quantity: line.quantity, adjustmentDirection: line.adjustmentDirection ?? null, fromStockCategory: line.fromStockCategory ?? null, unitCost: line.unitCost ?? null, }); } const violation = checkStockAdjustmentInvariants(id, currentHeader.adjustmentType, mergedLines); if (violation) { return err(violation); } // ===== 4. Apply the adjustment changes ===== // Header — write the patch; a REJECTED adjustment reverts to DRAFT and clears its reason. const updateData: Updateable<"StockAdjustment"> = { ...(headerPatch as Updateable<"StockAdjustment">), }; if (currentHeader.status === "REJECTED") { updateData.status = "DRAFT"; updateData.rejectionReason = null; } let stockAdjustment = currentHeader; // Skip an empty patch; kysely can't compile a SET with no columns. if (Object.values(updateData).some((value) => value !== undefined)) { stockAdjustment = await db .updateTable("StockAdjustment") .set(updateData) .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<"StockAdjustmentLine"> = { ...linePatch, }; // Skip an empty patch; kysely can't compile a SET with no columns. if (Object.values(lineUpdates).some((value) => value !== undefined)) { await db .updateTable("StockAdjustmentLine") .set(lineUpdates) .where("id", "=", lineId) .execute(); } } // Removed lines — delete. if (removeLineIds.length > 0) { await db.deleteFrom("StockAdjustmentLine").where("id", "in", removeLineIds).execute(); } // Added lines — insert. const addRows = addLines.map((line) => { return { // Raw line first so command-set columns always win. ...(line as Record), stockAdjustmentId: id, itemId: line.itemId, storageLocationId: line.storageLocationId, quantity: line.quantity, adjustmentDirection: line.adjustmentDirection ?? null, fromStockCategory: line.fromStockCategory ?? null, unitCost: line.unitCost ?? null, }; }); if (addRows.length > 0) { await db.insertInto("StockAdjustmentLine").values(addRows).execute(); } return ok({ stockAdjustment }); }