// Stock-at-deadline check for the shipping-performance SLA (direct Postgres). // // Question answered per order: "could the warehouse have shipped this order on its deadline day?" // For every item line (m_product.ProductType = 'I', QtyOrdered > 0) we compute the on-hand quantity // of the product in the ORDER'S warehouse at the END of the deadline day, restricted to locators // whose type allows shipping / replenishment / reservation (same rule as the dashboard SameDay card). // // Historic stock = current m_storage.QtyOnHand minus every m_transaction.MovementQty dated AFTER // the deadline day (backward method, identical to materials/products/qty-on-hand-pit.get.ts: the sum // of all transactions of a product reconciles exactly with its current storage). // // Why end of the deadline day: the order was NOT shipped that day, so none of its own demand was // consumed; whatever was left at the end of that day is exactly what was available to it (orders // that did ship that day are already deducted). Deadlines in the future have no later movements → // current stock. // // Rule (user decision 2026-10-27): an order is stock-blocked when AT LEAST ONE line needed more than // was on hand (`needed > onHand`). QtyOrdered is used (not QtyOrdered - QtyDelivered) because the // judgement point is before the first shipment. // // Fail-soft: without PG credentials or on any query error `available` is false and the caller keeps // its unmodified classification. import { Pool } from 'pg' export type StockShortfallLine = { productId: number; sku: string; needed: number; onHand: number } export type StockShortfall = { orderId: number; lines: StockShortfallLine[] } export type StockCheckResult = { available: boolean blocked: Map ms: number checked: number error?: string } const CHUNK = 500 const STATEMENT_TIMEOUT_MS = 30000 // Shared pool (survives HMR via globalThis, like telephony/cdr.ts) const g = globalThis as any const getPool = (): Pool | null => { if (g.__biStockPool) return g.__biStockPool as Pool const config = useRuntimeConfig() if (!config.pgHost || !config.pgDatabase || !config.pgUser || !config.pgPassword) return null const pool = new Pool({ host: config.pgHost, port: parseInt(config.pgPort || '5432'), database: config.pgDatabase, user: config.pgUser, password: config.pgPassword, max: 4, idleTimeoutMillis: 60000, statement_timeout: STATEMENT_TIMEOUT_MS }) pool.on('error', (err) => console.error('stockAtDeadline: pool error', err)) g.__biStockPool = pool return pool } const SHIPPABLE_LOCATOR = (lt: string) => `(${lt}.m_locatortype_id IS NULL OR ${lt}.isavailableforshipping = 'Y' OR ${lt}.isavailableforreplenishment = 'Y' OR ${lt}.isavailableforreservation = 'Y')` // $1 = int[] order ids, $2 = date[] deadlines (parallel arrays) const SQL = ` WITH req AS ( SELECT c_order_id, deadline FROM unnest($1::int[], $2::date[]) AS t(c_order_id, deadline) ), lines AS ( SELECT r.c_order_id, r.deadline, o.m_warehouse_id, ol.m_product_id, COALESCE(NULLIF(p.sku, ''), p.value) AS sku, SUM(ol.qtyordered) AS needed FROM req r JOIN c_order o ON o.c_order_id = r.c_order_id JOIN c_orderline ol ON ol.c_order_id = o.c_order_id JOIN m_product p ON p.m_product_id = ol.m_product_id WHERE p.producttype = 'I' AND ol.qtyordered > 0 GROUP BY r.c_order_id, r.deadline, o.m_warehouse_id, ol.m_product_id, p.sku, p.value ), needed_products AS ( SELECT DISTINCT m_product_id, m_warehouse_id FROM lines ), cur AS ( SELECT s.m_product_id, l.m_warehouse_id, SUM(s.qtyonhand) AS qty FROM m_storage s JOIN needed_products np ON np.m_product_id = s.m_product_id JOIN m_locator l ON l.m_locator_id = s.m_locator_id AND l.m_warehouse_id = np.m_warehouse_id LEFT JOIN m_locatortype lt ON lt.m_locatortype_id = l.m_locatortype_id WHERE ${SHIPPABLE_LOCATOR('lt')} GROUP BY s.m_product_id, l.m_warehouse_id ) SELECT ln.c_order_id, ln.m_product_id, ln.sku, ln.needed, COALESCE(cur.qty, 0) - COALESCE(( SELECT SUM(tx.movementqty) FROM m_transaction tx JOIN m_locator tl ON tl.m_locator_id = tx.m_locator_id LEFT JOIN m_locatortype tlt ON tlt.m_locatortype_id = tl.m_locatortype_id WHERE tx.m_product_id = ln.m_product_id AND tl.m_warehouse_id = ln.m_warehouse_id AND tx.movementdate > ln.deadline AND ${SHIPPABLE_LOCATOR('tlt')} ), 0) AS on_hand FROM lines ln LEFT JOIN cur ON cur.m_product_id = ln.m_product_id AND cur.m_warehouse_id = ln.m_warehouse_id ` /** * Find orders that could not have been shipped on their deadline day because at least one item * line exceeded the on-hand stock in the order's warehouse at the end of that day. */ export const findStockBlockedOrders = async ( pairs: { orderId: number; deadlineDate: string }[] ): Promise => { const started = Date.now() const blocked = new Map() const valid = pairs.filter(p => Number.isFinite(p.orderId) && p.orderId > 0 && /^\d{4}-\d{2}-\d{2}$/.test(p.deadlineDate)) if (!valid.length) return { available: true, blocked, ms: 0, checked: 0 } const pool = getPool() if (!pool) return { available: false, blocked, ms: 0, checked: 0, error: 'PG not configured' } try { for (let i = 0; i < valid.length; i += CHUNK) { const chunk = valid.slice(i, i + CHUNK) const res = await pool.query(SQL, [chunk.map(c => c.orderId), chunk.map(c => c.deadlineDate)]) for (const r of res.rows) { const needed = Number(r.needed) || 0 const onHand = Number(r.on_hand) || 0 if (needed <= onHand) continue const orderId = Number(r.c_order_id) const entry = blocked.get(orderId) || { orderId, lines: [] } entry.lines.push({ productId: Number(r.m_product_id), sku: r.sku || String(r.m_product_id), needed, onHand }) blocked.set(orderId, entry) } } return { available: true, blocked, ms: Date.now() - started, checked: valid.length } } catch (err: any) { console.error('stockAtDeadline: query failed', err?.message || err) return { available: false, blocked: new Map(), ms: Date.now() - started, checked: valid.length, error: err?.message || String(err) } } } /** Human-readable shortfall, e.g. "SKU-1: needed 3, had 0 · SKU-2: needed 1, had 0" */ export const formatShortfall = (s: StockShortfall | undefined, max = 4): string => { if (!s?.lines.length) return '' const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)) const parts = s.lines.slice(0, max).map(l => `${l.sku}: needed ${fmt(l.needed)}, had ${fmt(Math.max(0, l.onHand))}`) if (s.lines.length > max) parts.push(`+${s.lines.length - max} more`) return parts.join(' · ') }