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:
Hagernesh
2026-07-15 13:51:23 +00:00
parent 6391ba837a
commit fc4aadbc57
15 changed files with 510 additions and 10 deletions

View File

@@ -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((dispatchedarrived)/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';
}

View File

@@ -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' })

View File

@@ -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 (03 / 47 / 814 / 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: '03 days', count: row?.b0 ?? 0 },
{ key: '4-7', label: '47 days', count: row?.b1 ?? 0 },
{ key: '8-14', label: '814 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

View File

@@ -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' })