diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
index 92aa36b85..3b61d7ff0 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts
@@ -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';
}
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
index e9e8d95db..f64fddb3a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts
@@ -81,6 +81,20 @@ export class WarehouseInventoryController {
return this.inventoryService.throughput(g);
}
+ @Get('dwell-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' })
+ dwellStats() {
+ return this.inventoryService.dwellStats();
+ }
+
+ @Get('cycle-stats')
+ @BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
+ @ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' })
+ cycleStats() {
+ return this.inventoryService.cycleStats();
+ }
+
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
index 793372d84..9c269270a 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts
@@ -406,12 +406,14 @@ export class WarehouseInventoryService {
*/
async opsStats(): Promise<{
receivedToday: number;
+ receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
}> {
const [row]: Array<{
receivedToday: number;
+ receivedYesterday: number;
pendingInspection: number;
trucksOnSite: number;
itemsAging: number;
@@ -419,6 +421,8 @@ export class WarehouseInventoryService {
`SELECT
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday",
+ (SELECT count(*)::int FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday",
(SELECT count(*)::int FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection",
(SELECT count(*)::int FROM freight.customer_truck_assignments
@@ -430,12 +434,109 @@ export class WarehouseInventoryService {
);
return {
receivedToday: row?.receivedToday ?? 0,
+ receivedYesterday: row?.receivedYesterday ?? 0,
pendingInspection: row?.pendingInspection ?? 0,
trucksOnSite: row?.trucksOnSite ?? 0,
itemsAging: row?.itemsAging ?? 0,
};
}
+ /** In-warehouse statuses used by the dwell / aging metrics. */
+ private readonly IN_WAREHOUSE_STATUSES = [
+ 'RECEIVED',
+ 'UNLOADED',
+ 'STORED',
+ 'RESERVED',
+ 'READY_FOR_LOADING',
+ 'READY_FOR_PICKUP',
+ ];
+
+ /**
+ * Dwell time of items still in the warehouse: average days held plus a count
+ * per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling
+ * back to created_at). Powers the dwell / aging histogram.
+ */
+ async dwellStats(): Promise<{
+ avgDwellDays: number;
+ inWarehouseCount: number;
+ buckets: Array<{ key: string; label: string; count: number }>;
+ }> {
+ const [row]: Array<{
+ avgDwellDays: number | null;
+ inWarehouseCount: number;
+ b0: number;
+ b1: number;
+ b2: number;
+ b3: number;
+ }> = await this.dataSource.query(
+ `WITH held AS (
+ SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL
+ AND status = ANY($1)
+ )
+ SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays",
+ count(*)::int AS "inWarehouseCount",
+ count(*) FILTER (WHERE age_days < 4)::int AS b0,
+ count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1,
+ count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2,
+ count(*) FILTER (WHERE age_days >= 15)::int AS b3
+ FROM held`,
+ [this.IN_WAREHOUSE_STATUSES],
+ );
+ return {
+ avgDwellDays: row?.avgDwellDays ?? 0,
+ inWarehouseCount: row?.inWarehouseCount ?? 0,
+ buckets: [
+ { key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 },
+ { key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 },
+ { key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 },
+ { key: '15+', label: '15+ days', count: row?.b3 ?? 0 },
+ ],
+ };
+ }
+
+ /**
+ * Average stage cycle times over items dispatched in the last 90 days:
+ * arrived→ready, ready→loaded, loaded→dispatched, and the total
+ * arrived→dispatched (dock-to-dispatch). Days, to one decimal.
+ */
+ async cycleStats(): Promise<{
+ sampleSize: number;
+ avgDockToDispatchDays: number;
+ stages: Array<{ key: string; label: string; avgDays: number }>;
+ }> {
+ const gapDays = (from: string, to: string) =>
+ `round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`;
+ const [row]: Array<{
+ sampleSize: number;
+ total: number | null;
+ arrivedReady: number | null;
+ readyLoaded: number | null;
+ loadedDispatched: number | null;
+ }> = await this.dataSource.query(
+ `SELECT count(*)::int AS "sampleSize",
+ ${gapDays('arrived_at', 'dispatched_at')} AS "total",
+ ${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady",
+ ${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded",
+ ${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched"
+ FROM freight.warehouse_inventory
+ WHERE deleted_at IS NULL
+ AND arrived_at IS NOT NULL
+ AND dispatched_at IS NOT NULL
+ AND dispatched_at > now() - interval '90 days'`,
+ );
+ return {
+ sampleSize: row?.sampleSize ?? 0,
+ avgDockToDispatchDays: row?.total ?? 0,
+ stages: [
+ { key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 },
+ { key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 },
+ { key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 },
+ ],
+ };
+ }
+
/**
* Received-vs-dispatched throughput as a server-side time series. Buckets by
* date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
index 7a5c53d38..3a60de4ea 100644
--- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
+++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts
@@ -96,6 +96,13 @@ export class WarehouseRulesController {
return this.feeService.accrualDashboard(billingCurrency);
}
+ @Get('warehouse-fees/on-time-dispatch')
+ @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view)
+ @ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' })
+ onTimeDispatch() {
+ return this.feeService.onTimeDispatchStats();
+ }
+
@Post('warehouse-fees/accrual/:inventoryId/acknowledge')
@BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update)
@ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' })
diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
index 6c8167a33..4dfd39b0c 100644
--- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
+++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx
@@ -17,6 +17,11 @@ export interface KpiItem {
* into semantic tints.
*/
color?: string;
+ /**
+ * Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
+ * (green up, red down, muted zero). E.g. today's count minus yesterday's.
+ */
+ delta?: number;
}
export interface KpiStripProps {
@@ -67,16 +72,30 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
{loading ? (