mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
merge conflict resolved
This commit is contained in:
@@ -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, ''');
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user