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

@@ -0,0 +1,22 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
/**
* Proof of delivery captured by the EDR driver when a last-mile leg is
* completed. Sent as multipart/form-data — the recipient's signature (field
* `signature`) and proof photos (field `photos`) are uploaded alongside these
* text fields.
*/
export class RecordProofOfDeliveryDto {
@ApiProperty({ description: 'Name of the person who received the cargo.' })
@IsString()
@IsNotEmpty()
@MaxLength(160)
recipientName!: string;
@ApiPropertyOptional({ description: 'Optional delivery notes.' })
@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}

View File

@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
vehicleAssignments?: LastMileVehicleAssignment[];
// ── Proof of delivery (captured by the EDR driver on completion) ──────────
@Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true })
podRecipientName?: string | null;
/** File id of the recipient's captured signature (PNG). */
@Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true })
podSignatureFileId?: string | null;
/** File ids of the delivery proof photos. */
@Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' })
podPhotoFileIds!: string[];
@Column({ name: 'pod_notes', type: 'text', nullable: true })
podNotes?: string | null;
@Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true })
podCapturedAt?: Date | null;
}

View File

@@ -11,8 +11,11 @@ import {
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
@@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { SetVehiclesDto } from './dto/set-vehicles.dto';
import { SetDistancesDto } from './dto/set-distances.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service';
@@ -115,6 +119,19 @@ export class LastMileController {
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
}
@Post(':id/proof-of-delivery')
@BookingStaff(FREIGHT_PERMS.lastMile.update)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
async recordProofOfDelivery(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RecordProofOfDeliveryDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
}
@Post(':id/invoice')
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { FilesModule } from '../files/files.module';
import { DriversModule } from '../drivers/drivers.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VehiclesModule } from '../vehicles/vehicles.module';
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
VehiclesModule,
DriversModule,
NotificationsModule,
FilesModule,
],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],

View File

@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
import { LastMileRepository } from './last-mile.repository';
import { FilesService } from '../files/files.service';
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
@@ -47,6 +49,7 @@ export class LastMileService {
private readonly dataSource: DataSource,
private readonly history: FleetHistoryService,
private readonly billing: BillingService,
private readonly filesService: FilesService,
) {}
/** Attach real invoice info (number/status) to records so the UI can show an
@@ -210,6 +213,49 @@ export class LastMileService {
return record;
}
/**
* Record proof of delivery (recipient signature + photos + notes) and complete
* the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
* vehicle release, history).
*/
async recordProofOfDelivery(
id: string,
dto: RecordProofOfDeliveryDto,
files: Express.Multer.File[],
): Promise<LastMile> {
const existing = await this.findById(id);
const signature = files.find((f) => f.fieldname === 'signature');
const photos = files.filter((f) => f.fieldname === 'photos');
const signatureFileId = signature
? (
await this.filesService.upload({
resourceId: id,
resource: 'last-mile',
code: 'pod-signature',
file: signature,
})
).id
: null;
const photoFileIds = photos.length
? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
: [];
await this.lastMileRepository.update(id, {
podRecipientName: dto.recipientName.trim(),
podSignatureFileId: signatureFileId,
podPhotoFileIds: photoFileIds,
podNotes: dto.notes?.trim() || null,
podCapturedAt: new Date(),
} as never);
if (existing.status !== 'DELIVERED') {
return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
}
return this.findById(id);
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const record = await this.lastMileRepository.create({
bookingId: dto.bookingId,

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