feat: add contract extension request functionality

- Implemented  method in  to allow customers to request an extension for expired contracts.
- Added  component in  for users to initiate extension requests.
- Updated  to include logic for handling extension requests for expired contracts.
- Enhanced  to display extension request options and status.
- Created migration to add  and  columns to the contracts table.
- Added unit tests for contract extension request and handling in .
- Defined DTOs for request and extension in .
- Updated types in  to include new fields related to contract extensions.
This commit is contained in:
marshal
2026-09-06 12:33:40 +00:00
parent f225721e89
commit 75b75e3d4e
32 changed files with 1582 additions and 179 deletions

View File

@@ -871,7 +871,7 @@ export class TrainSchedulingController {
@TrainSchedulingView()
@ApiOperation({
summary:
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
"Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)",
})
async scheduleWagonListExport(
@Param("id", ParseUUIDPipe) id: string,

View File

@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
export class TrainSchedulingGlobalRules extends BaseEntity {
/**
* LEGACY — `max_train_length_meters`, `max_train_weight_tons` and
* `max_20ft_container_weight_tons` are no longer read by planning: train
* weight/length come from locomotive configuration and per-box ceilings from
* the rule engine's weight limit rules (`max_capacity_tons`). Kept only so
* existing rows keep loading.
*/
@Column({
name: 'max_train_length_meters',
type: 'numeric',

View File

@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
});
});
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
const ceilings = (bookings: unknown[]) =>
(
service as never as {
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
}
).containerCapacityCeilingsByLine(bookings);
it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => {
const find = jest.fn().mockResolvedValue([
{ containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' },
{ containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' },
{ containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null },
]);
dataSource.getRepository.mockImplementation((entity: unknown) => {
if (entity === WeightLimitRule) return { find };
throw new Error('unexpected repository');
});
const result = await ceilings([
{
tradeDirection: 'EXPORT',
bookingContainers: [
{ id: 'line-a', containerTypeId: 'ct-20' },
{ id: 'line-b', containerTypeId: 'ct-40' },
],
},
{ tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] },
]);
expect(result).toEqual({ 'line-a': 26, 'line-c': 28 });
expect(find).toHaveBeenCalledTimes(1);
});
it('queries nothing when the bookings carry no container lines', async () => {
dataSource.getRepository.mockImplementation(() => {
throw new Error('should not be called');
});
await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({});
});
});
});

View File

