mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into origin/freight_feature/transit
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user