diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 4b3e3bcca..96ed3b701 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -11,6 +11,7 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; import { BillingModule } from '../billing/billing.module'; +import { DocumentsModule } from '../billing/documents/documents.module'; import { FirstMileModule } from '../first-mile/first-mile.module'; import { LastMileModule } from '../last-mile/last-mile.module'; import { BookingContractService } from './booking-contract.service'; @@ -68,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; CustomerTruckContainer, ]), BillingModule, + DocumentsModule, NotificationsModule, NotificationInboxModule, forwardRef(() => FirstMileModule), diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index dac739fbf..9f72c7409 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -50,7 +50,8 @@ 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'; +import { PdfRenderService } from '../billing/documents/pdf-render.service'; +import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -99,7 +100,7 @@ export class BookingsService { private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, private readonly vehiclesService: VehiclesService, - private readonly contractPdfService: ContractPdfService, + private readonly pdfRender: PdfRenderService, private readonly events: EventEmitter2, ) {} @@ -170,7 +171,12 @@ export class BookingsService { ); const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); - const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + // Chromium when available; otherwise the styled tabular fallback (never the + // generic text dump — the freight order is an outward-facing gate document). + const buffer = await this.pdfRender.htmlToPdfBuffer(html, { + label: 'freight order', + fallback: (prepared) => buildTabularFallbackPdf(prepared), + }); return { filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, buffer, @@ -262,21 +268,10 @@ export class BookingsService { containers: string | null; }>, ): string { + const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') : '-'; - const bookingRows: 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], - ['Assigned At', assignedAt], - ['Booking Status', booking.status], - ]; - const bookingRowHtml = bookingRows - .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) - .join(''); // Fall back to the legacy single-truck booking columns when there are no // multi-truck rows (bookings assigned before the multi-truck feature). @@ -297,44 +292,63 @@ export class BookingsService { ] : []; - const truckBlocks = truckList - .map((t, i) => { - const rows: Array<[string, string | null | undefined]> = [ - ['Truck Plate Number', t.plateNumber], - ['Driver Name', t.driverName], - ['Truck Type', t.truckType], - ['Containers Loaded', t.containers], - [ - 'Arrival', - t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival', - ], - ]; - const html = rows - .map( - ([label, value]) => - `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`, - ) - .join(''); - return `

Truck ${i + 1}

${html}
`; - }) + const truckRows = truckList + .map( + (t, i) => ` + ${i + 1} + ${esc(t.plateNumber)} + ${esc(t.driverName)} + ${esc(t.truckType)} + ${esc(t.containers)} + ${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'} + `, + ) .join(''); const copy = (watermark: string) => `
-
${this.escapeHtml(watermark)}
-
+
${esc(watermark)}
+
+
Ethio-Djibouti Railway S.C.

Freight Order

-

Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}

