Merge pull request #591 from Tria-plc/Truckdetantion

Truckdetantion

Receive to warehouse form cleanup and trimming
Wiring GRN with bookings
Fixed Slow GRN
plus fixed Freight order genration and rendering
This commit is contained in:
Hagernesh Tadesse
2026-07-10 10:35:56 +03:00
committed by GitHub
5 changed files with 121 additions and 106 deletions

View File

@@ -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),

View File

@@ -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]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.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]) =>
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
)
.join('');
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
})
const truckRows = truckList
.map(
(t, i) => `<tr>
<td class="num">${i + 1}</td>
<td>${esc(t.plateNumber)}</td>
<td>${esc(t.driverName)}</td>
<td>${esc(t.truckType)}</td>
<td>${esc(t.containers)}</td>
<td>${t.arrivedAt ? esc(new Date(t.arrivedAt).toLocaleString('en-GB')) : 'Awaiting arrival'}</td>
</tr>`,
)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div class="watermark">${esc(watermark)}</div>
<div class="top">
<div>
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Freight Order</h1>
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
<div class="subtitle">Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</div>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="meta">
Booking
<strong>${esc(booking.reference)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<div class="summary">
<div class="tile"><span>Client</span><strong>${esc(booking.company?.name)}</strong></div>
<div class="tile"><span>Client ID</span><strong>${esc(booking.companyId)}</strong></div>
<div class="tile"><span>Trade direction</span><strong>${esc(booking.tradeDirection)}</strong></div>
<div class="tile"><span>Freight type</span><strong>${esc(booking.freightType)}</strong></div>
<div class="tile"><span>Assigned at</span><strong>${esc(assignedAt)}</strong></div>
<div class="tile"><span>Booking status</span><strong>${esc(booking.status)}</strong></div>
</div>
<table>
<thead>
<tr>
<th class="num">#</th>
<th>Truck plate</th>
<th>Driver</th>
<th>Truck type</th>
<th>Containers loaded</th>
<th>Arrival</th>
</tr>
</thead>
<tbody>${truckRows}</tbody>
</table>
<div class="notice">
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.
</div>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
<div class="line">Customer / Carrier signature — date</div>
<div class="line">Port operations verification — date</div>
<div class="line">Gate security verification — date</div>
</div>
</section>`;
@@ -342,21 +356,30 @@ export class BookingsService {
<html>
<head>
<meta charset="utf-8" />
<title>Freight Order</title>
<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; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.truck { page-break-inside: avoid; }
.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; }
@page { size: A4 portrait; margin: 10mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.copy { position: relative; padding: 24px 28px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 800; color: rgba(15, 23, 42, 0.07); transform: rotate(-18deg); pointer-events: none; }
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
.subtitle { margin-top: 4px; color: #64748b; font-size: 12px; }
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin: 4px 0; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 14px 0; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 48px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 6px 7px; font-size: 10.5px; vertical-align: top; }
.num { text-align: right; width: 26px; }
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 30px; position: relative; z-index: 1; }
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 30px; }
</style>
</head>
<body>

View File

@@ -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) => {

View File

@@ -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({
)}
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow>
<TextInput
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<TextInput
label="Incoterms"
value={value.incoterms}
onChange={(e) => onChange({ ...value, incoterms: e.currentTarget.value })}
/>
</Group>
<TextInput
label="HS codes"
value={value.hsCodes}
onChange={(e) => onChange({ ...value, hsCodes: e.currentTarget.value })}
label="Declaration / Bill of Entry number"
value={value.declarationNumber}
onChange={(e) => onChange({ ...value, declarationNumber: e.currentTarget.value })}
/>
<Text size="sm" fw={600} mt="xs">Physical cargo specifications</Text>
<Group grow>
<TextInput
label="Item code"
value={value.itemCode}
onChange={(e) => onChange({ ...value, itemCode: e.currentTarget.value })}
/>
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
</Group>
<TextInput
label="Item description"
value={value.itemDescription}
readOnly={lockedFields?.itemDescription}
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
/>
<Group grow>
<Select
label="Packaging type"
@@ -626,11 +602,6 @@ function TruckEntranceFields({
onChange={(v) => onChange({ ...value, damagedRejectedQuantity: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput
label="Warehouse code and location"
value={value.warehouseCodeLocation}
onChange={(e) => onChange({ ...value, warehouseCodeLocation: e.currentTarget.value })}
/>
<Group grow>
<TextInput
label="Driver signatory"

View File

@@ -388,6 +388,8 @@ export interface EligibleBooking {
customerTin: string | null;
customerPhone: string | null;
containerNumber: string | null;
/** Distinct seal numbers from the booking's container units, comma-joined. */
sealNumbers: string | null;
containerQuantity: number | null;
containerPackagingType: string | null;
cargoDescription: string | null;