Warehouse zone occupancy heatmap

Visualize live per-zone occupancy on the warehouse detail page.
This commit is contained in:
Hagernesh
2026-07-13 11:38:23 +00:00
parent 1034756bab
commit 4e8d35a7fb
18 changed files with 550 additions and 1 deletions

View File

@@ -52,6 +52,12 @@ export class WarehouseInventoryController {
return this.inventoryService.arrivalQueue();
}
@Get('zone-occupancy')
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
zoneOccupancy(@Query('yardId') yardId?: string) {
return this.inventoryService.zoneOccupancy(yardId);
}
@Post('auto-unload-arrived')
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
autoUnloadArrived() {

View File

@@ -397,6 +397,87 @@ export class WarehouseInventoryService {
* but has no customer truck assigned yet, nudge the customer to assign one — with
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
*/
/**
* Live occupancy per zone: rated capacity vs the weight/items currently held
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
* occupancy heatmap. Optionally scoped to one yard.
*/
async zoneOccupancy(yardId?: string): Promise<
Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: number | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
occupancyPct: number | null;
}>
> {
const rows: Array<{
id: string;
name: string;
code: string;
type: string;
yardId: string;
capacityWeight: string | null;
capacityContainers: number | null;
usedWeight: number;
usedItems: number;
}> = await this.dataSource.query(
`SELECT z.id,
z.name,
z.code,
z.type,
z.yard_id AS "yardId",
z.capacity_weight AS "capacityWeight",
z.capacity_containers AS "capacityContainers",
COALESCE(SUM(inv.weight) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::float8 AS "usedWeight",
COALESCE(COUNT(inv.id) FILTER (
WHERE inv.deleted_at IS NULL
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
), 0)::int AS "usedItems"
FROM freight.warehouse_zones z
LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
WHERE z.is_active = true
AND z.deleted_at IS NULL
AND ($1::uuid IS NULL OR z.yard_id = $1)
GROUP BY z.id
ORDER BY z.name`,
[yardId ?? null],
);
return rows.map((r) => {
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
const byWeight =
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
const byItems =
r.capacityContainers && r.capacityContainers > 0
? (r.usedItems / r.capacityContainers) * 100
: null;
// Prefer container-count occupancy (unit-consistent). Weight capacity is
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
const pct = byItems ?? byWeight;
return {
id: r.id,
name: r.name,
code: r.code,
type: r.type,
yardId: r.yardId,
capacityWeight: capWeight,
capacityContainers: r.capacityContainers,
usedWeight: r.usedWeight,
usedItems: r.usedItems,
occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
};
});
}
/**
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
* collection truck while their goods are still in the warehouse