@@ -75,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
import { TabularExportService } from '../../exports/tabular-export.service';
import {
buildWagonListWorkbook,
groupWagonListLines,
WagonListLine,
} from '../utils/wagon-list-workbook.util';
/** One line of the schedule wagon-list export (raw SQL projection). */
interface ScheduleWagonListRow {
sequenceNo: number | null;
wagonNumber: string | null;
wagonType: string | null;
containerNumber: string | null;
containerSizeFt: number | null;
loadType: string | null;
status: string | null;
bulkCargoDescription: string | null;
/** numeric columns arrive as strings from pg. */
vgmTons: string | null;
originLabel: string | null;
destinationLabel: string | null;
bookingReference: string | null;
customerName: string | null;
}
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
@@ -112,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
@@ -160,6 +149,7 @@ import {
validateMixedTrainLimitsPerEdge,
MAX_TEU_SLOTS_PER_WAGON,
type ContainerPlacementInput,
type ContainerPlacementRules,
type WagonPlanSlot,
} from '../utils/wagon-plan.util';
import {
@@ -190,6 +180,8 @@ import {
trainSetLocomotiveLimits,
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
MAX_FALLBACK_LENGTH,
MAX_FALLBACK_WEIGHT,
WagonTypeDimensions,
} from '../train-capacity.util';
import {
@@ -379,13 +371,13 @@ export interface UnassignedBookingsResponse {
bookings: CompositionUnassignedBookingRow[];
}
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
maxWeightTons: 3500,
maxLengthMeters: 760,
maxWagonsPerTrain: Math.floor(760 / 14),
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
};
/**
* Train weight/length come from locomotive configuration (the assigned set, or
* the strongest in-service locomotive when none is assigned yet); per-box
* container ceilings come from the rule engine's weight limit rules. Only the
* 20ft pair-imbalance tolerance is a static default.
*/
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
interface BookingWindowRow {
@@ -447,11 +439,9 @@ export class TrainSchedulingService {
// Per-wagon history ledger (global module). @Optional keeps the positional
// spec constructors working; production always has it.
@Optional() private readonly wagonHistory?: WagonHistoryService,
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it from ExportsModule.
@Optional() private readonly tabularExport?: TabularExportService,
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
// Trailing + @Optional for the same positional-spec reason as above.
// Trailing + @Optional so the positional constructors in the existing specs
// keep working; production always resolves it.
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
) {}
@@ -813,9 +803,9 @@ export class TrainSchedulingService {
}
/**
* Train length/weight and 20ft weight caps are engine-internal (wagon
* planning still reads them off the row); they are no longer exposed or
* editable through the global-rules endpoints.
* Train length/weight and the 20ft weight cap columns are legacy: planning
* now takes weight/length from locomotive configuration and per-box ceilings
* from weight limit rules. They are neither read nor exposed here.
*/
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
if (!row) return row;
@@ -3779,15 +3769,15 @@ export class TrainSchedulingService {
}
/**
* The schedule detail page's wagon-list Excel export.
*
* One row per container (a wagon carrying two boxes yields two rows, repeating
* the wagon number) so each container's own VGM is present and totals footable.
* Bulk wagons, having no containers, yield a single row carrying the bulk
* description and the allocated tonnage as the VGM figure.
* The schedule detail page's wagon-list Excel export, laid out like the
* wagon sheet the yard circulates by hand: containers grouped by customer,
* one line per container (a two-box wagon repeats its wagon number under one
* "No."), a blank line between customers, and the wagon count / company /
* transitor merged down each group. See buildWagonListWorkbook.
*
* Only wagon slots that actually carry an allocation are listed — empty slots
* on the consist are omitted.
* on the consist are omitted. A bulk wagon yields one line carrying the cargo
* description in place of a container number.
*/
async scheduleWagonListWorkbook(
scheduleId: string,
@@ -3796,56 +3786,37 @@ export class TrainSchedulingService {
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!this.tabularExport) {
throw new BadRequestException('Tabular export service is unavailable');
}
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
// container-less) allocation as one row. `booking_container_units` is joined
// on BOTH container number and its booking_container line — container
// numbers repeat across bookings, so number alone would multiply rows.
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
// container-less) allocation as one row. The transitor is the customs
// clearing agent the customer named on the booking.
const lines: WagonListLine[] = await this.dataSource.query(
`SELECT tsw.sequence_no AS "sequenceNo",
w.wagon_number AS "wagonNumber",
COALESCE(wt.name, wt.code) AS "wagonType",
ci.container_number AS "containerNumber",
cit.size_ft AS "containerSizeFt",
a.load_type AS "loadType",
a.status AS "status",
bl.cargo_description AS "bulkCargoDescription",
COALESCE(
ci.gross_weight_tons,
bcu.vgm_tons,
bc.vgm_per_unit_tons,
a.allocated_weight_tons
) AS "vgmTons",
COALESCE(by_.label, so.label) AS "originLabel",
COALESCE(ay.label, sd.label) AS "destinationLabel",
b.reference AS "bookingReference",
COALESCE(
slc.name,
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
c.name
) AS "customerName"
) AS "customerName",
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
FROM freight.train_schedules s
JOIN freight.train_set_wagons tsw
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
JOIN freight.wagon_booking_allocations a
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
LEFT JOIN freight.bookings b ON b.id = a.booking_id
LEFT JOIN freight.companies c ON c.id = b.company_id
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
LEFT JOIN freight.wagon_allocation_container_items ci
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
LEFT JOIN freight.booking_container bc
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
LEFT JOIN freight.booking_container_units bcu
ON bcu.container_number = ci.container_number
AND bcu.booking_container_id = bc.id
AND bcu.deleted_at IS NULL
LEFT JOIN freight.wagon_allocation_bulk_loads bl
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
@@ -3857,47 +3828,13 @@ export class TrainSchedulingService {
[scheduleId],
);
// "number" is the printed line number of the sheet, not the wagon sequence —
// a two-container wagon occupies two lines, and the reader counts lines.
const sheetRows = rows.map((row, index) => ({
number: index + 1,
wagonNumber: row.wagonNumber ?? '—',
containerNumber:
row.containerNumber ??
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
originLabel: row.originLabel ?? '—',
destinationLabel: row.destinationLabel ?? '—',
customerName: row.customerName ?? '—',
}));
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
const buffer = await this.tabularExport.toXlsx({
title: `Wagons ${reference}`.slice(0, 31),
description: `Wagon list for train ${reference}`,
label: 'train-schedule:wagon-list',
kpis: [
{ label: 'Lines', value: sheetRows.length },
{
label: 'Wagons',
value: new Set(rows.map((r) => r.sequenceNo)).size,
},
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
],
columns: [
{ key: 'number', label: 'No.', type: 'number' },
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
{ key: 'containerNumber', label: 'Container number', type: 'string' },
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
{ key: 'originLabel', label: 'Origin', type: 'string' },
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
{ key: 'customerName', label: 'Customer', type: 'string' },
],
rows: sheetRows,
const { groups, totalWagons } = groupWagonListLines(lines);
const buffer = await buildWagonListWorkbook({
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
groups,
totalWagons,
});
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
return {
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
buffer,
@@ -6717,11 +6654,6 @@ export class TrainSchedulingService {
)),
);
const placementRules = {
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
@@ -6754,6 +6686,11 @@ export class TrainSchedulingService {
);
if (requireContainerPlacements && resolvedMode !== 'BULK') {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
const placementRules: ContainerPlacementRules = {
maxContainerWeightTonsByLineId:
await this.containerCapacityCeilingsByLine(containerBookings),
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
};
violations.push(
...validateContainerPlacements(
containerBookings,
@@ -6901,6 +6838,70 @@ export class TrainSchedulingService {
}
}
/**
* Hard per-box ceiling for every container line of the given bookings, from
* the rule engine's weight limit rule (`max_capacity_tons`) matching the
* line's container type and the booking's trade direction (a `BOTH` rule
* applies to either direction; an exact-direction rule wins over it). Lines
* whose rule has no capacity set get no entry — capacity is optional.
*/
private async containerCapacityCeilingsByLine(
bookings: Booking[],
): Promise<Record<string, number>> {
const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = [];
for (const booking of bookings) {
const direction = String(booking.tradeDirection ?? '').toUpperCase();
for (const line of booking.bookingContainers ?? []) {
if (!line.containerTypeId) continue;
lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction });
}
}
if (!lines.length) return {};
const typeIds = [...new Set(lines.map((l) => l.containerTypeId))];
const rules = await this.dataSource
.getRepository(WeightLimitRule)
.find({ where: { containerTypeId: In(typeIds) } });
const ceilings: Record<string, number> = {};
for (const { lineId, containerTypeId, tradeDirection } of lines) {
const candidates = rules.filter(
(r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null,
);
const rule =
candidates.find((r) => r.tradeDirection === tradeDirection) ??
candidates.find((r) => r.tradeDirection === 'BOTH');
const cap = Number(rule?.maxCapacityTons);
if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap;
}
return ceilings;
}
/**
* Limits for a train that has no locomotive assigned yet: the strongest
* in-service locomotive on each axis, so planning assumes the most capable
* power that could be coupled. Null when no locomotive is configured at all.
*/
private async strongestFleetLocomotiveLimits(): Promise<LocomotiveLimits | null> {
const fleet = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
});
const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0);
const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0);
if (!pulls.length && !lengths.length) return null;
const strongest = (axis: number[], pick: (l: Locomotive) => number) =>
fleet.find((l) => pick(l) === Math.max(...axis));
return {
maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity,
maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity,
overageToleranceTons:
Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0,
overageToleranceMeters:
Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) ||
0,
};
}
private async resolveTrainLimitConfig(
dto?: {
maxTrainWeightTons?: number;
@@ -6911,24 +6912,14 @@ export class TrainSchedulingService {
builtWagonCount?: number,
): Promise<Required<TrainLimitConfig>> {
const row = await this.loadGlobalRulesRow();
const configured = this.configService?.get<{
maxTrainWeightTons?: number;
maxTrainLengthMeters?: number;
maxWagonsPerTrain?: number;
}>('app.trainScheduling');
const ruleWeightCap =
dto?.maxTrainWeightTons ??
(row?.maxTrainWeightTons != null
? Number(row.maxTrainWeightTons)
: configured?.maxTrainWeightTons);
const ruleLengthCap =
dto?.maxTrainLengthMeters ??
(row?.maxTrainLengthMeters != null
? Number(row.maxTrainLengthMeters)
: configured?.maxTrainLengthMeters);
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
'app.trainScheduling',
);
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
const max20ftPairWeightDiffTons = this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
);
if (locomotive) {
// With a locomotive assigned its own limits are the single source of
@@ -6963,52 +6954,40 @@ export class TrainSchedulingService {
: builtWagonCount && builtWagonCount > 0
? builtWagonCount
: derived.maxWagonSlots,
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) ||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}
const maxWeightTons = this.positiveNumber(
dto?.maxTrainWeightTons,
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
);
const maxLengthMeters = this.positiveNumber(
dto?.maxTrainLengthMeters,
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
);
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
// No locomotive on the set yet: plan against the strongest in-service
// locomotive's configuration. An explicit dto override still narrows it.
const fleet = await this.strongestFleetLocomotiveLimits();
if (!fleet) {
this.logger.warn(
'No in-service locomotive is configured — train weight/length limits fall back to ' +
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
);
}
const derived = deriveTrainCapacityFromLocomotive(
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
wagonTypes,
{
maxTrainWeightTons: dto?.maxTrainWeightTons,
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
},
);
return {
maxWeightTons,
maxLengthMeters,
maxWeightTons: derived.maxWeightTons,
maxLengthMeters: derived.maxLengthMeters,
maxWagonsPerTrain: Math.floor(
this.positiveNumber(
dto?.maxWagonsPerTrain,
row?.maxWagonsPerTrain != null
? Number(row.maxWagonsPerTrain)
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
),
),
max20ftContainerWeightTons: this.positiveNumber(
undefined,
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
),
max20ftPairWeightDiffTons: this.positiveNumber(
undefined,
Number(row?.max20ftPairWeightDiffTons) ||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
),
max20ftPairWeightDiffTons,
};
}

View File

@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
import { BookingsModule } from '../bookings/bookings.module';
import { Container } from '../container-management/entities/container.entity';
import { ExportsModule } from '../exports/exports.module';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FacilityHandlingService } from './facility-handling.service';
@@ -69,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
UserTradeAccessModule,
NotificationsModule,
NotificationInboxModule,
ExportsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,

View File

@@ -0,0 +1,169 @@
import ExcelJS from 'exceljs';
import {
buildWagonListWorkbook,
groupWagonListLines,
WAGON_LIST_HEADERS,
WagonListLine,
wagonListSheetName,
} from './wagon-list-workbook.util';
const line = (overrides: Partial<WagonListLine>): WagonListLine => ({
sequenceNo: 1,
wagonNumber: 'ER0001',
containerNumber: 'CONT0000001',
containerSizeFt: 40,
loadType: 'CONTAINER',
bulkCargoDescription: null,
originLabel: 'DCT',
destinationLabel: 'GMP',
customerName: 'ABC transit',
transitor: null,
...overrides,
});
// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes,
// then a second customer's single wagon, and a bulk wagon for a third.
const fixture: WagonListLine[] = [
line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'CXDU1833620',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 2,
wagonNumber: 'ER0693',
containerNumber: 'TTNU1328287',
containerSizeFt: 20,
transitor: 'Semuzu Transit',
}),
line({
sequenceNo: 3,
wagonNumber: 'ER0444',
containerNumber: 'ESLU0720200',
containerSizeFt: 20,
customerName: 'SYNTRANS LOGISTICS PLC',
}),
line({
sequenceNo: 4,
wagonNumber: 'ER0716',
containerNumber: null,
containerSizeFt: null,
loadType: 'BULK',
bulkCargoDescription: 'Wheat',
customerName: 'Baili food processing',
}),
];
describe('groupWagonListLines', () => {
it('groups by customer in first-appearance order and counts wagons, not containers', () => {
const { groups, totalWagons } = groupWagonListLines(fixture);
expect(groups.map((g) => g.companyName)).toEqual([
'ABC transit',
'SYNTRANS LOGISTICS PLC',
'Baili food processing',
]);
expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]);
expect(totalWagons).toBe(4);
});
it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]);
expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]);
expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]);
});
it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => {
const { groups } = groupWagonListLines(fixture);
expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']);
expect(groups[0].transitor).toBe('Semuzu Transit');
expect(groups[2].lines[0]).toMatchObject({
containerNumber: 'Wheat',
containerType: 'Bulk',
});
expect(groups[2].transitor).toBe('');
});
it('files lines with no customer under a placeholder group', () => {
const { groups } = groupWagonListLines([line({ customerName: null })]);
expect(groups[0].companyName).toBe('—');
});
});
describe('wagonListSheetName', () => {
it('strips characters Excel forbids and caps at 31 characters', () => {
expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502');
expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31);
expect(wagonListSheetName('///')).toBe('Wagons');
});
});
describe('buildWagonListWorkbook', () => {
let sheet: ExcelJS.Worksheet;
beforeAll(async () => {
const grouped = groupWagonListLines(fixture);
const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped });
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
sheet = workbook.worksheets[0];
});
const cell = (address: string) => sheet.getCell(address).value;
const merged = (address: string) => sheet.getCell(address).isMerged;
it('opens with the banner (train + total wagons) merged across every column, then the headers', () => {
expect(sheet.name).toBe('V138U 8502');
expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/);
expect(merged('I1')).toBe(true);
expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]);
expect(sheet.getCell('A2').font?.bold).toBe(true);
});
it('lays each customer out as a contiguous block separated by a blank row', () => {
// Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili.
expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']);
expect(sheet.getRow(6).values).toEqual([]);
expect(cell('B7')).toBe('ER0444');
expect(sheet.getRow(8).values).toEqual([]);
expect(cell('B9')).toBe('ER0716');
expect(cell('C9')).toBe('Wheat');
expect(cell('G9')).toBe('Bulk');
});
it('prints "No." once per wagon, merged down a two-container wagon', () => {
expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]);
expect(merged('A4')).toBe(true);
expect(merged('A5')).toBe(true);
expect(merged('A3')).toBe(false);
expect(cell('A7')).toBe(3);
expect(cell('A9')).toBe(4);
});
it('merges wagon count, company and transitor down the whole customer block', () => {
expect(cell('D3')).toBe(2);
expect(cell('H3')).toBe('ABC transit');
expect(cell('I3')).toBe('Semuzu Transit');
for (const col of ['D', 'H', 'I']) {
expect(merged(`${col}3`)).toBe(true);
expect(merged(`${col}5`)).toBe(true);
}
expect(sheet.getCell('H3').font?.bold).toBe(true);
// A single-line block has nothing to merge.
expect(merged('H7')).toBe(false);
expect(cell('D7')).toBe(1);
expect(cell('I7')).toBeNull();
});
it('carries the route and container size on every line', () => {
expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']);
expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']);
});
});

View File

@@ -0,0 +1,219 @@
import ExcelJS from 'exceljs';
/**
* One loaded container (or one bulk load) on a wagon of the schedule — the
* input grain of the wagon-list workbook. A wagon carrying two boxes arrives
* as two lines sharing `sequenceNo`.
*/
export interface WagonListLine {
sequenceNo: number | null;
wagonNumber: string | null;
containerNumber: string | null;
/** 20 / 40 / 45 …; null for bulk or unknown. */
containerSizeFt: number | null;
loadType: string | null;
bulkCargoDescription: string | null;
originLabel: string | null;
destinationLabel: string | null;
customerName: string | null;
/** The customs clearing / transit agent named on the booking. */
transitor: string | null;
}
export interface WagonListGroupLine {
/** Sheet-wide wagon counter — printed once per wagon, not once per container. */
wagonOrdinal: number;
sequenceNo: number | null;
wagonNumber: string;
containerNumber: string;
containerType: string;
origin: string;
destination: string;
}
/** All lines of one customer, contiguous on the sheet. */
export interface WagonListGroup {
companyName: string;
transitor: string;
/** Distinct wagons in the group — the "Number of Wagons" cell. */
wagonCount: number;
lines: WagonListGroupLine[];
}
export interface WagonListWorkbookInput {
/** Train number (falls back to the schedule reference) — the banner text. */
trainLabel: string;
groups: WagonListGroup[];
totalWagons: number;
}
const BLANK = '—';
/**
* Groups the container-grain lines by customer, in order of first appearance,
* keeping consist order inside each group. Wagon ordinals run across the whole
* sheet so the reader can count wagons down the "No." column.
*/
export function groupWagonListLines(lines: WagonListLine[]): {
groups: WagonListGroup[];
totalWagons: number;
} {
const groups = new Map<
string,
WagonListGroup & { transitors: Set<string>; wagons: Set<string> }
>();
const ordinalByGroupWagon = new Map<string, number>();
let nextOrdinal = 1;
for (const line of lines) {
const companyName = line.customerName?.trim() || BLANK;
let group = groups.get(companyName);
if (!group) {
group = {
companyName,
transitor: '',
wagonCount: 0,
lines: [],
transitors: new Set(),
wagons: new Set(),
};
groups.set(companyName, group);
}
const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`;
const ordinalKey = `${companyName} ${wagonKey}`;
let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey);
if (wagonOrdinal === undefined) {
wagonOrdinal = nextOrdinal++;
ordinalByGroupWagon.set(ordinalKey, wagonOrdinal);
group.wagons.add(wagonKey);
}
const transitor = line.transitor?.trim();
if (transitor) group.transitors.add(transitor);
const isBulk = line.loadType === 'BULK' && !line.containerNumber;
group.lines.push({
wagonOrdinal,
sequenceNo: line.sequenceNo,
wagonNumber: line.wagonNumber ?? BLANK,
containerNumber:
line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK),
containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK,
origin: line.originLabel ?? BLANK,
destination: line.destinationLabel ?? BLANK,
});
}
const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({
...group,
transitor: [...transitors].join(', '),
wagonCount: wagons.size,
}));
return {
groups: result,
totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0),
};
}
const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9];
export const WAGON_LIST_HEADERS = [
'No.',
'Wagon',
'Container No.',
'Number of Wagons',
'Origin',
'Destination',
'Type of Container',
'Company Name',
'Transitor',
];
const LAST_COLUMN = WAGON_LIST_HEADERS.length;
/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */
const BANNER_FILL: ExcelJS.Fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFACB9CA' },
};
const CENTERED: Partial<ExcelJS.Alignment> = { horizontal: 'center', vertical: 'middle' };
/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */
export function wagonListSheetName(trainLabel: string): string {
const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim();
return (cleaned || 'Wagons').slice(0, 31);
}
/**
* The operations wagon-list sheet, laid out like the hand-made one the yard
* circulates: a banner row (train number + total wagons), one header row, then
* the containers grouped by customer with a blank row between customers.
* Inside a group the wagon number repeats per container while "No." is merged
* down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged
* down the whole group.
*/
export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), {
views: [{ zoomScale: 85 }],
});
COLUMN_WIDTHS.forEach((width, i) => {
sheet.getColumn(i + 1).width = width;
});
const banner = sheet.addRow([
`${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`,
]);
sheet.mergeCells(1, 1, 1, LAST_COLUMN);
banner.height = 28;
const bannerCell = banner.getCell(1);
bannerCell.font = { name: 'Calibri', size: 12, bold: true };
bannerCell.alignment = CENTERED;
bannerCell.fill = BANNER_FILL;
const header = sheet.addRow(WAGON_LIST_HEADERS);
header.eachCell((cell) => {
cell.font = { name: 'Calibri', size: 11, bold: true };
cell.alignment = CENTERED;
});
input.groups.forEach((group, groupIndex) => {
if (groupIndex > 0) sheet.addRow([]);
const firstRow = sheet.rowCount + 1;
let wagonStartRow = firstRow;
group.lines.forEach((line, lineIndex) => {
const isFirstLine = lineIndex === 0;
const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal;
const row = sheet.addRow([
newWagon ? line.wagonOrdinal : null,
line.wagonNumber,
line.containerNumber,
isFirstLine ? group.wagonCount : null,
line.origin,
line.destination,
line.containerType,
isFirstLine ? group.companyName : null,
isFirstLine ? group.transitor || null : null,
]);
for (let col = 1; col <= LAST_COLUMN; col++) {
const cell = row.getCell(col);
cell.font = { name: 'Calibri', size: 11, bold: col === 8 };
if (col === 8) cell.alignment = { ...CENTERED, wrapText: true };
else if (col !== 2 && col !== 3) cell.alignment = CENTERED;
}
row.getCell(1).numFmt = '#,##0';
if (newWagon && !isFirstLine) {
if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1);
wagonStartRow = row.number;
}
});
const lastRow = sheet.rowCount;
if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1);
if (lastRow > firstRow) {
for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col);
}
});
return Buffer.from(await workbook.xlsx.writeBuffer());
}

