import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { mergeCustomFields, type CustomFields } from "../../../shared/domain"; import { inboundShipmentLifecycle } from "../db/inboundShipment.lifecycle.generated"; import type { InboundShipmentLineSourceDocumentType, InboundShipmentLineStockType, InboundShipmentStatus, } from "../generated/enums"; import { EmptyShipmentLinesError, InvalidQuantityError, InvalidStatusError, InvalidUnitConversionRateError, ItemNotFoundError, PrimaryUnitMismatchError, ShipmentLineNotFoundError, StorageLocationNotFoundError, } from "../lib/errors.generated"; // ===== Aggregate state ===== export type InboundShipmentHeader = { readonly status: InboundShipmentStatus; readonly effectiveDate: Date; readonly postedAt: Date | null; readonly customFields: CustomFields; }; export type InboundShipmentLine = { readonly id: string; readonly itemId: string; readonly quantity: string; readonly unitId: string; readonly primaryQuantity: string; readonly primaryUnitId: string; readonly unitConversionRate: string; readonly unitCost: string | null; readonly storageLocationId: string; readonly stockType: InboundShipmentLineStockType; readonly sourceDocumentType: InboundShipmentLineSourceDocumentType; readonly sourceDocumentId: string; readonly sourceLineId: string; readonly customFields: CustomFields; }; declare const invariantsSatisfied: unique symbol; export type InboundShipment = { readonly id: string; readonly header: InboundShipmentHeader; readonly lines: readonly InboundShipmentLine[]; readonly [invariantsSatisfied]: true; }; export type UncheckedInboundShipment = Omit; // Verifies nothing. The caller is responsible for the invariants. export function markInvariantsSatisfied(shipment: UncheckedInboundShipment): InboundShipment { return shipment as InboundShipment; } // ===== Referenced master-data rules ===== // Inactive items are accepted because receipts may post long after the source // document was created. function checkLineItems( lines: readonly InboundShipmentLine[], itemById: ReadonlyMap, ) { for (const line of lines) { const item = itemById.get(line.itemId); if (!item) { return new ItemNotFoundError(line.itemId); } if (line.primaryUnitId !== item.unitId) { return new PrimaryUnitMismatchError(`${line.primaryUnitId} expected ${item.unitId}`); } } return null; } // Inactive locations are accepted for historical continuity. function checkLineStorageLocations( lines: readonly InboundShipmentLine[], storageLocationById: ReadonlyMap, ) { for (const line of lines) { if (!storageLocationById.has(line.storageLocationId)) { return new StorageLocationNotFoundError(line.storageLocationId); } } return null; } // ===== Creation ===== export interface NewInboundShipmentLine { itemId: string; storageLocationId: string; quantity: string; unitId: string; primaryQuantity: string; primaryUnitId: string; unitConversionRate: string; stockType?: InboundShipmentLineStockType; sourceDocumentType: InboundShipmentLineSourceDocumentType; sourceDocumentId: string; sourceLineId: string; customFields: CustomFields; } export interface CreateDraftParams { effectiveDate: Date; customFields: CustomFields; lines: NewInboundShipmentLine[]; } export interface CreateDraftRefs { itemById: ReadonlyMap; storageLocationById: ReadonlyMap; } // unitCost is never taken as input: it stays null while DRAFT and is derived // from the purchase-order line price at posting. function toInboundShipmentLine(line: NewInboundShipmentLine): InboundShipmentLine { return { id: crypto.randomUUID(), itemId: line.itemId, quantity: line.quantity, unitId: line.unitId, primaryQuantity: line.primaryQuantity, primaryUnitId: line.primaryUnitId, unitConversionRate: line.unitConversionRate, unitCost: null, storageLocationId: line.storageLocationId, stockType: line.stockType ?? "AVAILABLE", sourceDocumentType: line.sourceDocumentType, sourceDocumentId: line.sourceDocumentId, sourceLineId: line.sourceLineId, customFields: line.customFields, }; } function checkFieldInvariants(shipment: UncheckedInboundShipment) { if (shipment.lines.length === 0) { return new EmptyShipmentLinesError(shipment.id); } for (const line of shipment.lines) { if (new Decimal(line.quantity).lte(0)) { return new InvalidQuantityError(line.quantity); } if (new Decimal(line.primaryQuantity).lte(0)) { return new InvalidQuantityError(line.primaryQuantity); } if (new Decimal(line.unitConversionRate).lte(0)) { return new InvalidUnitConversionRateError(line.unitConversionRate); } } return null; } export function createDraft(params: CreateDraftParams, refs: CreateDraftRefs) { const shipment: UncheckedInboundShipment = { id: crypto.randomUUID(), header: { status: "DRAFT", effectiveDate: params.effectiveDate, postedAt: null, customFields: params.customFields, }, lines: params.lines.map(toInboundShipmentLine), }; const violation = checkLineItems(shipment.lines, refs.itemById) ?? checkLineStorageLocations(shipment.lines, refs.storageLocationById) ?? checkFieldInvariants(shipment); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(shipment)); } // ===== Draft update ===== export interface InboundShipmentHeaderPatch { effectiveDate?: Date; customFields?: CustomFields; } export interface InboundShipmentLinePatch { itemId?: string; storageLocationId?: string; quantity?: string; unitId?: string; primaryQuantity?: string; primaryUnitId?: string; unitConversionRate?: string; stockType?: InboundShipmentLineStockType; sourceDocumentType?: InboundShipmentLineSourceDocumentType; sourceDocumentId?: string; sourceLineId?: string; customFields?: CustomFields; } export interface UpdateDraftParams { headerPatch: InboundShipmentHeaderPatch; addLines: NewInboundShipmentLine[]; updateLines: { lineId: string; patch: InboundShipmentLinePatch }[]; removeLineIds: string[]; } export type UpdateDraftRefs = CreateDraftRefs; export function updateDraft( shipment: InboundShipment, params: UpdateDraftParams, refs: UpdateDraftRefs, ) { const { headerPatch, addLines, updateLines, removeLineIds } = params; if (!inboundShipmentLifecycle.tryTransition(shipment.header.status, "update")) { return err(new InvalidStatusError(shipment.id)); } const currentLineIds = new Set(shipment.lines.map((line) => line.id)); const patchByLineId = new Map(updateLines.map((edit) => [edit.lineId, edit.patch])); for (const lineId of [...patchByLineId.keys(), ...removeLineIds]) { if (!currentLineIds.has(lineId)) { return err(new ShipmentLineNotFoundError(lineId)); } } const mergedLines: InboundShipmentLine[] = []; // Untouched lines keep the master data they were validated against when they // were written, so only (re)chosen references are judged below. const itemTouchedLines: InboundShipmentLine[] = []; const locationTouchedLines: InboundShipmentLine[] = []; for (const line of shipment.lines) { if (removeLineIds.includes(line.id)) { continue; } const patch = patchByLineId.get(line.id); if (!patch) { mergedLines.push(line); continue; } const merged: InboundShipmentLine = { ...line, itemId: patch.itemId === undefined ? line.itemId : patch.itemId, quantity: patch.quantity === undefined ? line.quantity : patch.quantity, unitId: patch.unitId === undefined ? line.unitId : patch.unitId, primaryQuantity: patch.primaryQuantity === undefined ? line.primaryQuantity : patch.primaryQuantity, primaryUnitId: patch.primaryUnitId === undefined ? line.primaryUnitId : patch.primaryUnitId, unitConversionRate: patch.unitConversionRate === undefined ? line.unitConversionRate : patch.unitConversionRate, storageLocationId: patch.storageLocationId === undefined ? line.storageLocationId : patch.storageLocationId, stockType: patch.stockType === undefined ? line.stockType : patch.stockType, sourceDocumentType: patch.sourceDocumentType === undefined ? line.sourceDocumentType : patch.sourceDocumentType, sourceDocumentId: patch.sourceDocumentId === undefined ? line.sourceDocumentId : patch.sourceDocumentId, sourceLineId: patch.sourceLineId === undefined ? line.sourceLineId : patch.sourceLineId, customFields: mergeCustomFields(line.customFields, patch.customFields), }; if (patch.itemId !== undefined || patch.primaryUnitId !== undefined) { itemTouchedLines.push(merged); } if (patch.storageLocationId !== undefined) { locationTouchedLines.push(merged); } mergedLines.push(merged); } const addedLines = addLines.map(toInboundShipmentLine); mergedLines.push(...addedLines); itemTouchedLines.push(...addedLines); locationTouchedLines.push(...addedLines); const updated: UncheckedInboundShipment = { id: shipment.id, header: { ...shipment.header, effectiveDate: headerPatch.effectiveDate === undefined ? shipment.header.effectiveDate : headerPatch.effectiveDate, customFields: mergeCustomFields(shipment.header.customFields, headerPatch.customFields), }, lines: mergedLines, }; const violation = checkLineItems(itemTouchedLines, refs.itemById) ?? checkLineStorageLocations(locationTouchedLines, refs.storageLocationById) ?? checkFieldInvariants(updated); if (violation) { return err(violation); } return ok(markInvariantsSatisfied(updated)); } // ===== Cancellation ===== export function cancel(shipment: InboundShipment) { const nextStatus = inboundShipmentLifecycle.tryTransition(shipment.header.status, "cancel"); if (!nextStatus) { return err(new InvalidStatusError(shipment.id)); } return ok( markInvariantsSatisfied({ ...shipment, header: { ...shipment.header, status: nextStatus }, }), ); } // ===== Posting ===== // The lines are re-checked for emptiness because posting is what turns the // draft snapshot into stock effects. export function post(shipment: InboundShipment, postedAt: Date) { const nextStatus = inboundShipmentLifecycle.tryTransition(shipment.header.status, "post"); if (!nextStatus) { return err(new InvalidStatusError(shipment.id)); } if (shipment.lines.length === 0) { return err(new EmptyShipmentLinesError(shipment.id)); } return ok( markInvariantsSatisfied({ ...shipment, header: { ...shipment.header, status: nextStatus, postedAt }, }), ); } /** The received quantities to push to purchase, in the source unit. */ export function sumReceiptQuantitiesBySourceLine(shipment: InboundShipment) { const receivedBySourceLineId = new Map(); for (const line of shipment.lines) { receivedBySourceLineId.set( line.sourceLineId, (receivedBySourceLineId.get(line.sourceLineId) ?? new Decimal(0)).plus(line.quantity), ); } return [...receivedBySourceLineId.entries()].map(([sourceLineId, quantity]) => ({ sourceLineId, quantity: quantity.toString(), })); } // The purchase-order price is per source unit; the ledger quantity is // primaryQuantity, so convert: unitCost = unitPrice / unitConversionRate. export function assignUnitCosts( shipment: InboundShipment, unitPriceBySourceLineId: ReadonlyMap, ) { const lines = shipment.lines.map((line) => { const unitPrice = unitPriceBySourceLineId.get(line.sourceLineId); if (unitPrice === undefined) { return line; } return { ...line, unitCost: new Decimal(unitPrice).div(line.unitConversionRate).toString() }; }); return markInvariantsSatisfied({ ...shipment, lines }); }