export type StockQtyRoundMode = "CEILING" | "FLOOR"; export type StockQtyRoundSource = "CREATE" | "QC"; export type StockQtyRoundChoice = { mode: StockQtyRoundMode; before: number; after: number; }; /** Keep in sync with ItemUomService.isIntegerCountStockUom */ const INTEGER_COUNT_UNIT_TOKENS = new Set([ "包", "箱", "件", "個", "PCS", "PC", "CTN", "EA", "PK", "PKT", "BOX", ]); export type PoStockQtyConvertHint = { sourceRatioN?: number | null; sourceRatioD?: number | null; stockRatioN?: number | null; stockRatioD?: number | null; purchaseRatioN?: number | null; purchaseRatioD?: number | null; stockUomCode?: string | null; stockUomShortDesc?: string | null; stockQtyInteger?: boolean | null; }; function toRatio(value: number | null | undefined): number | null { const n = Number(value); if (!Number.isFinite(n) || n === 0) return null; return n; } function stockQtyMustBeInteger(hint?: PoStockQtyConvertHint): boolean { if (hint?.stockQtyInteger === true) return true; if (hint?.stockQtyInteger === false) return false; const code = hint?.stockUomCode?.trim() ?? ""; if (code.toUpperCase().startsWith("PCS/CTN")) return true; const short = hint?.stockUomShortDesc?.trim() ?? ""; return INTEGER_COUNT_UNIT_TOKENS.has(short) || INTEGER_COUNT_UNIT_TOKENS.has(code.toUpperCase()); } /** Java BigDecimal HALF_UP for non-negative qty. */ function roundHalfUp(value: number, scale: number): number { const factor = 10 ** scale; return Math.round(value * factor) / factor; } function finalizePoStockQty(raw: number, hint?: PoStockQtyConvertHint): number { if (!Number.isFinite(raw)) return 0; return stockQtyMustBeInteger(hint) ? roundHalfUp(raw, 0) : roundHalfUp(raw, 2); } /** * Same ratio path as backend convertQtyToStockQtyPrecise: * base = qty * sourceRatioN / sourceRatioD * stock = base * stockRatioD / stockRatioN */ function convertPoBatchToStockQty( batchM18Qty: number, hint?: PoStockQtyConvertHint, ): number | null { if (!hint || !Number.isFinite(batchM18Qty)) return null; const sourceN = toRatio(hint.sourceRatioN) ?? toRatio(hint.purchaseRatioN); const sourceD = toRatio(hint.sourceRatioD) ?? toRatio(hint.purchaseRatioD); const stockN = toRatio(hint.stockRatioN); const stockD = toRatio(hint.stockRatioD); if (sourceN == null || sourceD == null || stockN == null || stockD == null) return null; const baseQty = (batchM18Qty * sourceN) / sourceD; const stockQty = (baseQty * stockD) / stockN; return finalizePoStockQty(stockQty, hint); } /** * Preview converted stock qty for a 來貨數 batch. * Matches create-stock-in: ratio convert, then integer HALF_UP for 包/箱/PCS, else 2 decimals. */ export function previewPoBatchStockQty( orderM18Qty: number, orderStockQty: number, batchM18Qty: number, hint?: PoStockQtyConvertHint, ): number { const fromRatios = convertPoBatchToStockQty(batchM18Qty, hint); if (fromRatios != null) return fromRatios; if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { return finalizePoStockQty(batchM18Qty, hint); } return finalizePoStockQty((batchM18Qty * orderStockQty) / orderM18Qty, hint); } export function isNotIntegerQty(qty: number): boolean { if (!Number.isFinite(qty)) return false; return Math.abs(qty - Math.round(qty)) > 1e-9; } /** * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 * PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR */ export function needsPoQcStockQtyRound( purchaseOrderLineId: number | null | undefined, status: string | null | undefined, acceptedQty: number | null | undefined, ): boolean { if (!purchaseOrderLineId) return false; const silStatus = (status ?? "").toLowerCase().trim(); if (silStatus !== "pending" && silStatus !== "escalated") return false; return isNotIntegerQty(Number(acceptedQty ?? 0)); } /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ export function roundStockQty(before: number, mode: StockQtyRoundMode): number { if (mode === "CEILING") return Math.ceil(before); return Math.floor(before); }