Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit

This commit is contained in:
marshal
2026-09-02 22:38:15 +00:00
425 changed files with 30919 additions and 3238 deletions

View File

@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import {
EDR_HAULAGE_CONFLICT_MESSAGE,
LAST_MILE_COMMITTED_SQL,
edrHaulsThisBooking,
} from '../../common/mile-haulage.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -112,8 +117,13 @@ interface CarriageAcceptanceWagonRow {
departureAt: Date | null;
marshalledAt: string | null;
arrivalAt: string | null;
/** Per-row stations: the slot's own board/alight yard, else the schedule's endpoints. */
departureStation: string | null;
arrivalStation: string | null;
containerNumbers: string | null;
sealNumbers: string | null;
/** Allocation status — LOADED/DEPARTED means EDR has the cargo. */
status: string | null;
}
/** A received-but-not-yet-marshalled export line, standing in for a wagon row. */
@@ -169,18 +179,23 @@ export class BookingsService {
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',
);
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
// closes self-haul only once it has been approved.
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
FROM freight.bookings b
WHERE b.id = $1`,
[bookingId],
);
if (
edrHaulsThisBooking({
tradeDirection: booking.tradeDirection ?? null,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
})
) {
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
@@ -263,9 +278,13 @@ export class BookingsService {
/**
* Carriage acceptance sheet — one per booking, listing every wagon the booking
* occupies. Handed to the customer when EDR accepts the cargo (export) and when
* the wagons are allocated before marshalling (import), so it is only available
* once the booking has wagon allocations.
* occupies. A booking is routinely loaded in parts (some containers go, the
* rest wait for the next train), so each row carries a Status of Loaded or
* Not loaded and the totals count only the loaded ones: the customer sees the
* whole plan on one page without the sheet overstating what EDR has taken.
*
* Handed to the customer when EDR accepts the cargo (export) and when the
* wagons are allocated before marshalling (import).
*/
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
@@ -285,6 +304,9 @@ export class BookingsService {
s.scheduled_departure_date AS "departureAt",
so.label AS "marshalledAt",
sd.label AS "arrivalAt",
COALESCE(by_.label, so.label) AS "departureStation",
COALESCE(ay.label, sd.label) AS "arrivalStation",
a.status AS "status",
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
FROM freight.wagon_booking_allocations a
@@ -296,13 +318,31 @@ export class BookingsService {
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
LEFT JOIN freight.yards by_ ON by_.id = tsw.board_yard_id
LEFT JOIN freight.yards ay ON ay.id = tsw.alight_yard_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
AND (
$2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR EXISTS (
SELECT 1
FROM freight.booking_container_units received_unit
JOIN freight.booking_container received_line
ON received_line.id = received_unit.booking_container_id
AND received_line.deleted_at IS NULL
WHERE received_line.booking_id = a.booking_id
AND received_unit.container_number = ci.container_number
AND received_unit.received_to_port = true
AND NULLIF(TRIM(received_unit.grn_number), '') IS NOT NULL
AND received_unit.deleted_at IS NULL
)
)
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label
GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons,
s.train_number, s.scheduled_departure_date, so.label, sd.label,
by_.label, ay.label
HAVING $2 <> 'EXPORT' OR $3 <> 'CONTAINER' OR COUNT(ci.id) > 0
ORDER BY tsw.sequence_no`,
[bookingId],
[bookingId, booking.tradeDirection, booking.freightType],
);
// Export acceptance happens at the warehouse gate, not at marshalling: EDR
// takes custody of the cargo when it receives it, and the customer is handed
@@ -337,17 +377,17 @@ export class BookingsService {
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.containers c
ON c.id = inv.container_id AND c.deleted_at IS NULL
WHERE inv.booking_id = $1 AND inv.deleted_at IS NULL
AND COALESCE(
NULLIF(TRIM(inv.grn_number), ''),
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
) IS NOT NULL
ORDER BY inv.created_at`,
`SELECT unit.vgm_tons AS "allocatedWeightTons",
unit.container_number AS "containerNumbers",
unit.seal_number AS "sealNumbers"
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 = $1
AND unit.deleted_at IS NULL
AND unit.received_to_port = true
AND NULLIF(TRIM(unit.grn_number), '') IS NOT NULL
ORDER BY unit.received_at, unit.container_number`,
[bookingId],
)
: [];
@@ -381,8 +421,12 @@ export class BookingsService {
departureAt: null,
marshalledAt: null,
arrivalAt: null,
departureStation: null,
arrivalStation: null,
containerNumbers: row.containerNumbers,
sealNumbers: row.sealNumbers ?? null,
// A received line has no allocation; it is cargo EDR already holds.
status: null,
}));
}
@@ -497,7 +541,17 @@ export class BookingsService {
const header = wagons[0];
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
const totals = wagons.reduce(
// Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the
// totals count only those: the sheet shows the whole plan, but must never
// total up cargo still sitting in the yard. A received-line sheet
// (pendingWagons) has no allocation status, and every line on it is cargo
// already accepted, so it counts in full.
const isLoaded = (w: CarriageAcceptanceWagonRow) =>
pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED';
const loadedWagons = wagons.filter(isLoaded);
const notLoadedCount = wagons.length - loadedWagons.length;
const totals = loadedWagons.reduce(
(acc, w) => ({
tare: acc.tare + (Number(w.tareWeightTons) || 0),
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
@@ -507,7 +561,7 @@ export class BookingsService {
{ tare: 0, capacity: 0, load: 0, length: 0 },
);
// A wagon carrying no weight and no container is running empty under this booking.
const fullWagons = wagons.filter(
const fullWagons = loadedWagons.filter(
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
).length;
@@ -520,11 +574,14 @@ export class BookingsService {
<td class="num">${num(w.tareWeightTons, 2)}</td>
<td class="num">${num(w.equatedLength)}</td>
<td class="num">${num(w.loadCapacityTons)}</td>
<td>${esc(arrivalStation)}</td>
<td>${esc(w.arrivalStation ?? arrivalStation)}</td>
<td>${esc(cargoName)}</td>
<td>${esc(departureStation)}</td>
<td>${esc(w.departureStation ?? departureStation)}</td>
<td>${esc(w.containerNumbers)}</td>
<td>${esc(w.sealNumbers)}</td>
<td class="${isLoaded(w) ? 'loaded' : 'pending'}">${
pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded'
}</td>
<td class="num">${money(prices[i])}</td>
</tr>`,
)
@@ -535,23 +592,37 @@ export class BookingsService {
// figure from the printed sheet.
const totalsRow = `<tr class="totals">
<td>TOT</td>
<td>${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'}</td>
<td>${
pendingWagons
? 'pending marshalling'
: `full ${fullWagons} / empty ${wagons.length - fullWagons}`
}</td>
<td>${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'}</td>
<td></td>
<td class="num">${num(totals.tare, 2)}</td>
<td class="num">${num(totals.length)}</td>
<td class="num">${num(totals.capacity)}</td>
<td></td>
<td>Gross ${num(totals.tare + totals.load)} T</td>
<td></td>
<td></td>
<td></td>
<td></td>
<td>${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''}</td>
<td class="num">${money(totalAmount)}</td>
</tr>`;
// The signed footer of the paper sheet. Rendered as .tile so the
// Chromium-less fallback (buildTabularFallbackPdf parses .tile, not
// arbitrary divs) still prints every figure.
const footer = `
<div class="summary footer-summary">
<div class="tile"><span>In Total Wagon No.</span><strong>${loadedWagons.length}</strong></div>
<div class="tile"><span>Tare Weight (T)</span><strong>${num(totals.tare, 2)}</strong></div>
<div class="tile"><span>Load Capacity (T)</span><strong>${num(totals.capacity)}</strong></div>
<div class="tile"><span>Gross Weight (T)</span><strong>${num(totals.tare + totals.load)}</strong></div>
<div class="tile"><span>Equated Length</span><strong>${num(totals.length)}</strong></div>
<div class="tile"><span>Full Wagon</span><strong>${pendingWagons ? '-' : fullWagons}</strong></div>
<div class="tile"><span>Empty Wagon</span><strong>${
pendingWagons ? '-' : loadedWagons.length - fullWagons
}</strong></div>
<div class="tile"><span>Total Amount (${esc(currency)})</span><strong>${money(totalAmount)}</strong></div>
</div>`;
return `<!doctype html>
<html>
<head>
@@ -568,6 +639,8 @@ export class BookingsService {
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
.footer-summary { grid-template-columns: repeat(8, 1fr); margin: 10px 0 0; }
.footer-summary .tile { background: #f8fafc; }
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
@@ -575,6 +648,8 @@ export class BookingsService {
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
.num { text-align: right; }
.loaded { color: #0f766e; font-weight: 700; }
.pending { color: #b45309; font-weight: 700; }
tr.totals td { background: #f8fafc; font-weight: 700; }
.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: 34px; }
@@ -618,6 +693,7 @@ export class BookingsService {
<th>Departure Station</th>
<th>Container No.</th>
<th>Seal No.</th>
<th>Status</th>
<th class="num">Price (${esc(currency)})</th>
</tr>
</thead>
@@ -626,6 +702,7 @@ export class BookingsService {
${totalsRow}
</tbody>
</table>
${footer}
<div class="notice">
${
@@ -1845,6 +1922,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
@@ -2131,6 +2214,12 @@ export class BookingsService {
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
cargoText: filter.cargoText,
containerTypeId: filter.containerTypeId,
containersMin: filter.containersMin,
containersMax: filter.containersMax,
requestedContainersMin: filter.requestedContainersMin,
requestedContainersMax: filter.requestedContainersMax,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,