import { ok, err, type CommandContext } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import type { StockAdjustmentAdjustmentType, StockAdjustmentLineAdjustmentDirection, StockAdjustmentLineFromStockCategory, } from "../generated/enums"; import type { Transaction } from "../generated/kysely-tailordb"; import { ReasonCodeRequiredError, EmptyAdjustmentLinesError, InvalidQuantityError, ItemNotFoundError, ItemNotActiveError, StorageLocationNotFoundError, StorageLocationNotActiveError, InvalidFromStockCategoryError, } from "../lib/errors.generated"; import type { ItemManagementQueries } from "../module"; export interface StockAdjustmentLineInput { itemId: string; storageLocationId: string; quantity: string; adjustmentDirection?: StockAdjustmentLineAdjustmentDirection | null; fromStockCategory?: StockAdjustmentLineFromStockCategory | null; unitCost?: string | null; } export interface StockAdjustmentHeaderInput { adjustmentType: StockAdjustmentAdjustmentType; reasonCode: string; adjustmentDate: Date; } export interface CreateStockAdjustmentInput { header: StockAdjustmentHeaderInput; lines: StockAdjustmentLineInput[]; } /** * Function: createStockAdjustment * * Creates a new stock adjustment document in DRAFT status. * Stock adjustments are used to correct inventory discrepancies, * scrap damaged goods, or block/unblock stock for quality holds. */ export async function run, LCF extends Record>( db: Transaction, input: { header: StockAdjustmentHeaderInput & CF; lines: (StockAdjustmentLineInput & LCF)[]; }, ctx: CommandContext, itemManagementQueries: Pick, ) { const { header, lines } = input; const { adjustmentType, reasonCode, adjustmentDate } = header; // 1. Validate reasonCode if (!reasonCode || reasonCode.trim() === "") { return err(new ReasonCodeRequiredError("reasonCode")); } // 2. Validate at least one line if (!lines || lines.length === 0) { return err(new EmptyAdjustmentLinesError("lines")); } // 3. Validate each line for (const line of lines) { // quantity > 0 if (new Decimal(line.quantity).lte(0)) { return err(new InvalidQuantityError(line.quantity)); } // item must exist and be ACTIVE const { item } = (await itemManagementQueries.getItem(db, { id: line.itemId }, ctx)).value; if (!item) { return err(new ItemNotFoundError(line.itemId)); } if (item.status !== "ACTIVE") { return err(new ItemNotActiveError(line.itemId)); } // storage location must exist const location = await db .selectFrom("StorageLocation") .selectAll() .where("id", "=", line.storageLocationId) .executeTakeFirst(); if (!location) { return err(new StorageLocationNotFoundError(line.storageLocationId)); } if (location.status !== "ACTIVE") { return err(new StorageLocationNotActiveError(line.storageLocationId)); } // fromStockCategory only valid for SCRAP type if (line.fromStockCategory && adjustmentType !== "SCRAP") { return err(new InvalidFromStockCategoryError(line.itemId)); } } // 4. Insert StockAdjustment const stockAdjustment = await db .insertInto("StockAdjustment") .values({ // Header input first (incl. custom fields) so command-set columns always win. ...(header as Record), adjustmentType, status: "DRAFT", reasonCode, adjustmentDate, rejectionReason: null, }) .returningAll() .executeTakeFirstOrThrow(); // 5. Insert StockAdjustmentLines await db .insertInto("StockAdjustmentLine") .values( lines.map((line) => { const { itemId, storageLocationId, quantity, adjustmentDirection, fromStockCategory, unitCost, ...lineCustomFields } = line; return { ...lineCustomFields, stockAdjustmentId: stockAdjustment.id, itemId, storageLocationId, quantity, adjustmentDirection: adjustmentDirection ?? null, fromStockCategory: fromStockCategory ?? null, unitCost: unitCost ?? null, }; }), ) .execute(); return ok({ stockAdjustment }); }