View File

@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
});
it('rejects 20ft container over max individual weight', () => {
it('rejects a container over its line weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
max20ftPairWeightDiffTons: 10,
});
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
});
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
const units = expandBookingContainerUnits([booking]);
const placements = units.map((unit, index) => ({
bookingContainerId: unit.bookingContainerId,
unitIndex: unit.unitIndex,
sequenceNo: 1,
containerNumber: `CNTR-${index + 1}`,
}));
const violations = validate20ftContainerRules(units, placements, {
maxContainerWeightTonsByLineId: {},
max20ftPairWeightDiffTons: 10,
});
expect(violations).toEqual([]);
});
it('rejects 20ft pair when weight difference exceeds limit', () => {
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
}));
const violations = validate20ftContainerRules(units, placements, {
max20ftContainerWeightTons: 30,
max20ftPairWeightDiffTons: 10,
});

View File

@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
maxWeightTons?: number;
maxLengthMeters?: number;
maxWagonsPerTrain?: number;
max20ftContainerWeightTons?: number;
max20ftPairWeightDiffTons?: number;
};
export type ContainerPlacementRules = {
max20ftContainerWeightTons?: number;
/**
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
* line's container type and the booking's trade direction. A line with no
* entry has no ceiling — the rule's capacity is optional.
*/
maxContainerWeightTonsByLineId?: Record<string, number>;
max20ftPairWeightDiffTons?: number;
};
@@ -820,15 +825,21 @@ export function perEdgeConsistUsage(
);
}
/**
* Per-box weight rules for a container plan:
* - every unit is checked against its line's weight-limit-rule capacity
* ceiling (`maxContainerWeightTonsByLineId`, any size);
* - 20ft pairs sharing a wagon are checked for weight imbalance.
*/
export function validate20ftContainerRules(
units: ContainerUnitRow[],
placements: ContainerPlacementInput[],
rules?: ContainerPlacementRules,
): string[] {
const violations: string[] = [];
const maxEach = rules?.max20ftContainerWeightTons;
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
const maxDiff = rules?.max20ftPairWeightDiffTons;
if (maxEach == null && maxDiff == null) return violations;
if (capacityByLine == null && maxDiff == null) return violations;
const placementByUnit = new Map(
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
const weightsBySlot = new Map<number, number[]>();
for (const unit of units) {
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const maxEach = capacityByLine?.[unit.bookingContainerId];
if (maxEach != null && unit.grossWeightTons > maxEach) {
violations.push(
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
);
}
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
if (sizeFt >= 40) continue;
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
if (!placement?.sequenceNo) continue;