feat(warehouse): assemble dashboard cockpit + server-side throughput series

- WarehouseDashboardPage now composes the ops KPI strip, lifecycle cards, flow
  charts, zone-occupancy heatmap and demurrage/accrual exceptions into one
  control-tower view; drop the redundant lifecycle donut.
- New GET /warehouse-inventory/throughput (date_trunc time series) replaces the
  client-side buildTrend that downloaded the entire inventory list to bucket it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-07-15 11:07:45 +00:00
parent ea2d912773
commit 2e22b72e27
8 changed files with 142 additions and 119 deletions

View File

@@ -73,6 +73,14 @@ export class WarehouseInventoryController {
return this.inventoryService.zoneOccupancy(yardId);
}
@Get('throughput')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
@ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' })
throughput(@Query('granularity') granularity?: string) {
const g = granularity === 'week' || granularity === 'year' ? granularity : 'month';
return this.inventoryService.throughput(g);
}
@Post('auto-unload-arrived')
@BookingStaff(FREIGHT_PERMS.warehouseInventory.unload)
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })

View File

@@ -436,6 +436,53 @@ export class WarehouseInventoryService {
};
}
/**
* 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
* generate_series so empty periods still return a zero row — replaces the
* client-side approach that downloaded the whole inventory to bucket it.
*/
async throughput(
granularity: 'week' | 'month' | 'year' = 'month',
): Promise<Array<{ periodStart: string; received: number; dispatched: number }>> {
// Whitelist the unit — it is interpolated into date_trunc / interval literals.
const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity)
? granularity
: 'month';
const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4;
const rows: Array<{ periodStart: string; received: number; dispatched: number }> =
await this.dataSource.query(
`WITH periods AS (
SELECT gs AS period_start
FROM generate_series(
date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval,
date_trunc('${unit}', now()),
'1 ${unit}'::interval
) gs
)
SELECT p.period_start AS "periodStart",
COALESCE(r.cnt, 0)::int AS received,
COALESCE(d.cnt, 0)::int AS dispatched
FROM periods p
LEFT JOIN (
SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND arrived_at IS NOT NULL
GROUP BY 1
) r ON r.ps = p.period_start
LEFT JOIN (
SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt
FROM freight.warehouse_inventory
WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL
GROUP BY 1
) d ON d.ps = p.period_start
ORDER BY p.period_start`,
[back],
);
return rows;
}
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard