// Shipping performance (cut-off SLA) per merchant organization. // // Rule: an order imported (c_order.Created, Berlin local) before the cut-off time on a working day // must ship that day; everything else must ship by the cut-off of the NEXT working day // (Mon–Fri, minus CUST_NonWorkingDays). "Shipped" = earliest customer shipment (m_inout C-) of the // order with shipping_date (set at commissioning; true UTC) – fallback ProcessedOn when commissioned // without a shipping_date. Day granularity: shipped on or before the deadline's date = on time. // Cut-off = ?cutoff=HH:MM override → c_bpartner.CutOffTime of the org's partner → 14:00. // // Stock-blocked orders: an order that is not on time is checked against the historic stock of its // item lines in the order's warehouse at the END of the deadline day (m_storage minus later // m_transaction movements, direct Postgres – server/utils/bi/stockAtDeadline.ts). If at least one // line needed more than was on hand the order gets status 'nostock' and is excluded from every rate // (on-time, SLA, lateness, lead time). ?nostock=include keeps the original status (the row still // carries stockBlocked/stockNote). Without PG the check is skipped (stockCheck: 'unavailable'). // // Query: org_id (admins; limited roles are forced to their own org), from, to (YYYY-MM-DD, order // Created window, default last 3 months), cutoff (HH:MM, optional), nostock (exclude|include). import { string } from 'alga-js' import refreshTokenHelper from '../../utils/refreshTokenHelper' import errorHandlingHelper from '../../utils/errorHandlingHelper' import forceLogoutHelper from '../../utils/forceLogoutHelper' import fetchHelper from '../../utils/fetchHelper' import { berlinParts, idempiereLocalToInstantMs, utcIsoToInstantMs, berlinLocalToInstantMs, dateKey, monthKey, addDays, weekdayOf, pad2 } from '../../utils/berlinTime' import { findStockBlockedOrders, formatShortfall } from '../../utils/bi/stockAtDeadline' const DEFAULT_CUTOFF = '14:00' const PAGE = 1000 const MAX_ORDERS = 20000 const MAX_SHIPMENTS = 40000 const EXCLUDED_DOCSTATUS = new Set(['VO', 'RE', 'IN', 'NA']) const isoDate = (d: Date) => d.toISOString().slice(0, 10) const validDate = (v: any) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null const parseCutoff = (v: any): { h: number; mi: number } | null => { if (!v) return null const m = String(v).match(/(\d{1,2}):(\d{2})/) if (!m) return null const h = Number(m[1]), mi = Number(m[2]) if (h > 23 || mi > 59) return null return { h, mi } } const idOf = (v: any): number | null => (typeof v === 'number' ? v : (v?.id ?? null)) const pageAll = async (event: any, token: string, base: string, cap: number) => { const out: any[] = [] for (let skip = 0; skip < cap; skip += PAGE) { const top = Math.min(PAGE, cap - skip) const res: any = await fetchHelper(event, `${base}&$top=${top}&$skip=${skip}`, 'GET', token, null) const page = res?.records || [] out.push(...page) const total = Number(res?.['row-count']) || 0 if (page.length < top || (total && out.length >= total)) break } return out } type Status = 'ontime' | 'late' | 'pending' | 'overdue' | 'nostock' type Bucket = { key: string label: string orders: number shipped: number onTime: number late: number overdue: number pending: number noStock: number leadSum: number leadCount: number onTimeRate: number | null avgLeadHours: number | null } const newBucket = (key: string, label = key): Bucket => ({ key, label, orders: 0, shipped: 0, onTime: 0, late: 0, overdue: 0, pending: 0, noStock: 0, leadSum: 0, leadCount: 0, onTimeRate: null, avgLeadHours: null }) const finishBucket = (b: Bucket) => { const judged = b.onTime + b.late + b.overdue b.onTimeRate = judged > 0 ? Math.round((b.onTime / judged) * 1000) / 10 : null b.avgLeadHours = b.leadCount > 0 ? Math.round((b.leadSum / b.leadCount) * 10) / 10 : null return b } const handleFunc = async (event: any, authToken: any = null) => { const token = authToken ?? await getTokenHelper(event) const query = getQuery(event) // Org scope: client admins may pick any org, everybody else is pinned to the cookie org let role: any = {} try { role = JSON.parse(getCookie(event, 'logship_role') ?? '{}') } catch { role = {} } const cookieOrgId = Number(getCookie(event, 'logship_organization_id')) || 0 const requestedOrgId = Number(query.org_id) || 0 const orgId = role?.IsClientAdministrator ? (requestedOrgId || cookieOrgId) : cookieOrgId if (!orgId) { setResponseStatus(event, 400) return { status: 400, message: 'Organization is required' } } const today = new Date() const to = validDate(query.to) ?? isoDate(today) const from = validDate(query.from) ?? isoDate(new Date(today.getTime() - 90 * 86400000)) const noStockMode: 'exclude' | 'include' = String(query.nostock || '').toLowerCase() === 'include' ? 'include' : 'exclude' // ---- cut-off time ------------------------------------------------------------------------ const org: any = await fetchHelper(event, `models/ad_org/${orgId}?$select=Name,C_BPartner_ID`, 'GET', token, null) const orgName = org?.Name || org?.identifier || String(orgId) const orgBpId = idOf(org?.C_BPartner_ID) let partnerCutoff: string | null = null if (orgBpId) { try { const bp: any = await fetchHelper(event, `models/c_bpartner/${orgBpId}?$select=Name,CutOffTime`, 'GET', token, null) const parsed = parseCutoff(bp?.CutOffTime ?? bp?.cutOffTime) if (parsed) partnerCutoff = `${pad2(parsed.h)}:${pad2(parsed.mi)}` } catch (err) { console.error('shipping-performance: partner cut-off lookup failed', err) } } const override = parseCutoff(query.cutoff) const cutoff = override ?? parseCutoff(partnerCutoff) ?? parseCutoff(DEFAULT_CUTOFF)! const cutoffLabel = `${pad2(cutoff.h)}:${pad2(cutoff.mi)}` const cutoffSource = override ? 'override' : (partnerCutoff ? 'partner' : 'default') const cutoffMinutes = cutoff.h * 60 + cutoff.mi // ---- non-working days (tenant-wide org 0 + this org) --------------------------------------- const nonWorking = new Set() try { const nwRes: any = await fetchHelper( event, `models/cust_nonworkingdays?$filter=${string.urlEncode(`IsActive eq true and (AD_Org_ID eq 0 or AD_Org_ID eq ${orgId})`)}&$select=Name,DateFrom,DateTo&$top=500`, 'GET', token, null ) for (const r of nwRes?.records || []) { const start = String(r.DateFrom || '').slice(0, 10) const end = String(r.DateTo || r.DateFrom || '').slice(0, 10) if (!/^\d{4}-\d{2}-\d{2}$/.test(start)) continue let cur = { y: Number(start.slice(0, 4)), m: Number(start.slice(5, 7)), d: Number(start.slice(8, 10)) } for (let i = 0; i < 60; i++) { const k = dateKey(cur) nonWorking.add(k) if (k >= end) break cur = addDays(cur, 1) } } } catch (err) { console.error('shipping-performance: non-working days lookup failed', err) } const isWorkingDay = (p: { y: number; m: number; d: number }) => { const wd = weekdayOf(p) return wd !== 0 && wd !== 6 && !nonWorking.has(dateKey(p)) } /** Count working days strictly after `a` up to and including `b` (both YYYY-MM-DD keys). */ const workingDaysBetween = (aKey: string, bKey: string) => { if (bKey <= aKey) return 0 let cur = { y: Number(aKey.slice(0, 4)), m: Number(aKey.slice(5, 7)), d: Number(aKey.slice(8, 10)) } let n = 0 for (let i = 0; i < 400; i++) { cur = addDays(cur, 1) if (isWorkingDay(cur)) n++ if (dateKey(cur) >= bKey) break } return n } // ---- orders --------------------------------------------------------------------------------- const orderFilter = `AD_Org_ID eq ${orgId} and IsSOTrx eq true and IsFulfillmentOrder eq true and Created ge '${from} 00:00:00' and Created le '${to} 23:59:59'` const orderSelect = 'DocumentNo,Created,DateOrdered,DocStatus,ExternalOrderId,C_OrderSource_ID,C_BPartner_ID,ProcessedOn,GrandTotal' const ordersRaw = await pageAll( event, token, `models/c_order?$filter=${string.urlEncode(orderFilter)}&$select=${string.urlEncode(orderSelect)}&$orderby=${string.urlEncode('Created asc')}`, MAX_ORDERS ) const orders = ordersRaw.filter(o => !EXCLUDED_DOCSTATUS.has(o.DocStatus?.id || o.DocStatus)) // ---- shipments (customer shipments created from the window start; bounded 120 d past `to`) ---- const shipToBound = isoDate(new Date(new Date(to + 'T12:00:00Z').getTime() + 120 * 86400000)) const shipFilter = `AD_Org_ID eq ${orgId} and IsSOTrx eq true and MovementType eq 'C-' and Created ge '${from} 00:00:00' and Created le '${shipToBound} 23:59:59'` const shipSelect = 'DocumentNo,C_Order_ID,Created,ProcessedOn,shipping_date,IsCommissioned,DocStatus,MovementDate,M_Shipper_ID,TrackingNo' const shipmentsRaw = await pageAll( event, token, `models/m_inout?$filter=${string.urlEncode(shipFilter)}&$select=${string.urlEncode(shipSelect)}&$orderby=${string.urlEncode('Created asc')}`, MAX_SHIPMENTS ) type ShipInfo = { inOutId: number; documentNo: string; shippedAt: number | null; source: string; shipper: string; trackingNo: string } const shipmentsByOrder: Record = {} for (const s of shipmentsRaw) { if (EXCLUDED_DOCSTATUS.has(s.DocStatus?.id || s.DocStatus)) continue const orderId = idOf(s.C_Order_ID) if (!orderId) continue let shippedAt: number | null = null let source = '' if (s.shipping_date) { shippedAt = utcIsoToInstantMs(s.shipping_date) source = 'shipping_date' } else if ((s.IsCommissioned === true || s.IsCommissioned === 'Y') && s.ProcessedOn) { shippedAt = Number(s.ProcessedOn) || null source = 'processed' } ;(shipmentsByOrder[orderId] ||= []).push({ inOutId: s.id, documentNo: s.DocumentNo || '', shippedAt, source, shipper: s.M_Shipper_ID?.identifier || '', trackingNo: s.TrackingNo || '' }) } // ---- pass 1: deadline + first shipment + preliminary status --------------------------------- const nowMs = Date.now() const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] type Prelim = { o: any createdMs: number created: ReturnType createdMinutes: number deadlineKey: string deadlineMs: number first: ShipInfo | null anyShipment: ShipInfo | null ships: ShipInfo[] status: Status shippedKey: string daysLate: number leadHours: number | null } const prelim: Prelim[] = orders.map((o: any) => { const createdMs = idempiereLocalToInstantMs(o.Created) ?? 0 const created = berlinParts(createdMs) const createdMinutes = created.h * 60 + created.mi // Deadline: same day if before cut-off on a working day, else next working day's cut-off let dl: { y: number; m: number; d: number } = { y: created.y, m: created.m, d: created.d } if (!(isWorkingDay(dl) && createdMinutes < cutoffMinutes)) { do { dl = addDays(dl, 1) } while (!isWorkingDay(dl)) } const deadlineKey = dateKey(dl) const deadlineMs = berlinLocalToInstantMs(dl.y, dl.m, dl.d, cutoff.h, cutoff.mi, 0) // Shipping any time on the deadline day counts as on time → overdue only after that day ends const deadlineEndMs = berlinLocalToInstantMs(dl.y, dl.m, dl.d, 23, 59, 59) const ships = (shipmentsByOrder[o.id] || []) const shippedOnes = ships.filter(s => s.shippedAt != null).sort((a, b) => (a.shippedAt! - b.shippedAt!)) const first = shippedOnes[0] || null const anyShipment = ships[0] || null let status: Status let shippedKey = '' let daysLate = 0 let leadHours: number | null = null if (first) { const sp = berlinParts(first.shippedAt!) shippedKey = dateKey(sp) daysLate = workingDaysBetween(deadlineKey, shippedKey) status = shippedKey <= deadlineKey ? 'ontime' : 'late' leadHours = Math.max(0, Math.round(((first.shippedAt! - createdMs) / 3600000) * 10) / 10) } else { status = nowMs > deadlineEndMs ? 'overdue' : 'pending' } return { o, createdMs, created, createdMinutes, deadlineKey, deadlineMs, first, anyShipment, ships, status, shippedKey, daysLate, leadHours } }) // ---- stock check (only orders that are not on time can be stock-blocked) -------------------- const candidates = prelim.filter(p => p.status !== 'ontime').map(p => ({ orderId: Number(p.o.id), deadlineDate: p.deadlineKey })) const stock = await findStockBlockedOrders(candidates) if (stock.available) { console.info(`shipping-performance: stock check ${stock.checked} orders → ${stock.blocked.size} blocked in ${stock.ms} ms (org ${orgId})`) } else if (stock.error !== 'PG not configured') { console.warn(`shipping-performance: stock check unavailable – ${stock.error}`) } // ---- pass 2: final status + aggregates ---------------------------------------------------- const months: Record = {} const days: Record = {} const hours: Record = {} const weekdays: Record = {} const sources: Record = {} for (let h = 0; h < 24; h++) hours[pad2(h)] = newBucket(pad2(h), `${pad2(h)}:00`) for (const i of [1, 2, 3, 4, 5, 6, 0]) weekdays[String(i)] = newBucket(String(i), WD[i]) const lateness: Record = { '0': 0, '1': 0, '2': 0, '3': 0, '4+': 0 } const leadHoursAll: number[] = [] let shipped = 0, onTime = 0, late = 0, overdue = 0, pending = 0, noStock = 0, generatedNotShipped = 0 let lateDaysSum = 0 let beforeCutoffMeasured = 0 const rows = prelim.map((p) => { const { o, createdMs, created, createdMinutes, deadlineKey, deadlineMs, first, anyShipment, ships, shippedKey, daysLate, leadHours } = p const shortfall = stock.blocked.get(Number(o.id)) const stockBlocked = !!shortfall const status: Status = stockBlocked && noStockMode === 'exclude' ? 'nostock' : p.status const beforeCutoff = createdMinutes < cutoffMinutes && isWorkingDay({ y: created.y, m: created.m, d: created.d }) // Aggregates const mk = monthKey(created) const dk = dateKey(created) const hk = pad2(created.h) const wk = String(created.weekday) const srcName = o.C_OrderSource_ID?.identifier || '—' const buckets = [ months[mk] ||= newBucket(mk), days[dk] ||= newBucket(dk), hours[hk], weekdays[wk], sources[srcName] ||= newBucket(srcName) ] for (const b of buckets) { b.orders++ if (status === 'ontime') { b.shipped++; b.onTime++ } else if (status === 'late') { b.shipped++; b.late++ } else if (status === 'overdue') b.overdue++ else if (status === 'nostock') b.noStock++ else b.pending++ if (status !== 'nostock' && leadHours != null) { b.leadSum += leadHours; b.leadCount++ } } if (status === 'ontime') { shipped++; onTime++; lateness['0']++ } else if (status === 'late') { shipped++; late++; lateDaysSum += daysLate lateness[daysLate >= 4 ? '4+' : String(Math.max(1, daysLate))]++ } else if (status === 'overdue') overdue++ else if (status === 'nostock') noStock++ else { pending++ } if (status !== 'nostock') { if (!first && anyShipment) generatedNotShipped++ if (leadHours != null) leadHoursAll.push(leadHours) if (beforeCutoff) beforeCutoffMeasured++ } return { orderId: o.id, documentNo: o.DocumentNo || '', externalOrderId: o.ExternalOrderId || '', orderSource: srcName, bpartnerName: o.C_BPartner_ID?.identifier || '', docStatus: o.DocStatus?.id || o.DocStatus || '', createdAt: createdMs, createdLocal: `${dk} ${pad2(created.h)}:${pad2(created.mi)}`, createdWeekday: WD[created.weekday], beforeCutoff, deadlineDate: deadlineKey, deadlineAt: deadlineMs, shippedAt: first?.shippedAt ?? null, shippedDate: shippedKey, shippedSource: first?.source || '', inOutId: first?.inOutId ?? anyShipment?.inOutId ?? null, shipmentDocumentNo: first?.documentNo || anyShipment?.documentNo || '', shipper: first?.shipper || anyShipment?.shipper || '', trackingNo: first?.trackingNo || anyShipment?.trackingNo || '', shipmentCount: ships.length, status, slaStatus: p.status, // status without the stock rule (what 'include' mode shows) stockBlocked, stockNote: formatShortfall(shortfall), daysLate, leadHours } }) const sorted = [...leadHoursAll].sort((a, b) => a - b) const pct = (p: number) => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(p * (sorted.length - 1)))] : null const judged = onTime + late + overdue const measured = rows.length - noStock const round1 = (v: number) => Math.round(v * 10) / 10 return { orgId, orgName, from, to, cutoff: cutoffLabel, cutoffSource, partnerCutoff, timezone: 'Europe/Berlin', nonWorkingDays: Array.from(nonWorking).filter(k => k >= from).sort(), truncated: ordersRaw.length >= MAX_ORDERS, noStockMode, stockCheck: stock.available ? 'ok' : 'unavailable', stockCheckMs: stock.ms, stockChecked: stock.checked, summary: { orders: rows.length, measured, // orders counted in the rates shipped, onTime, late, overdue, pending, noStock, stockBlocked: rows.filter(r => r.stockBlocked).length, // also in 'include' mode generatedNotShipped, onTimeRate: shipped > 0 ? round1((onTime / shipped) * 100) : null, // of shipped orders slaRate: judged > 0 ? round1((onTime / judged) * 100) : null, // incl. overdue unshipped avgLeadHours: sorted.length ? round1(sorted.reduce((a, b) => a + b, 0) / sorted.length) : null, medianLeadHours: pct(0.5), p90LeadHours: pct(0.9), avgDaysLate: late > 0 ? round1(lateDaysSum / late) : null, beforeCutoffShare: measured ? round1((beforeCutoffMeasured / measured) * 100) : null }, months: Object.values(months).sort((a, b) => a.key.localeCompare(b.key)).map(finishBucket), days: Object.values(days).sort((a, b) => a.key.localeCompare(b.key)).map(finishBucket), hours: Object.values(hours).map(finishBucket), weekdays: [1, 2, 3, 4, 5, 6, 0].map(i => finishBucket(weekdays[String(i)])), sources: Object.values(sources).sort((a, b) => b.orders - a.orders).map(finishBucket), lateness: [ { key: '0', label: 'On time', count: lateness['0'] }, { key: '1', label: '1 day late', count: lateness['1'] }, { key: '2', label: '2 days late', count: lateness['2'] }, { key: '3', label: '3 days late', count: lateness['3'] }, { key: '4+', label: '4+ days late', count: lateness['4+'] } ], rows } } export default defineEventHandler(async (event) => { let data: any = {} try { data = await handleFunc(event) } catch (err: any) { try { const authToken = await refreshTokenHelper(event) data = await handleFunc(event, authToken) } catch (error: any) { data = errorHandlingHelper(err?.data ?? err, error?.data ?? error) forceLogoutHelper(event, data) } } return data })