merge conflict resolved

This commit is contained in:
marshal
2026-09-02 22:35:18 +00:00
376 changed files with 26459 additions and 2649 deletions

View File

@@ -29,6 +29,7 @@ import {
ILike,
In,
IsNull,
LessThanOrEqual,
Not,
QueryFailedError,
Raw,
@@ -3682,6 +3683,11 @@ export class TrainSchedulingService {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const generatedAt = operation.loadListGeneratedAt ?? new Date();
// Leg slots (boardYardId set) couple to the train mid-corridor — this
// Djibouti-side document must say where, not list their cargo as loaded here.
const slotYardLabels = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
);
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
loadListGeneratedAt: generatedAt,
@@ -3708,6 +3714,8 @@ export class TrainSchedulingService {
wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null,
tareWeightTons: wagon.wagonType?.tareWeightTons ?? null,
equatedLengthM: wagon.wagonType?.equatedLengthM ?? null,
boardYard: wagon.boardYardId ? (slotYardLabels.get(wagon.boardYardId) ?? 'en route') : null,
alightYard: wagon.alightYardId ? (slotYardLabels.get(wagon.alightYardId) ?? 'en route') : null,
allocations: (wagon.allocations ?? []).map((allocation) => ({
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
@@ -3748,7 +3756,21 @@ export class TrainSchedulingService {
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
}
// Leg slots couple mid-corridor — this origin document must say where their
// cargo boards instead of listing it as loaded here (see the import list).
// Also doubles as the per-row Departure/Arrival Station lookup below.
const yardLabelById = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
);
const pendingBoardYardLabelBySlot = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.boardYardId)
.map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']),
);
const html = this.buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot,
yardLabelById,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
@@ -3766,8 +3788,11 @@ export class TrainSchedulingService {
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
* station work. A wagon slot is on the train iff it has not DEPARTED and
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
* unloaded, PLANNED/RESERVED = not on board yet).
* cargo. Whole-route cargo counts as on board unless DEPARTED (unloaded) —
* the import flow confirms loading at schedule level and never flips the
* allocation to LOADED, so requiring LOADED here rendered every import wagon
* as EMPTY. Leg slots (boardYardId set, coupled mid-corridor) still require
* confirmed LOADED cargo before they appear.
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
* path is comparing the board yard against the latest checkpoint sequence.
*/
@@ -3778,12 +3803,21 @@ export class TrainSchedulingService {
const wagons = (schedule.trainSet?.wagons ?? [])
.filter((wagon) => {
if (wagon.status === 'DEPARTED') return false;
// No physical wagon pinned to the slot — a booking can hold an
// allocation before a real wagon backs it (e.g. a fleet shortfall
// left it unpinned). There is nothing physical here to marshal, and
// a REAL cut also lands here: it nulls physicalWagonId without ever
// touching this slot's own status, so a cut wagon would otherwise
// linger as a phantom row with its cargo still listed.
if (!wagon.physicalWagonId) return false;
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
return wagon.boardYardId == null || hasLoaded;
})
.map((wagon) => ({
...wagon,
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
allocations: (wagon.allocations ?? []).filter((a) =>
wagon.boardYardId == null ? a.status !== 'DEPARTED' : a.status === 'LOADED',
),
})) as TrainSetWagon[];
const onBoardBookingIds = new Set(
@@ -3800,7 +3834,101 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Corrects intercityOnBoardView's CURRENT-state wagon list against a
* specific stop's document. intercityOnBoardView's "boardYardId == null ||
* hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish
* "this leg slot coupled at THIS stop" from "it coupled at a LATER stop
* that has, by generation time, also already happened" (both look LOADED).
* Reprinting an earlier stop's document after a later one has run would
* otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the
* set of physical wagon numbers with a logged ADD at or before this stop
* (see marshallingDocumentAt) — the ground truth a real-time heuristic
* can't provide once multiple stops have already happened.
*/
private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set<string>): TrainSetWagon[] {
return wagons.filter(
(wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''),
);
}
/**
* Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train
* reached them. Origin is never in this list — it's always its own doc (the
* plain import/export load list), so numbering here starts at 2. A stop with
* only a routine checkpoint and no consist change never gets a row, which is
* the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count.
*/
async marshallingStops(
scheduleId: string,
): Promise<Array<{ stopIndex: number; yardId: string; yardLabel: string; firstOccurredAt: string }>> {
const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId },
order: { occurredAt: 'ASC' },
});
const firstSeenAt = new Map<string, Date>();
for (const row of rows) {
if (!row.yardId || firstSeenAt.has(row.yardId)) continue;
firstSeenAt.set(row.yardId, row.occurredAt);
}
const orderedYardIds = [...firstSeenAt.entries()]
.sort((a, b) => a[1].getTime() - b[1].getTime())
.map(([yardId]) => yardId);
const labels = await this.yardLabelsById(orderedYardIds);
return orderedYardIds.map((yardId, i) => ({
stopIndex: i + 2,
yardId,
yardLabel: labels.get(yardId) ?? yardId,
firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(),
}));
}
/**
* The coupled/uncoupled/switched rows for one stop, in the locked table
* shape (wagon, event, containers). "EMPTY WAGON" replaces the container
* list rather than a blank cell — the column always exists so a loaded and
* an empty coupling read as the same table, not two different layouts.
* Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations
* for that physical wagon: an ADD is a leg slot boarding already loaded (see
* stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a
* REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row
* carries the incoming wagon's id — the slot's cargo already rides it.
*/
private consistChangesAt(
schedule: TrainSchedule,
logRows: ScheduleWagonAdjustmentLog[],
): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> {
const slotByPhysicalWagonId = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.physicalWagonId)
.map((wagon) => [wagon.physicalWagonId as string, wagon]),
);
return logRows.map((row) => {
const slot = slotByPhysicalWagonId.get(row.wagonId);
const containerNumbers = (slot?.allocations ?? [])
.flatMap((allocation) => allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean)
.join(', ');
return {
wagonNumber: row.wagonNumber,
event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched',
containerNumbers: containerNumbers || 'EMPTY WAGON',
};
});
}
/**
* The numbered marshalling document for one corridor stop (see
* marshallingStops — stopIndex 2+, origin is its own separate doc).
* ponytail: the wagon table always shows the CURRENT on-board state, not a
* point-in-time reconstruction of what stood on the train at that past
* stop — a full historical snapshot is a much bigger feature nobody has
* asked for. What's stop-specific is the consist-changes table below it,
* which IS scoped to that stop's own logged events.
*/
async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -3810,14 +3938,96 @@ export class TrainSchedulingService {
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const stops = await this.marshallingStops(scheduleId);
const stop = stops.find((s) => s.stopIndex === stopIndex);
if (!stop) {
throw new NotFoundException(
`No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`,
);
}
const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule);
// intercityOnBoardView's "boardYardId == null || hasLoaded" test reads
// CURRENT state — it can't tell "coupled here" from "coupled at a LATER
// stop that has since also happened" (both look LOADED by generation
// time once the trip has moved past this stop). Reprinting Marshalling 2
// after Marshalling 3's stop already ran would otherwise show Marshalling
// 3's coupled wagons too. Correct it against the log: a leg-slot wagon
// belongs on THIS stop's document only if it actually has a logged ADD
// at or before THIS stop's own timestamp.
const boardedByThisStop = new Set(
(
await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: {
trainScheduleId: scheduleId,
action: 'ADD',
occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)),
},
})
).map((row) => row.wagonNumber),
);
const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop);
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' },
});
// Per-row Departure/Arrival Station: a whole-route wagon reads the
// schedule's own origin/destination, a leg-slot wagon reads where IT
// boards/alights instead.
const yardLabelById = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
);
const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`,
wagons,
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
yardLabelById,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* Back-compat alias: the single "current" intercity doc (Marshalling 2) the
* old one-document-per-schedule UI calls. Resolves to the LATEST stop with
* a logged consist change; falls back to the current-position doc with no
* changes table when nothing has coupled/uncoupled yet (e.g. right after
* dispatch, before any mid-corridor stop).
*/
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const stops = await this.marshallingStops(scheduleId);
const latest = stops[stops.length - 1];
if (latest) {
return this.marshallingDocumentAt(scheduleId, latest.stopIndex);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
throw new BadRequestException(
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const yardLabelById = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]),
);
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
@@ -3825,6 +4035,7 @@ export class TrainSchedulingService {
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
yardLabelById,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3873,6 +4084,15 @@ export class TrainSchedulingService {
.find({ where: { trainScheduleId: scheduleId } });
}
private async yardLabelsById(
ids: Array<string | null | undefined>,
): Promise<Map<string, string>> {
const unique = [...new Set(ids.filter((id): id is string => Boolean(id)))];
if (!unique.length) return new Map();
const yards = await this.dataSource.getRepository(Yard).find({ where: { id: In(unique) } });
return new Map(yards.map((yard) => [yard.id, yard.label || yard.code]));
}
private buildExportLoadListHtml(
schedule: TrainSchedule,
opts?: {
@@ -3882,6 +4102,21 @@ export class TrainSchedulingService {
unassignedBookings?: Booking[];
emptyContainers?: EmptyContainerReturn[];
logoImageUrl?: string | null;
// Slots that couple to the train downstream (slot id → board yard label).
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map<string, string>;
// yardId → label, for the per-row Departure/Arrival Station columns
// (falls back to the schedule's own origin/destination when a wagon's
// boardYardId/alightYardId is null — i.e. it rides the whole corridor).
yardLabelById?: Map<string, string>;
// Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box.
consistChangesAtStop?: Array<{
wagonNumber: string;
event: 'Coupled' | 'Uncoupled' | 'Switched';
containerNumbers: string;
}>;
},
): string {
const esc = (value: unknown) =>
@@ -3895,10 +4130,20 @@ export class TrainSchedulingService {
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
// The document is checked against the physical train, so it has to run in
// consist order — the relation comes back unordered.
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
);
// consist order — the relation comes back unordered. Slots planned to
// couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only —
// intercity calls never pass it, their wagons list is already on-board
// only) are dropped here, not just tallied around: they are not part of
// the departing consist, so they get no row and no count on this document.
// Their own coupling shows up on THAT stop's own marshalling document.
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])]
.filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id))
// No physical wagon pinned to the slot (fleet shortfall left a booking's
// allocation unpinned, or a REAL cut nulled it out): nothing physical
// to marshal, so no row. Harmless no-op for the numbered docs, whose
// wagons list already went through intercityOnBoardView's own check.
.filter((wagon) => Boolean(wagon.physicalWagonId))
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0));
// Empties sit on wagons that carry no booking allocation, keyed by the wagon
// slot recorded when they were loaded.
const emptiesByWagon = new Map<number, EmptyContainerReturn[]>();
@@ -3909,15 +4154,28 @@ export class TrainSchedulingService {
empty,
]);
}
const originLabel = schedule.originStation?.label ?? schedule.originStation?.code;
const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code;
const rows = wagons
.flatMap((wagon) => {
// Departure/Arrival Station per row: a leg-slot wagon boards/alights
// somewhere other than the schedule's own endpoints; a whole-route
// wagon just reads origin/destination.
const departureLabel = wagon.boardYardId
? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route')
: originLabel;
const arrivalLabel = wagon.alightYardId
? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route')
: destinationLabel;
// Wagon identity is the same on every row the wagon produces, loaded or not.
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
<td>${esc(departureLabel)}</td>
<td>${esc(arrivalLabel)}</td>`;
const allocations = wagon.allocations ?? [];
// An empty wagon still runs in the consist, so it still gets a line. Staff
// check this document against the physical train — a wagon with no row
@@ -3941,7 +4199,7 @@ export class TrainSchedulingService {
return [
`<tr class="empty">
${wagonCells}
<td colspan="4">EMPTY — no cargo allocated</td>
<td colspan="5">EMPTY — no cargo allocated</td>
</tr>`,
];
}
@@ -3969,7 +4227,7 @@ export class TrainSchedulingService {
// they are still physically on the train, so they get rows of their own.
const unassigned = opts?.unassignedBookings ?? [];
const unassignedRows = unassigned.length
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
? `<tr class="empty"><td colspan="13">ON BOARD — WAGON NOT RECORDED</td></tr>` +
unassigned
.map((booking) => {
const containerNumbers = (booking.bookingContainers ?? [])
@@ -3978,7 +4236,7 @@ export class TrainSchedulingService {
.join(', ');
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'}${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
return `<tr>
<td colspan="6">${esc(booking.reference)}${esc(leg)}</td>
<td colspan="8">${esc(booking.reference)}${esc(leg)}</td>
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
<td>${esc(booking.company?.name)}</td>
<td>${esc(containerNumbers)}</td>
@@ -4000,7 +4258,9 @@ export class TrainSchedulingService {
);
// Container count summary (40ft, 20ft) — empties returning to Djibouti are
// physically on the train, so they count, and are called out on their own tile.
// physically on the train, so they count, and are called out on their own
// tile. Cargo boarding downstream never enters this loop — `wagons` above
// already excludes those slots.
let count40ft = 0, count20ft = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
@@ -4036,6 +4296,7 @@ export class TrainSchedulingService {
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -4081,6 +4342,32 @@ export class TrainSchedulingService {
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
</div>
${
opts?.consistChangesAtStop?.length
? `<h2>Consist Changed At This Stop</h2>
<table>
<thead>
<tr>
<th>Wagon No</th>
<th>Event</th>
<th>Container No</th>
</tr>
</thead>
<tbody>
${opts.consistChangesAtStop
.map(
(row) => `<tr>
<td>${esc(row.wagonNumber)}</td>
<td>${esc(row.event)}</td>
<td>${esc(row.containerNumbers)}</td>
</tr>`,
)
.join('')}
</tbody>
</table>`
: ''
}
<table>
<thead>
<tr>
@@ -4090,6 +4377,8 @@ export class TrainSchedulingService {
<th class="num">Equated Length</th>
<th class="num">Tare Weight</th>
<th class="num">Load Capacity</th>
<th>Departure Station</th>
<th>Arrival Station</th>
<th>Cargo Type</th>
<th>Company</th>
<th>Container No</th>
@@ -4098,7 +4387,7 @@ export class TrainSchedulingService {
</tr>
</thead>
<tbody>
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
${rows || '<tr><td colspan="13">No wagons on this train set.</td></tr>'}
${unassignedRows}
</tbody>
</table>
@@ -4244,17 +4533,24 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = loadList.wagons.reduce(
(sum, wagon) =>
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
// A leg slot (boardYard set) couples mid-corridor — it is not part of the
// consist this Djibouti-side document is checked against yet, so it gets
// no row and no count here at all. Its own coupling shows up on THAT
// stop's own marshalling document once it actually happens. Same for a
// slot with no physical wagon pinned at all — a booking can hold an
// allocation before a real wagon backs it (fleet shortfall), or a REAL
// cut nulled it out; either way there is nothing physical to marshal.
const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard && wagon.wagonNumber != null);
const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
const totalWeight = wagons.reduce(
(sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
loadList.wagons.forEach((wagon) => {
wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
const size = this.resolveContainerItemSize(item);
@@ -4264,15 +4560,15 @@ export class TrainSchedulingService {
});
});
const allocationRows = loadList.wagons
const allocationRows = wagons
.flatMap((wagon) => {
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
<td>${esc(wagon.wagonNumber)}</td>
<td>${esc(wagon.wagonType)}</td>
<td class="num">${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))}</td>
<td class="num">${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))}</td>
<td>${esc(loadList.origin)}</td>
<td>${esc(loadList.destination)}</td>`;
<td>${esc(wagon.boardYard ?? loadList.origin)}</td>
<td>${esc(wagon.alightYard ?? loadList.destination)}</td>`;
// An empty wagon still runs in the consist, so it still gets a line — see
// buildExportLoadListHtml.
if (wagon.allocations.length === 0) {
@@ -4364,7 +4660,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
@@ -5205,6 +5501,76 @@ export class TrainSchedulingService {
stationYardId: station.yardId,
})
.getMany();
// Leg slots (booking legs boarding/alighting mid-corridor — see
// stampSlotLegs) reaching their board/alight yard here: logged same as
// planned couples/cuts above, so the marshalling document can show
// what coupled ALREADY LOADED / uncoupled WITH cargo at this stop.
// Purely observational — their physical wagon was already pinned to
// the slot at schedule-build time (assignPhysicalWagonsToSlots), so
// nothing here changes wagon state, only the log. Dedupe against
// existing rows (not a wagon-state flag, unlike the couple/cut blocks
// above) since passedYardIds re-includes earlier stops on every call.
const legSlotsHere = (schedule.trainSet?.wagons ?? []).filter(
(slot) =>
slot.physicalWagonId &&
((slot.boardYardId && passedYardIds.includes(slot.boardYardId)) ||
(slot.alightYardId && passedYardIds.includes(slot.alightYardId))),
);
if (legSlotsHere.length && builtTrainId) {
const legWagonIds = [
...new Set(legSlotsHere.map((slot) => slot.physicalWagonId!)),
];
const legWagonById = new Map(
(
await manager.getRepository(Wagon).find({ where: { id: In(legWagonIds) } })
).map((w) => [w.id, w]),
);
const alreadyLogged = new Set(
(
await manager.getRepository(ScheduleWagonAdjustmentLog).find({
where: {
trainScheduleId: scheduleId,
wagonId: In(legWagonIds),
action: In(['ADD', 'REMOVE']),
},
})
).map((row) => `${row.wagonId}:${row.action}:${row.yardId}`),
);
const legLogRows: ScheduleWagonAdjustmentLog[] = [];
for (const slot of legSlotsHere) {
const wagon = legWagonById.get(slot.physicalWagonId!);
if (!wagon) continue;
const events: Array<{ action: 'ADD' | 'REMOVE'; yardId: string }> = [];
if (slot.boardYardId && passedYardIds.includes(slot.boardYardId)) {
events.push({ action: 'ADD', yardId: slot.boardYardId });
}
if (slot.alightYardId && passedYardIds.includes(slot.alightYardId)) {
events.push({ action: 'REMOVE', yardId: slot.alightYardId });
}
for (const { action, yardId } of events) {
const key = `${wagon.id}:${action}:${yardId}`;
if (alreadyLogged.has(key)) continue;
alreadyLogged.add(key);
legLogRows.push(
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: scheduleId,
trainId: builtTrainId,
action,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
adjustedByUserId: null,
yardId,
occurredAt,
}),
);
}
}
if (legLogRows.length) {
await manager.getRepository(ScheduleWagonAdjustmentLog).save(legLogRows);
}
}
await manager
.getRepository(Wagon)
.createQueryBuilder()