mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Merge branch 'dev' into freight/chore/payment-test
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* The draft/finalize phase is abolished: train schedules are created SCHEDULED
|
||||
* and the Finalize button is gone from the backoffice. Promote every surviving
|
||||
* DRAFT schedule so it stays reachable (dispatch requires SCHEDULED and there
|
||||
* is no manual promotion path anymore). Idempotent; one-way — the original
|
||||
* DRAFT set is not recorded, so down() cannot restore it.
|
||||
*/
|
||||
export class PromoteDraftSchedulesToScheduled3100000000000 implements MigrationInterface {
|
||||
name = "PromoteDraftSchedulesToScheduled3100000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`UPDATE freight.train_schedules
|
||||
SET status = 'SCHEDULED'
|
||||
WHERE status = 'DRAFT'
|
||||
AND deleted_at IS NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// One-way data promotion — nothing to restore.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
/**
|
||||
* Consist adjustments can now happen mid-route (train standing at a stop), so
|
||||
* each history row records WHERE it happened. Nullable — rows written before
|
||||
* this column simply have no yard.
|
||||
*/
|
||||
export class AddYardToScheduleWagonAdjustmentLogs3110000000000 implements MigrationInterface {
|
||||
name = "AddYardToScheduleWagonAdjustmentLogs3110000000000";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
ADD COLUMN IF NOT EXISTS yard_id uuid`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.schedule_wagon_adjustment_logs
|
||||
DROP COLUMN IF EXISTS yard_id`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,11 +135,20 @@ export class BookingContractService {
|
||||
async generateContractForGovernment(bookingId: string): Promise<void> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
if (!booking.isGovernment || booking.contractGeneratedAt) return;
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
contractSummary: this.buildContractSummary(booking),
|
||||
contractTemplateKey: this.templateResolver.resolve(booking),
|
||||
contractTemplateKey: templateKey,
|
||||
contractGeneratedAt: new Date(),
|
||||
} as never);
|
||||
// Render the PDF eagerly but NEVER block creation on it — Chromium can take
|
||||
// seconds (or hang on assets); the document re-renders on view/download.
|
||||
void this.upsertContractPdf(bookingId, booking.reference, templateKey).catch(
|
||||
(err) =>
|
||||
this.logger.warn(
|
||||
`Government contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async streamContract(bookingId: string) {
|
||||
|
||||
@@ -464,6 +464,26 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/carriage-acceptance-sheet')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Download the carriage acceptance sheet (one per booking, lists every allocated wagon)',
|
||||
})
|
||||
async carriageAcceptanceSheet(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
const { filename, buffer } = await this.bookingsService.carriageAcceptanceSheet(id);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||
async listCustomerTrucks(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DocumentReviewStatus,
|
||||
} from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
@@ -201,10 +202,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
vgmPerUnitTons: number;
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
containerNumbers?: string[];
|
||||
weightResult: ContainerWeightResult;
|
||||
}>,
|
||||
): Promise<BookingContainer[]> {
|
||||
const containerRepo = this.dataSource.getRepository(BookingContainer);
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
const typeRepo = this.dataSource.getRepository(ContainerType);
|
||||
const saved: BookingContainer[] = [];
|
||||
|
||||
@@ -230,7 +233,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
isOverweight: item.weightResult.isOverweight,
|
||||
overweightExcessTons: item.weightResult.overweightExcessTons,
|
||||
});
|
||||
saved.push(await containerRepo.save(row));
|
||||
const savedRow = await containerRepo.save(row);
|
||||
saved.push(savedRow);
|
||||
|
||||
// Physical container numbers, one unit row each (capped to the line
|
||||
// quantity; blanks skipped). Optional — units can also be entered later.
|
||||
const numbers = (item.containerNumbers ?? [])
|
||||
.map((n) => n.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, item.quantity);
|
||||
let sortOrder = 0;
|
||||
for (const containerNumber of numbers) {
|
||||
await unitRepo.save(
|
||||
unitRepo.create({
|
||||
bookingContainerId: savedRow.id,
|
||||
containerNumber,
|
||||
vgmTons: item.vgmPerUnitTons,
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return saved;
|
||||
|
||||
@@ -70,6 +70,23 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
wagonType: string | null;
|
||||
wagonNumber: string | null;
|
||||
tareWeightTons: string | null;
|
||||
equatedLength: string | null;
|
||||
loadCapacityTons: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: Date | null;
|
||||
marshalledAt: string | null;
|
||||
arrivalAt: string | null;
|
||||
containerNumbers: string | null;
|
||||
sealNumbers: string | null;
|
||||
}
|
||||
|
||||
const URGENT_PRIORITY_THRESHOLD = 1000;
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
@@ -208,6 +225,230 @@ 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.
|
||||
*/
|
||||
async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const wagons: CarriageAcceptanceWagonRow[] = await this.dataSource.query(
|
||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||
COALESCE(wt.code, wt.name) AS "wagonType",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.length_meters AS "equatedLength",
|
||||
tsw.capacity_tons AS "loadCapacityTons",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "marshalledAt",
|
||||
sd.label AS "arrivalAt",
|
||||
string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers",
|
||||
string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
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.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.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
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
if (wagons.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'No wagons are allocated to this booking yet — the carriage acceptance sheet is issued after wagon allocation',
|
||||
);
|
||||
}
|
||||
|
||||
const html = this.buildCarriageAcceptanceSheetHtml(booking, wagons);
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
label: 'carriage acceptance sheet',
|
||||
fallback: (prepared) => buildTabularFallbackPdf(prepared),
|
||||
});
|
||||
return {
|
||||
filename: `carriage-acceptance-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
* remainder so the Price column always sums to the Total Amount on the sheet.
|
||||
*/
|
||||
private splitAmountAcrossWagons(total: number, weights: number[]): number[] {
|
||||
const sum = weights.reduce((acc, w) => acc + w, 0);
|
||||
const shares = weights.map((w) =>
|
||||
Math.round((sum > 0 ? (total * w) / sum : total / weights.length) * 100) / 100,
|
||||
);
|
||||
const drift = Math.round((total - shares.reduce((a, b) => a + b, 0)) * 100) / 100;
|
||||
shares[shares.length - 1] = Math.round((shares[shares.length - 1] + drift) * 100) / 100;
|
||||
return shares;
|
||||
}
|
||||
|
||||
private buildCarriageAcceptanceSheetHtml(
|
||||
booking: Booking,
|
||||
wagons: CarriageAcceptanceWagonRow[],
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const num = (v: unknown, digits = 3) => (Number(v) || 0).toFixed(digits);
|
||||
const money = (v: number) =>
|
||||
v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
const departureStation = booking.originYard?.label ?? booking.originYard?.code ?? '-';
|
||||
const arrivalStation = booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-';
|
||||
const cargoName = booking.cargoType?.cargoTypeName ?? booking.cargoFreeText ?? '-';
|
||||
const currency = booking.paymentCurrency ?? 'ETB';
|
||||
const totalAmount = Number(booking.adjustedTotalAmount ?? booking.totalAmount) || 0;
|
||||
const prices = this.splitAmountAcrossWagons(
|
||||
totalAmount,
|
||||
wagons.map((w) => Number(w.allocatedWeightTons) || 0),
|
||||
);
|
||||
const header = wagons[0];
|
||||
const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date();
|
||||
|
||||
const totals = wagons.reduce(
|
||||
(acc, w) => ({
|
||||
tare: acc.tare + (Number(w.tareWeightTons) || 0),
|
||||
capacity: acc.capacity + (Number(w.loadCapacityTons) || 0),
|
||||
load: acc.load + (Number(w.allocatedWeightTons) || 0),
|
||||
length: acc.length + (Number(w.equatedLength) || 0),
|
||||
}),
|
||||
{ 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(
|
||||
(w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers),
|
||||
).length;
|
||||
|
||||
const rows = wagons
|
||||
.map(
|
||||
(w, i) => `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td>${esc(w.wagonType)}</td>
|
||||
<td>${esc(w.wagonNumber)}</td>
|
||||
<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(cargoName)}</td>
|
||||
<td>${esc(departureStation)}</td>
|
||||
<td>${esc(w.containerNumbers)}</td>
|
||||
<td>${esc(w.sealNumbers)}</td>
|
||||
<td class="num">${money(prices[i])}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Carriage Acceptance Sheet</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.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 { font-size: 11px; color: #475569; margin-top: 4px; }
|
||||
.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; }
|
||||
.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; }
|
||||
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; }
|
||||
.num { text-align: right; }
|
||||
tfoot 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; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Carriage Acceptance Sheet</h1>
|
||||
<div class="subtitle">Booking ${esc(booking.reference)} — ${esc(booking.tradeDirection)}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Sheet No.
|
||||
<strong>CAS-${esc(booking.reference)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Marshalled at</span><strong>${esc(header.marshalledAt ?? departureStation)}</strong></div>
|
||||
<div class="tile"><span>Arrival at</span><strong>${esc(header.arrivalAt ?? arrivalStation)}</strong></div>
|
||||
<div class="tile"><span>Date and time</span><strong>${esc(sheetDate.toLocaleString('en-GB'))}</strong></div>
|
||||
<div class="tile"><span>Train No.</span><strong>${esc(header.trainNumber)}</strong></div>
|
||||
<div class="tile"><span>Customer</span><strong>${esc(booking.company?.name)}</strong></div>
|
||||
<div class="tile"><span>Cargo</span><strong>${esc(cargoName)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="num">SN</th>
|
||||
<th>Type of Wagon</th>
|
||||
<th>Wagon No.</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Arrival Station</th>
|
||||
<th>Cargo Name</th>
|
||||
<th>Departure Station</th>
|
||||
<th>Container No.</th>
|
||||
<th>Seal No.</th>
|
||||
<th class="num">Price (${esc(currency)})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Total wagons: ${wagons.length} (full ${fullWagons} / empty ${wagons.length - fullWagons})</td>
|
||||
<td class="num">${num(totals.tare, 2)}</td>
|
||||
<td class="num">${num(totals.length)}</td>
|
||||
<td class="num">${num(totals.capacity)}</td>
|
||||
<td colspan="5">Gross weight (tare + load): ${num(totals.tare + totals.load)} T</td>
|
||||
<td class="num">${money(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
The wagons listed above are accepted for carriage under booking ${esc(booking.reference)}.
|
||||
Wagon identity, container and seal numbers must be verified against the physical consist
|
||||
before the sheet is signed.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Signed by — EDR operations / date</div>
|
||||
<div class="line">Signed by — customer or agent / date</div>
|
||||
<div class="line">Signed by — marshalling yard / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Resolve trade direction from yard countries; reject client mismatch. */
|
||||
/**
|
||||
* An intercity corridor is valid when both yards are Ethiopian and at least
|
||||
@@ -934,6 +1175,7 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
hazardousQuantity: c.hazardousQuantity,
|
||||
reeferQuantity: c.reeferQuantity,
|
||||
containerNumbers: c.containerNumbers,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
// The split is a pure helper on the prototype (never touches `this`), so it can be
|
||||
// exercised without constructing the service and its dependency graph.
|
||||
const split = (total: number, weights: number[]): number[] =>
|
||||
(
|
||||
BookingsService.prototype as unknown as {
|
||||
splitAmountAcrossWagons(total: number, weights: number[]): number[];
|
||||
}
|
||||
).splitAmountAcrossWagons(total, weights);
|
||||
|
||||
describe('carriage acceptance sheet — price split', () => {
|
||||
it('splits proportionally to allocated weight', () => {
|
||||
expect(split(100, [30, 10])).toEqual([75, 25]);
|
||||
});
|
||||
|
||||
it('splits equally when no weights are recorded', () => {
|
||||
expect(split(90, [0, 0, 0])).toEqual([30, 30, 30]);
|
||||
});
|
||||
|
||||
it('always sums back to the booking total despite rounding', () => {
|
||||
const shares = split(100, [1, 1, 1]);
|
||||
expect(shares.reduce((a, b) => a + b, 0)).toBe(100);
|
||||
expect(shares).toEqual([33.33, 33.33, 33.34]);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,17 @@ export class CreateBookingContainerDto {
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Physical container numbers for this line (each becomes a booking_container_unit; extras beyond `quantity` are ignored)',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@MaxLength(64, { each: true })
|
||||
containerNumbers?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const;
|
||||
export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE', 'SWITCH'] as const;
|
||||
export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* History row for a consist adjustment made from a schedule: staff coupled a
|
||||
* wagon onto (ADD) or detached one from (REMOVE) the schedule's built train —
|
||||
* e.g. trimming free wagons whose tare pushed gross weight over the
|
||||
* locomotives' pull limit. Plain columns (no FK relations) so the history
|
||||
* survives the wagon or train being deleted later.
|
||||
* wagon onto (ADD), detached one from (REMOVE), or swapped the physical wagon
|
||||
* under a loaded slot (SWITCH — wagonNumber reads "OLD → NEW") on the
|
||||
* schedule's built train. `yardId` records WHERE it happened: the origin yard
|
||||
* before departure, or the mid-route stop the train was standing at. Plain
|
||||
* columns (no FK relations) so the history survives the wagon or train being
|
||||
* deleted later.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' })
|
||||
@Index(['trainScheduleId'])
|
||||
@@ -33,6 +35,9 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true })
|
||||
adjustedByUserId!: string | null;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId!: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
|
||||
@@ -614,6 +614,26 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const linked =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
|
||||
// Intercity is allocated MANUALLY: payment secures the ride, staff then
|
||||
// place it on whichever same-route train suits (intercity panel). Unpin
|
||||
// from the train it reserved against — that train may be the wrong one by
|
||||
// the time it departs — and return it to the waiting pool as PAID.
|
||||
if (!linked && booking.tradeDirection === "DOMESTIC" && !booking.isGovernment) {
|
||||
await this.dataSource.getRepository(Booking).update(bookingId, {
|
||||
trainScheduleId: null,
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
paymentDeadline: null,
|
||||
} as never);
|
||||
this.logger.log(
|
||||
`[BATCH] intercity ${booking.reference ?? bookingId} PAID — awaiting manual placement by staff`,
|
||||
);
|
||||
void this.completeTrackingMilestones(bookingId, [
|
||||
"FREIGHT_PAYMENT_PENDING",
|
||||
"FREIGHT_PAYMENT_SETTLED",
|
||||
]);
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "intercity_paid_unplaced");
|
||||
return;
|
||||
}
|
||||
if (!linked) {
|
||||
if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return;
|
||||
await this.allocate(booking.trainScheduleId, booking, "paid");
|
||||
@@ -3143,6 +3163,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
return;
|
||||
}
|
||||
// Manual placement of an ALREADY-PAID intercity booking: payment landed
|
||||
// earlier (and unpinned it back to the pool) — staff are now choosing its
|
||||
// train, so link directly. No new pay window; wagon assignment stays with
|
||||
// staff in the workspace.
|
||||
if (booking.paymentStatus === 'PAID' || booking.status === 'PAID') {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(booking.id, { trainScheduleId: scheduleId });
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.allocate(scheduleId, booking, 'paid');
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
return;
|
||||
}
|
||||
await this.reserve(booking, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
@@ -3322,6 +3355,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
booking: Booking,
|
||||
reason: "paid" | "gov",
|
||||
): Promise<void> {
|
||||
// Stamp the computed wagon need on the link. Several callers pass a booking
|
||||
// loaded without cargo relations (ensurePaidBookingAllocated), and a NULL
|
||||
// wagonsRequired makes every capacity/occupancy reader miscount this
|
||||
// booking as 1 wagon — reload with the relations wagonsFor sizes from.
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const full =
|
||||
booking.bookingContainers || booking.cargoType
|
||||
? booking
|
||||
: await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: booking.id },
|
||||
relations: {
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
},
|
||||
});
|
||||
const wagonsRequired = this.wagonsFor(full ?? booking, wagonDims);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const exists =
|
||||
await this.trainScheduleBookingsRepository.existsForBooking(
|
||||
@@ -3338,6 +3387,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
status: reason === "paid" ? "PAID" : booking.status,
|
||||
schedulingStatus: "SCHEDULED",
|
||||
scheduledAt: new Date(),
|
||||
wagonsRequired,
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
@@ -3346,7 +3396,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||
);
|
||||
this.notifier.secured(booking, reason, scheduleId);
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
// Intercity rides are placed on wagons BY STAFF (workspace wizard) — auto
|
||||
// wagon assignment is for the import/export batch flow only.
|
||||
if (booking.tradeDirection !== 'DOMESTIC') {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
void this.markWagonAllocatedMilestone(booking.id);
|
||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||
// Government allocations don't pay upfront — theirs stay pending.
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsOptional, IsUUID } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsOptional, IsUUID, ValidateNested } from 'class-validator';
|
||||
|
||||
export class ConsistWagonSwitchDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Coupled wagon being taken out of the consist.' })
|
||||
@IsUUID()
|
||||
fromWagonId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'AVAILABLE same-type wagon from the current yard that takes its place (and its slot, cargo included).' })
|
||||
@IsUUID()
|
||||
toWagonId!: string;
|
||||
}
|
||||
|
||||
export class AdjustScheduleConsistDto {
|
||||
@ApiPropertyOptional({
|
||||
@@ -23,4 +34,15 @@ export class AdjustScheduleConsistDto {
|
||||
@IsArray()
|
||||
@IsUUID('all', { each: true })
|
||||
removeWagonIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [ConsistWagonSwitchDto],
|
||||
description:
|
||||
"Wagon swaps: the replacement takes over the outgoing wagon's position AND its slot, so cargo allocations ride the new wagon. This is how a LOADED wagon leaves the train — removal is blocked for it, switching is not. Replacement must be the same wagon type, AVAILABLE, standing in the train's current yard.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ConsistWagonSwitchDto)
|
||||
switches?: ConsistWagonSwitchDto[];
|
||||
}
|
||||
|
||||
@@ -330,8 +330,10 @@ export class IntercityService {
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.where(`booking.trade_direction = 'DOMESTIC'`)
|
||||
.andWhere('booking.train_schedule_id IS NULL')
|
||||
// PAID = customer paid but staff have not placed it on a train yet
|
||||
// (intercity allocation is manual) — it stays in the pool until they do.
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
`((booking.is_government = false AND booking.status IN ('FULLY_EXECUTED', 'PAID'))
|
||||
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
@@ -382,8 +384,12 @@ export class IntercityService {
|
||||
if (booking.trainScheduleId) {
|
||||
return 'Already assigned to a train';
|
||||
}
|
||||
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
|
||||
if (booking.status !== readyStatus) {
|
||||
// Commercial: FULLY_EXECUTED opens a pay window; PAID (payment landed,
|
||||
// awaiting manual placement) links straight onto the chosen train.
|
||||
const readyStatuses = booking.isGovernment
|
||||
? ['APPROVED']
|
||||
: ['FULLY_EXECUTED', 'PAID'];
|
||||
if (!readyStatuses.includes(booking.status)) {
|
||||
return `Not ready to board (status ${booking.status})`;
|
||||
}
|
||||
if (!this.corridorOnRoute(booking, milestoneSeq)) {
|
||||
|
||||
@@ -195,6 +195,16 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/history")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Unified change history for a schedule: wagon consist adjustments (add/remove/switch, with the stop they happened at) merged with booking composition removals, newest first",
|
||||
})
|
||||
getScheduleHistory(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getScheduleHistory(id);
|
||||
}
|
||||
|
||||
@Get("bookable-schedules")
|
||||
// No staff guard: customers hit this while creating a booking to find OPEN
|
||||
// same-route schedules. Do not attach train_scheduling permissions here.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -549,9 +549,12 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
wagonTypes: Array<Pick<WagonType, 'lengthMeters'>>,
|
||||
limits: TrainLimitConfig | undefined,
|
||||
stops: string[],
|
||||
/** Display names parallel to `stops` — violations then name the leg they hit. */
|
||||
stopLabels?: string[],
|
||||
): string[] {
|
||||
if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits);
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const label = (i: number) => stopLabels?.[i] ?? stops[i];
|
||||
const violations = new Set<string>();
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = wagonPlan.filter(
|
||||
@@ -559,7 +562,7 @@ export function validateMixedTrainLimitsPerEdge(
|
||||
);
|
||||
if (!active.length) continue;
|
||||
for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) {
|
||||
violations.add(violation);
|
||||
violations.add(`Leg ${label(edge)} → ${label(edge + 1)}: ${violation}`);
|
||||
}
|
||||
}
|
||||
return [...violations];
|
||||
@@ -603,7 +606,37 @@ export function maxEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
): { grossWeightTons: number; lengthMeters: number; loadedWagonCount: number } {
|
||||
const totals = (slots: EdgeUsageSlot[]) => ({
|
||||
return perEdgeConsistUsage(wagonPlan, stops).reduce(
|
||||
(max, e) => ({
|
||||
grossWeightTons: Math.max(max.grossWeightTons, e.grossWeightTons),
|
||||
lengthMeters: Math.max(max.lengthMeters, e.lengthMeters),
|
||||
loadedWagonCount: Math.max(max.loadedWagonCount, e.loadedWagonCount),
|
||||
}),
|
||||
{ grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage of one corridor edge (between stops[edge] and stops[edge + 1]). */
|
||||
export type EdgeConsistUsage = {
|
||||
edge: number;
|
||||
grossWeightTons: number;
|
||||
lengthMeters: number;
|
||||
loadedWagonCount: number;
|
||||
wagonCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-edge breakdown behind {@link maxEdgeConsistUsage}: every edge's own
|
||||
* consist totals, so callers can name WHICH leg breaks a limit instead of
|
||||
* only reporting the heaviest figure. Two stops or fewer collapse to a
|
||||
* single whole-route edge.
|
||||
*/
|
||||
export function perEdgeConsistUsage(
|
||||
wagonPlan: EdgeUsageSlot[],
|
||||
stops: string[],
|
||||
): EdgeConsistUsage[] {
|
||||
const totals = (edge: number, slots: EdgeUsageSlot[]): EdgeConsistUsage => ({
|
||||
edge,
|
||||
grossWeightTons: slots.reduce(
|
||||
(sum, w) =>
|
||||
sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0),
|
||||
@@ -611,19 +644,16 @@ export function maxEdgeConsistUsage(
|
||||
),
|
||||
lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0),
|
||||
loadedWagonCount: slots.filter((w) => (w.allocations?.length ?? 1) > 0).length,
|
||||
wagonCount: slots.length,
|
||||
});
|
||||
if (stops.length <= 2) return totals(wagonPlan);
|
||||
if (stops.length <= 2) return [totals(0, wagonPlan)];
|
||||
const spans = slotSpans(wagonPlan, stops);
|
||||
const usage = { grossWeightTons: 0, lengthMeters: 0, loadedWagonCount: 0 };
|
||||
for (let edge = 0; edge < stops.length - 1; edge += 1) {
|
||||
const active = totals(
|
||||
return Array.from({ length: stops.length - 1 }, (_, edge) =>
|
||||
totals(
|
||||
edge,
|
||||
wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to),
|
||||
);
|
||||
usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons);
|
||||
usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters);
|
||||
usage.loadedWagonCount = Math.max(usage.loadedWagonCount, active.loadedWagonCount);
|
||||
}
|
||||
return usage;
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function validate20ftContainerRules(
|
||||
|
||||
Reference in New Issue
Block a user