list trucks + loadable containers → load)

This commit is contained in:
Hagernesh
2026-07-06 12:40:34 +00:00
parent fe9503638b
commit 551a5fdeed
8 changed files with 402 additions and 2 deletions

View File

@@ -64,6 +64,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
@@ -358,6 +359,33 @@ export class BookingsController {
return this.customerTruckService.removeTruck(id, assignmentId);
}
@Get(':id/customer-trucks/loadable-containers')
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.getLoadableContainers(id);
}
@Post(':id/customer-trucks/:assignmentId/load')
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: LoadCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can load a truck');
}
return this.customerTruckService.loadTruck(id, assignmentId, dto);
}
@Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',

View File

@@ -198,6 +198,87 @@ export class CustomerTruckService {
return this.listTrucks(bookingId);
}
/** Booking container numbers not yet loaded onto any truck. */
async getLoadableContainers(bookingId: string): Promise<string[]> {
const [all, assigned] = await Promise.all([
this.bookingContainerNumbers(bookingId),
this.assignedContainerNumbers(bookingId),
]);
const taken = new Set(assigned);
return all.filter((n) => !taken.has(n));
}
/**
* Truck_dispatch (load): assign the selected containers to a truck after it has
* arrived, and set a provisional gross weight from their VGM. The truck is
* weighed for real on departure. Locked once the truck has left.
*/
async loadTruck(
bookingId: string,
assignmentId: string,
dto: { containerNumbers: string[] },
): Promise<CustomerTruckAssignment[]> {
await this.loadBookingGuard(bookingId);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.container_number = ANY($2::varchar[])
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
}
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level

View File

@@ -0,0 +1,13 @@
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers!: string[];
}

View File

@@ -285,6 +285,19 @@ export class WarehouseInventoryController {
return res.send(buffer);
}
@Get('customer-truck-exit-paper/:assignmentId')
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {

View File

@@ -2308,6 +2308,115 @@ export class WarehouseInventoryService {
};
}
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the
* handover being signed and warehouse fees paid.
*/
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
const [truck] = await this.dataSource.query(
`SELECT a.booking_id AS "bookingId", a.plate_number AS "plateNumber",
a.driver_name AS "driverName", a.truck_type AS "truckType",
a.gross_weight_kg AS "grossWeightKg", a.departed_at AS "departedAt",
b.reference AS "bookingReference", company.name AS "customerName"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.id = $1 AND a.deleted_at IS NULL`,
[assignmentId],
);
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
if (!(await this.handover.isFullySigned(truck.bookingId))) {
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
}
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
[truck.bookingId],
);
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
const containers: Array<{ containerNumber: string; goods: string | null }> =
await this.dataSource.query(
`SELECT c.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
FROM freight.customer_truck_containers c
JOIN freight.bookings b ON b.id = c.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[assignmentId],
);
const html = this.buildTruckExitPaperHtml({
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
bookingReference: truck.bookingReference,
customerName: truck.customerName,
plateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
grossWeightKg: Number(truck.grossWeightKg ?? 0),
gateOut: truck.departedAt,
containers,
});
return {
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
};
}
private buildTruckExitPaperHtml(data: {
reference: string;
bookingReference: string;
customerName: string | null;
plateNumber: string;
driverName: string;
truckType: string;
grossWeightKg: number;
gateOut: string | Date | null;
containers: Array<{ containerNumber: string; goods: string | null }>;
}): string {
const esc = (v: unknown) =>
String(v ?? '-').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
const rows: Array<[string, string]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName ?? '-'],
['Pickup Truck Plate', data.plateNumber],
['Driver', data.driverName],
['Truck Type', data.truckType],
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
['Gate-Out Time', gateOut],
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
];
const containerRows = data.containers.length
? data.containers
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
.join('')
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
<style>
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
th { background: #f8fafc; width: 34%; font-weight: 800; }
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
.ref strong { font-size: 16px; }
</style></head>
<body>
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
<div class="section">Release Particulars</div>
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
<div class="section">Containers Leaving on This Truck</div>
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
<tbody>${containerRows}</tbody></table>
</body></html>`;
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query(