import { err, ok } from "@tailor-platform/erp-kit/core"; import { Decimal } from "decimal.js"; import { mergeCustomFields, type CustomFields } from "../../../shared/domain"; import { outboundShipmentLifecycle } from "../db/outboundShipment.lifecycle.generated"; import type { OutboundShipmentLineSourceDocumentType, OutboundShipmentLineStockType, OutboundShipmentStatus, } from "../generated/enums"; import { EmptyShipmentLinesError, InvalidQuantityError, InvalidStatusError, InvalidUnitConversionRateError, ItemNotFoundError, PrimaryUnitMismatchError, ShipmentLineNotFoundError, StorageLocationNotFoundError, } from "../lib/errors.generated"; // ===== Aggregate state ===== export type OutboundShipmentHeader = { readonly status: OutboundShipmentStatus; readonly effectiveDate: Date; readonly postedAt: Date | null; readonly customFields: CustomFields; }; export type OutboundShipmentLine = { readonly id: string; readonly itemId: string; readonly quantity: string; readonly unitId: string; readonly primaryQuantity: string; readonly primaryUnitId: string; readonly unitConversionRate: string; readonly storageLocationId: string; readonly stockType: OutboundShipmentLineStockType; readonly sourceDocumentType: OutboundShipmentLineSourceDocumentType; readonly sourceDocumentId: string; readonly sourceLineId: string; readonly customFields: CustomFields; }; declare const invariantsSatisfied: unique symbol; export type OutboundShipment = { readonly id: string; readonly header: OutboundShipmentHeader; readonly lines: readonly OutboundShipmentLine[]; readonly [invariantsSatisfied]: true; }; export type UncheckedOutboundShipment = Omit; // Verifies nothing. The caller is responsible for the invariants. export function markInvariantsSatisfied(shipment: UncheckedOutboundShipment): OutboundShipment { return shipment as OutboundShipment; } // ===== Referenced master-data rules ===== // Inactive items are accepted because shipments may post long after the source // document was created. function checkLineItems( lines: readonly OutboundShipmentLine[], 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 OutboundShipmentLine[], storageLocationById: ReadonlyMap, ) { for (const line of lines) { if (!storageLocationById.has(line.storageLocationId)) { return new StorageLocationNotFoundError(line.storageLocationId); } } return null; } // ===== Creation ===== export interface NewOutboundShipmentLine { itemId: string; storageLocationId: string; quantity: string; unitId: string; primaryQuantity: string; primaryUnitId: string; unitConversionRate: string; stockType?: OutboundShipmentLineStockType; sourceDocumentType: OutboundShipmentLineSourceDocumentType; sourceDocumentId: string; sourceLineId: string; customFields: CustomFields; } export interface CreateDraftParams { effectiveDate: Date; customFields: CustomFields; lines: NewOutboundShipmentLine[]; } export interface CreateDraftRefs { itemById: ReadonlyMap; storageLocationById: ReadonlyMap; } function toOutboundShipmentLine(line: NewOutboundShipmentLine): OutboundShipmentLine { return { id: crypto.randomUUID(), itemId: line.itemId, quantity: line.quantity, unitId: line.unitId, primaryQuantity: line.primaryQuantity, primaryUnitId: line.primaryUnitId, unitConversionRate: line.unitConversionRate, storageLocationId: line.storageLocationId, stockType: line.stockType ?? "AVAILABLE", sourceDocumentType: line.sourceDocumentType, sourceDocumentId: line.sourceDocumentId, sourceLineId: line.sourceLineId, customFields: line.customFields, }; } function checkFieldInvariants(shipment: UncheckedOutboundShipment) { 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: UncheckedOutboundShipment = { id: crypto.randomUUID(), header: { status: "DRAFT", effectiveDate: params.effectiveDate, postedAt: null, customFields: params.customFields, }, lines: params.lines.map(toOutboundShipmentLine), }; 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 OutboundShipmentHeaderPatch { effectiveDate?: Date; customFields?: CustomFields; } export interface OutboundShipmentLinePatch { itemId?: string; storageLocationId?: string; quantity?: string; unitId?: string; primaryQuantity?: string; primaryUnitId?: string; unitConversionRate?: string; stockType?: OutboundShipmentLineStockType; sourceDocumentType?: OutboundShipmentLineSourceDocumentType; sourceDocumentId?: string; sourceLineId?: string; customFields?: CustomFields; } export interface UpdateDraftParams { headerPatch: OutboundShipmentHeaderPatch; addLines: NewOutboundShipmentLine[]; updateLines: { lineId: string; patch: OutboundShipmentLinePatch }[]; removeLineIds: string[]; } export type UpdateDraftRefs = CreateDraftRefs; export function updateDraft( shipment: OutboundShipment, params: UpdateDraftParams, refs: UpdateDraftRefs, ) { const { headerPatch, addLines, updateLines, removeLineIds } = params; if (!outboundShipmentLifecycle.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: OutboundShipmentLine[] = []; // Untouched lines keep the master data they were validated against when they // were written, so only (re)chosen references are judged below. const itemTouchedLines: OutboundShipmentLine[] = []; const locationTouchedLines: OutboundShipmentLine[] = []; 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: OutboundShipmentLine = { ...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(toOutboundShipmentLine); mergedLines.push(...addedLines); itemTouchedLines.push(...addedLines); locationTouchedLines.push(...addedLines); const updated: UncheckedOutboundShipment = { 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: OutboundShipment) { const nextStatus = outboundShipmentLifecycle.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: OutboundShipment, postedAt: Date) { const nextStatus = outboundShipmentLifecycle.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 shipped quantities to push to sales, in the source unit. */ export function sumFulfillmentQuantitiesBySalesOrderLine(shipment: OutboundShipment) { const shippedBySalesOrderLineId = new Map(); for (const line of shipment.lines) { if (line.sourceDocumentType !== "SALES_ORDER") { continue; } shippedBySalesOrderLineId.set( line.sourceLineId, (shippedBySalesOrderLineId.get(line.sourceLineId) ?? new Decimal(0)).plus(line.quantity), ); } return [...shippedBySalesOrderLineId.entries()].map(([salesOrderLineId, quantity]) => ({ salesOrderLineId, quantity: quantity.toString(), })); }