Merge branch 'dev' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
Marshal
2026-07-02 13:29:10 +00:00
71 changed files with 5165 additions and 672 deletions

View File

@@ -58,10 +58,11 @@ import {
RequestOperationDto,
OperationReviewDto,
StaffRejectDto,
} from "./dto/request-changes.dto";
import { ContractViewDto } from "./dto/contract-view.dto";
import { SignContractDto } from "./dto/sign-contract.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
@@ -275,7 +276,40 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Get(":id/tracking")
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CustomerTruckAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
return this.transitionService.enrichBookingResponse(assigned);
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/tracking')
@ApiOperation({
summary: "Shipment tracking timeline for a booking",
description:

View File

@@ -45,6 +45,8 @@ import {
import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
@@ -92,8 +94,62 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly contractPdfService: ContractPdfService,
) {}
async assignCustomerTruck(
bookingId: string,
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
}
await this.bookingsRepository.update(bookingId, {
status: 'TRUCK_ASSIGNED',
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
customerTruckDriverName: dto.driverName.trim(),
customerTruckType: dto.truckType.trim(),
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
customerTruckAssignedAt: new Date(),
});
return this.findById(bookingId);
}
async customerTruckFreightOrderCopies(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
@@ -131,6 +187,79 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
</div>
</section>`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
</head>
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Build evaluation input from booking freight shape. */
/**
* Whether a service type bundles customs clearance. This is the single source

View File

@@ -0,0 +1,34 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;
}

View File

@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
// Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING',
'TRUCK_ASSIGNED',
'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null;
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
customerTruckPlateNumber?: string | null;
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
customerTruckDriverName?: string | null;
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
customerTruckType?: string | null;
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
customerTruckContainerNumber?: string | null;
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
customerTruckAssignedAt?: Date | null;
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean;