mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
feat(warehouse): P1 dashboard analytics — dwell/aging, cycle-time + on-time, live deltas
Adds the operational-performance layer to the warehouse cockpit:
- Dwell time & aging: dwellStats() (avg + 0-3/4-7/8-14/15+ buckets over
in-warehouse items) → DwellAgingCard histogram.
- Cycle time & on-time: cycleStats() (arrived→ready→loaded→dispatched stage
averages + dock-to-dispatch) and onTimeDispatchStats() (share of items that
left before their storage free-days expired, resolved via the fee engine's
own rule matching) → CycleTimeCard.
- Live tiles: opsStats() now returns receivedYesterday; KpiStrip renders an
optional ▲/▼ delta, and the ops strip shows received-today vs yesterday.
New endpoints: GET /warehouse-inventory/{dwell-stats,cycle-stats} and
/warehouse-fees/on-time-dispatch (all guarded). New "Performance" section on
WarehouseDashboardPage. On-time reads N/A when there is no sample / no active
storage rule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -330,6 +330,80 @@ export class WarehouseFeeService {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* On-time dispatch rate: the share of items dispatched in the last N days that
|
||||
* LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day)
|
||||
* <= freeDays, with freeDays resolved by the same rule matching the fee engine
|
||||
* uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there
|
||||
* is nothing to measure (e.g. no dispatched items / no storage rules).
|
||||
*/
|
||||
async onTimeDispatchStats(
|
||||
windowDays = 90,
|
||||
): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> {
|
||||
const storageRules = (
|
||||
await this.feeRuleRepository.findAll({ where: { isActive: true } })
|
||||
).filter((r) => r.ruleType === 'STORAGE_FEE');
|
||||
|
||||
// Batched attribute pull mirroring loadItem's scope joins (multi-row) — only
|
||||
// the fields bestRule/matchScore reads, plus the two clock timestamps.
|
||||
const rows: Array<
|
||||
ItemAttributes & { arrivedAt: string; dispatchedAt: string }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
inv.dispatched_at AS "dispatchedAt",
|
||||
inv.warehouse_id AS "warehouseId",
|
||||
inv.yard_id AS "yardId",
|
||||
inv.zone_id AS "zoneId",
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
NULL AS "vehicleType"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
|
||||
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT bc.container_type_id
|
||||
FROM freight.booking_container bc
|
||||
WHERE bc.booking_id = inv.booking_id
|
||||
AND bc.deleted_at IS NULL
|
||||
AND bc.container_type_id IS NOT NULL
|
||||
ORDER BY bc.created_at ASC
|
||||
LIMIT 1
|
||||
) booking_container_type ON true
|
||||
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.arrived_at IS NOT NULL
|
||||
AND inv.dispatched_at IS NOT NULL
|
||||
AND inv.dispatched_at > now() - ($1 || ' days')::interval`,
|
||||
[windowDays],
|
||||
);
|
||||
|
||||
let onTimeCount = 0;
|
||||
for (const row of rows) {
|
||||
const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0;
|
||||
const elapsed = Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY,
|
||||
),
|
||||
);
|
||||
if (elapsed <= freeDays) onTimeCount += 1;
|
||||
}
|
||||
const sampleSize = rows.length;
|
||||
return {
|
||||
sampleSize,
|
||||
onTimeCount,
|
||||
onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user