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,