+
Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}
- ${this.escapeHtml(booking.reference)} -
- ${bookingRowHtml}
- ${truckBlocks} +
+ Booking + ${esc(booking.reference)} + Generated: ${esc(new Date().toLocaleString('en-GB'))} +
+ +
+
Client${esc(booking.company?.name)}
+
Client ID${esc(booking.companyId)}
+
Trade direction${esc(booking.tradeDirection)}
+
Freight type${esc(booking.freightType)}
+
Assigned at${esc(assignedAt)}
+
Booking status${esc(booking.status)}
+
+ + + + + + + + + + + + ${truckRows} +
#Truck plateDriverTruck typeContainers loadedArrival
+
+ Present this freight order at the warehouse gate. Each truck may only collect the + containers listed against it; the handover must be signed before any truck leaves. +
-
Customer / Carrier Signature
-
Port Operations Verification
-
Gate Security Verification
+
Customer / Carrier signature — date
+
Port operations verification — date
+
Gate security verification — date
`; @@ -342,21 +356,30 @@ export class BookingsService { + Freight Order diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 2b98856d0..92617db6d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -207,6 +207,7 @@ export interface EligibleBookingRow { customerTin: string | null; customerPhone: string | null; containerNumber: string | null; + sealNumbers: string | null; containerQuantity: number | null; containerPackagingType: string | null; cargoDescription: string | null; @@ -774,7 +775,8 @@ export class WarehouseInventoryService { company.name AS "customer", company.tin AS "customerTin", COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone", - bc.container_numbers AS "containerNumber", + COALESCE(bcu.unit_numbers, bc.container_numbers) AS "containerNumber", + bcu.seal_numbers AS "sealNumbers", bc.container_quantity AS "containerQuantity", bc.container_packaging_type AS "containerPackagingType", (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL @@ -831,6 +833,14 @@ export class WarehouseInventoryService { LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL ) bc ON true + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(unit.container_number, ''), ', ' ORDER BY unit.container_number) AS unit_numbers, + string_agg(DISTINCT NULLIF(unit.seal_number, ''), ', ') AS seal_numbers + FROM freight.booking_container_units unit + JOIN freight.booking_container line + ON line.id = unit.booking_container_id AND line.deleted_at IS NULL + WHERE line.booking_id = b.id AND unit.deleted_at IS NULL + ) bcu ON true LEFT JOIN LATERAL ( SELECT first_mile.id, first_mile.status, first_mile.vehicle_id FROM freight.first_mile first_mile @@ -881,11 +891,18 @@ export class WarehouseInventoryService { }> = []; await this.dataSource.transaction(async (manager) => { - await this.validateLocation(manager, { + const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { + dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); + } for (const bookingId of dto.bookingIds) { const skip = (reason: string) => { diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index a1b91decb..de41c1a82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -150,9 +150,6 @@ interface TruckEntranceFormState { assignedEquipmentNumber: string; customsSealNumber: string; declarationNumber: string; - incoterms: string; - hsCodes: string; - itemCode: string; itemDescription: string; packagingType: string; unitCount: number | ''; @@ -162,7 +159,6 @@ interface TruckEntranceFormState { volumeDimensions: string; conditionAtReceipt: string; damagedRejectedQuantity: number | ''; - warehouseCodeLocation: string; driverName: string; driverPhone: string; driverLicenseNumber: string; @@ -205,9 +201,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ assignedEquipmentNumber: '', customsSealNumber: '', declarationNumber: '', - incoterms: '', - hsCodes: '', - itemCode: '', itemDescription: '', packagingType: '', unitCount: '', @@ -217,7 +210,6 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({ volumeDimensions: '', conditionAtReceipt: '', damagedRejectedQuantity: '', - warehouseCodeLocation: '', driverName: '', driverPhone: '', driverLicenseNumber: '', @@ -239,9 +231,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined, - incoterms: form.incoterms.trim() || undefined, - hsCodes: form.hsCodes.trim() || undefined, - itemCode: form.itemCode.trim() || undefined, itemDescription: form.itemDescription.trim() || undefined, packagingType: form.packagingType.trim() || undefined, unitCount: form.unitCount === '' ? undefined : Number(form.unitCount), @@ -251,7 +240,6 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl volumeDimensions: form.volumeDimensions.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, damagedRejectedQuantity: form.damagedRejectedQuantity === '' ? undefined : Number(form.damagedRejectedQuantity), - warehouseCodeLocation: form.warehouseCodeLocation.trim() || undefined, driverName: form.driverName.trim(), driverPhone: form.driverPhone.trim(), driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, @@ -296,6 +284,10 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { const truckType = commonNonEmptyValue( bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType), ); + const customsSealNumber = commonNonEmptyValue(bookings.map((booking) => booking.sealNumbers)); + // Booking's declared cargo weight (tonnes) — the receive-time net until re-weighed. + const bookingWeight = bookings.length === 1 ? Number(bookings[0]?.weight ?? '') : NaN; + const netWeightKg: number | '' = Number.isFinite(bookingWeight) && bookingWeight > 0 ? bookingWeight : ''; const edrDigitalBookingId = bookings.length === 1 ? bookings[0]?.reference ?? bookings[0]?.id ?? '' @@ -321,9 +313,11 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { customerPhone, edrDigitalBookingId, assignedEquipmentNumber, + customsSealNumber, itemDescription, packagingType, unitCount, + netWeightKg, grossWeightKg: '', truckPlateNumber, trailerPlateNumber, @@ -331,6 +325,7 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): { driverPhone, driverLicenseNumber, truckType, + driverSignatoryName: driverName, }, lockedFields: { ownerName: Boolean(ownerName), @@ -550,38 +545,19 @@ function TruckEntranceFields({ )} Customs and compliance - - onChange({ ...value, declarationNumber: e.currentTarget.value })} - /> - onChange({ ...value, incoterms: e.currentTarget.value })} - /> - onChange({ ...value, hsCodes: e.currentTarget.value })} + label="Declaration / Bill of Entry number" + value={value.declarationNumber} + onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })} /> Physical cargo specifications - - onChange({ ...value, itemCode: e.currentTarget.value })} - /> - onChange({ ...value, itemDescription: e.currentTarget.value })} - /> - + onChange({ ...value, itemDescription: e.currentTarget.value })} + />