Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-27 05:25:43 +00:00
94 changed files with 4054 additions and 303 deletions

View File

@@ -591,6 +591,24 @@ export class BookingWagonCancellationService {
this.logger.error(
`Consolidation-lapse cancellation failed for paid booking ${payload.paidBookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
// A silent failure here leaves a PAID half-wagon booking boarding alone
// (BK-2026-000201: no LIVE IMPORT 20ft CANCELLATION_FEE rate — the fee
// pricing threw and the booking stayed PAID). Scream to staff so it is
// fixed and the booking cancelled by hand instead of shipping.
try {
const failed = await this.bookingsRepository.findById(
payload.paidBookingId,
);
if (failed) {
this.notifyStaff(
failed,
'Consolidation-lapse cancellation FAILED — action needed',
`${failed.reference}: its consolidation partner lapsed unpaid, but the automatic cancellation failed: ${err instanceof Error ? err.message : String(err)}. Fix the cause (usually a missing LIVE per-wagon CANCELLATION_FEE rate for this trade direction + container size), then cancel the whole booking manually so the fee is invoiced and its wagons are freed.`,
);
}
} catch {
// Notification is best-effort — the error log above already fired.
}
}
}

View File

@@ -359,6 +359,12 @@ export class Booking extends BaseEntity {
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
customsClearingAgent?: string | null;
@Column({ name: 'customs_clearing_agent_email', type: 'varchar', length: 200, nullable: true })
customsClearingAgentEmail?: string | null;
@Column({ name: 'customs_clearing_agent_phone', type: 'varchar', length: 50, nullable: true })
customsClearingAgentPhone?: string | null;
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
equipmentReturn!: string;
@@ -411,6 +417,23 @@ export class Booking extends BaseEntity {
@Column({ name: 'bulk_total_weight_tons', type: 'numeric', precision: 12, scale: 3, nullable: true })
bulkTotalWeightTons?: number | null;
/**
* NUMBER_OF_WAGONS bulk only: the wagon count the customer asked for at
* booking. Allocation and PER_WAGON pricing use this count verbatim, and the
* cargo weight spreads evenly across it (weight ÷ count per wagon — validated
* against wagon capacity at creation). Null for every other cargo unit.
*/
@Column({ name: 'bulk_requested_wagons', type: 'int', nullable: true })
bulkRequestedWagons?: number | null;
/**
* NUMBER_OF_WAGONS bulk only: optional informational item count entered with
* the weight. Never prices or sizes anything (unlike PER_ITEM, where the
* count lives in cargoTotalWeightVgm).
*/
@Column({ name: 'bulk_item_count', type: 'int', nullable: true })
bulkItemCount?: number | null;
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
isHazardous!: boolean;

View File

@@ -34,15 +34,27 @@ describe('bulkTemplateCode', () => {
expect(bulkTemplateCode('STEEL', 'INTERCITY', null)).toBe('BULK_INTERCITY_STEEL');
});
it('produces 5 distinct codes per cargo type', () => {
it('gives the Ethiopian-customs-only variant its own suffix', () => {
expect(bulkTemplateCode('STEEL', 'IMPORT', true, true)).toBe(
'BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS',
);
// The flag is meaningless without customs clearing.
expect(bulkTemplateCode('STEEL', 'IMPORT', false, true)).toBe(
'BULK_IMPORT_STEEL_NO_CUSTOMS',
);
});
it('produces 7 distinct codes per cargo type', () => {
const codes = [
bulkTemplateCode('STEEL', 'IMPORT', true),
bulkTemplateCode('STEEL', 'IMPORT', true, true),
bulkTemplateCode('STEEL', 'IMPORT', false),
bulkTemplateCode('STEEL', 'EXPORT', true),
bulkTemplateCode('STEEL', 'EXPORT', true, true),
bulkTemplateCode('STEEL', 'EXPORT', false),
bulkTemplateCode('STEEL', 'INTERCITY', null),
];
expect(new Set(codes).size).toBe(5);
expect(new Set(codes).size).toBe(7);
});
});
@@ -112,6 +124,33 @@ describe('ContractTemplatesService bulk create/resolve', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('creates the Ethiopian-customs-only variant alongside the full-customs one', async () => {
const { service } = build();
const created = await service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'IMPORT',
withCustoms: true,
ethiopianCustomsOnly: true,
});
expect(created.code).toBe('BULK_IMPORT_STEEL_ETHIOPIAN_CUSTOMS');
expect(created.ethiopianCustomsOnly).toBe(true);
expect(created.documentTitle).toBe(
'Steel Transportation and Ethiopian Customs Clearance Services',
);
});
it('rejects Ethiopian-customs-only without customs clearing', async () => {
const { service } = build();
await expect(
service.create({
cargoTypeId: 'cargo-1',
tradeDirection: 'IMPORT',
withCustoms: false,
ethiopianCustomsOnly: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
it('resolves a domestic bulk contract to the intercity template, ignoring its customs flag', async () => {
const { repository, service } = build();
await service.findActiveForContract('DOMESTIC', 'BULK', true, 'cargo-1');
@@ -119,6 +158,7 @@ describe('ContractTemplatesService bulk create/resolve', () => {
'cargo-1',
'INTERCITY',
null,
false,
);
});
@@ -129,6 +169,18 @@ describe('ContractTemplatesService bulk create/resolve', () => {
'cargo-1',
'IMPORT',
false,
false,
);
});
it('resolves an Ethiopian-customs-only contract to the Ethiopian variant', async () => {
const { repository, service } = build();
await service.findActiveForContract('IMPORT', 'BULK', true, 'cargo-1', true);
expect(repository.findActiveBulkTemplate).toHaveBeenCalledWith(
'cargo-1',
'IMPORT',
true,
true,
);
});
});

View File

@@ -16,6 +16,22 @@ describe('contractTemplateCodeFor', () => {
);
});
it('resolves the Ethiopian variant only when customs clearing is enabled', () => {
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', true, true)).toBe(
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
);
expect(contractTemplateCodeFor('EXPORT', 'BULK', true, true)).toBe(
'EXPORT_BULK_ETHIOPIAN_CUSTOMS',
);
// Without customs clearing the Ethiopian flag is meaningless.
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, true)).toBe(
'IMPORT_CONTAINER_NO_CUSTOMS',
);
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', true, true)).toBe(
'INTERCITY_CONTAINER',
);
});
it('never gives intercity a customs variant — it crosses no border', () => {
for (const flag of [true, false, null, undefined]) {
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
@@ -40,7 +56,11 @@ describe('contractTemplateCodeFor', () => {
for (const d of directions) {
for (const f of freights) {
for (const c of [true, false]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
for (const e of [true, false, undefined]) {
expect(CONTRACT_TEMPLATE_CODES).toContain(
contractTemplateCodeFor(d, f, c, e),
);
}
}
}
}
@@ -48,9 +68,9 @@ describe('contractTemplateCodeFor', () => {
});
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
it('seeds exactly the ten declared codes, once each', () => {
it('seeds exactly the fourteen declared codes, once each', () => {
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
expect(seeded).toHaveLength(10);
expect(seeded).toHaveLength(14);
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
});

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { IsNull, Repository } from "typeorm";
import { Repository } from "typeorm";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import {
@@ -33,10 +33,22 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
cargoTypeId: string,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): Promise<ContractTemplate | null> {
return this.repository.findOne({
where: { cargoTypeId, tradeDirection, withCustoms: withCustoms ?? IsNull() },
});
return this.repository
.createQueryBuilder("t")
.where("t.cargo_type_id = :cargoTypeId", { cargoTypeId })
.andWhere("t.trade_direction = :tradeDirection", { tradeDirection })
.andWhere(
withCustoms === null
? "t.with_customs IS NULL"
: "t.with_customs = :withCustoms",
withCustoms === null ? {} : { withCustoms },
)
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
ethiopianCustomsOnly,
})
.getOne();
}
/**
@@ -49,6 +61,7 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
cargoTypeId: string,
tradeDirection: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): Promise<ContractTemplate | null> {
return this.repository
.createQueryBuilder("t")
@@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository<ContractTemplate
: "t.with_customs = :withCustoms",
withCustoms === null ? {} : { withCustoms },
)
.andWhere("COALESCE(t.ethiopian_customs_only, false) = :ethiopianCustomsOnly", {
ethiopianCustomsOnly,
})
.andWhere(
`(t.cargo_type_id = :cargoTypeId OR t.cargo_type_id = (
SELECT c.parent_group_id FROM freight.cargo_types c

View File

@@ -41,13 +41,17 @@ import {
*/
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
IMPORT_BULK_ETHIOPIAN_CUSTOMS: "IMP_BULK_USD_FORWARDING",
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
EXPORT_BULK_ETHIOPIAN_CUSTOMS: "EXP_BULK_USD_FORWARDING",
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "IMP_CON_USD_FORWARDING",
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
};
@@ -101,7 +105,7 @@ export class ContractTemplatesService {
const direction = dto.tradeDirection;
const intercity = direction === "INTERCITY";
if (intercity && dto.withCustoms !== undefined) {
if (intercity && (dto.withCustoms !== undefined || dto.ethiopianCustomsOnly)) {
throw new BadRequestException(
"Intercity contracts are domestic and cross no border — they have no customs clearing variant",
);
@@ -112,12 +116,24 @@ export class ContractTemplatesService {
);
}
const withCustoms = intercity ? null : Boolean(dto.withCustoms);
const ethiopianOnly = Boolean(dto.ethiopianCustomsOnly) && !intercity;
if (ethiopianOnly && !withCustoms) {
throw new BadRequestException(
"Ethiopian-customs-only is a customs clearing variant — it requires withCustoms to be true",
);
}
const label = this.comboLabel(cargoType.cargoTypeName, direction, withCustoms);
const label = this.comboLabel(
cargoType.cargoTypeName,
direction,
withCustoms,
ethiopianOnly,
);
const existing = await this.repository.findByCargoCombo(
dto.cargoTypeId,
direction,
withCustoms,
ethiopianOnly,
);
if (existing) {
throw new ConflictException(
@@ -126,11 +142,13 @@ export class ContractTemplatesService {
}
const template = new ContractTemplate();
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms);
template.code = bulkTemplateCode(cargoType.code, direction, withCustoms, ethiopianOnly);
template.name = dto.name ?? label;
template.description = dto.description ?? null;
template.documentTitle = withCustoms
? `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
? ethiopianOnly
? `${cargoType.cargoTypeName} Transportation and Ethiopian Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation and Customs Clearance Services`
: `${cargoType.cargoTypeName} Transportation Services`;
template.whereasClauses = [];
template.articles = [];
@@ -138,6 +156,7 @@ export class ContractTemplatesService {
template.cargoTypeId = cargoType.id;
template.tradeDirection = direction;
template.withCustoms = withCustoms;
template.ethiopianCustomsOnly = intercity ? null : ethiopianOnly;
template.isSystem = false;
try {
return await this.repository.saveTemplate(template);
@@ -157,18 +176,21 @@ export class ContractTemplatesService {
cargoTypeName: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): string {
const dir = direction.charAt(0) + direction.slice(1).toLowerCase();
const customs =
withCustoms === null
? ""
: withCustoms
? ", with customs clearing"
? ethiopianCustomsOnly
? ", with Ethiopian customs clearing only"
: ", with customs clearing"
: ", without customs clearing";
return `${cargoTypeName} Bulk Contract (${dir}${customs})`;
}
/** Bulk templates only — the five seeded container templates are permanent. */
/** Bulk templates only — the seeded container templates are permanent. */
async remove(code: string): Promise<void> {
const template = await this.getByCode(code);
if (template.isSystem) {
@@ -193,6 +215,7 @@ export class ContractTemplatesService {
freightType?: string | null,
customsClearingEnabled?: boolean | null,
cargoTypeId?: string | null,
ethiopianCustomsOnly?: boolean | null,
): Promise<ContractTemplate | null> {
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
if (isBulk) {
@@ -202,12 +225,16 @@ export class ContractTemplatesService {
cargoTypeId,
direction,
direction === "INTERCITY" ? null : Boolean(customsClearingEnabled),
direction === "INTERCITY"
? false
: Boolean(customsClearingEnabled && ethiopianCustomsOnly),
);
}
const code = contractTemplateCodeFor(
tradeDirection,
freightType,
customsClearingEnabled,
ethiopianCustomsOnly,
);
const template = await this.repository.findByCode(code);
return template?.isActive ? template : null;

View File

@@ -43,6 +43,14 @@ export class CreateContractTemplateDto {
@IsBoolean()
withCustoms?: boolean;
@ApiPropertyOptional({
description:
"Restricts the with-customs variant to Ethiopian-side clearing only (Djibouti stays with the Client). Requires withCustoms=true; rejected for INTERCITY",
})
@IsOptional()
@IsBoolean()
ethiopianCustomsOnly?: boolean;
@ApiPropertyOptional({ description: "Display name (derived from the cargo type when omitted)" })
@IsOptional()
@IsString()

View File

@@ -4,9 +4,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
/**
* The five seeded container templates (import/export split by customs
* clearing; intercity is domestic, crosses no border, so it has a single
* template). These are system rows: always present, never deletable.
* The seeded container templates (import/export split by customs-clearing
* option — full, Ethiopian-only, none; intercity is domestic, crosses no
* border, so it has a single template). These are system rows: always
* present, never deletable.
*
* Bulk templates are NOT seeded — staff create them per bulk cargo type
* (`cargoTypeId`), trade direction (`tradeDirection`) and customs option
@@ -20,18 +21,24 @@ import { CargoType } from "../../rule-engine/entities/cargo-type.entity";
*
* The `_CUSTOMS` variant is issued when the contract has customs clearing
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* behalf); `_ETHIOPIAN_CUSTOMS` when the service type is Ethiopian-customs-only
* (the Service Provider clears the Ethiopian side only, Djibouti stays with the
* Client); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
* its own declarations.
*/
export const CONTRACT_TEMPLATE_CODES = [
"IMPORT_BULK_CUSTOMS",
"IMPORT_BULK_ETHIOPIAN_CUSTOMS",
"IMPORT_BULK_NO_CUSTOMS",
"EXPORT_BULK_CUSTOMS",
"EXPORT_BULK_ETHIOPIAN_CUSTOMS",
"EXPORT_BULK_NO_CUSTOMS",
"INTERCITY_BULK",
"IMPORT_CONTAINER_CUSTOMS",
"IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"IMPORT_CONTAINER_NO_CUSTOMS",
"EXPORT_CONTAINER_CUSTOMS",
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
"EXPORT_CONTAINER_NO_CUSTOMS",
"INTERCITY_CONTAINER",
] as const;
@@ -66,6 +73,7 @@ export function contractTemplateCodeFor(
tradeDirection?: string | null,
freightType?: string | null,
customsClearingEnabled?: boolean | null,
ethiopianCustomsOnly?: boolean | null,
): ContractTemplateCode {
const direction =
tradeDirection === "IMPORT"
@@ -78,7 +86,11 @@ export function contractTemplateCodeFor(
if (direction === "INTERCITY") {
return `INTERCITY_${freight}` as ContractTemplateCode;
}
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
const customs = customsClearingEnabled
? ethiopianCustomsOnly
? "ETHIOPIAN_CUSTOMS"
: "CUSTOMS"
: "NO_CUSTOMS";
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
}
@@ -107,9 +119,16 @@ export function bulkTemplateCode(
cargoCode: string,
direction: BulkTemplateDirection,
withCustoms: boolean | null,
ethiopianCustomsOnly = false,
): string {
const suffix =
direction === "INTERCITY" ? "" : withCustoms ? "_CUSTOMS" : "_NO_CUSTOMS";
direction === "INTERCITY"
? ""
: withCustoms
? ethiopianCustomsOnly
? "_ETHIOPIAN_CUSTOMS"
: "_CUSTOMS"
: "_NO_CUSTOMS";
return `BULK_${direction}_${cargoCode}${suffix}`.toUpperCase();
}
@@ -162,7 +181,15 @@ export class ContractTemplate extends BaseEntity {
@Column({ name: "with_customs", type: "boolean", nullable: true })
withCustoms?: boolean | null;
/** The five seeded container templates — cannot be deleted. */
/**
* Bulk templates only: the with-customs variant restricted to Ethiopian-side
* clearing (Djibouti stays with the Client). Only meaningful when
* `withCustoms` is true; null/false otherwise.
*/
@Column({ name: "ethiopian_customs_only", type: "boolean", nullable: true })
ethiopianCustomsOnly?: boolean | null;
/** The seeded container templates — cannot be deleted. */
@Column({ name: "is_system", type: "boolean", default: false })
isSystem!: boolean;
}

View File

@@ -11,6 +11,7 @@ import {
import { DataSource } from 'typeorm';
import { OnEvent } from '@nestjs/event-emitter';
import { insertWithGeneratedReference } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
@@ -273,6 +275,8 @@ export class ContractBookingService {
});
}
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
// Denormalize route/direction/freight onto the booking for the scheduling engine.
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
@@ -306,8 +310,7 @@ export class ContractBookingService {
cargoFreeText: dto.cargoFreeText?.trim() || null,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...bulkFields,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
@@ -853,6 +856,35 @@ export class ContractBookingService {
if (!dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Without-customs import/export: the customer's own clearing agent (name,
// email, phone) is captured per booking at completion. A resubmit may omit
// the fields and keep what the booking already stored. Customs contracts
// (GL clears) and intercity (no border) never collect an agent.
if (
!contract.customsClearingEnabled &&
contract.tradeDirection !== 'DOMESTIC'
) {
const agentName =
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
const agentEmail =
dto.customsClearingAgentEmail?.trim() ||
booking.customsClearingAgentEmail ||
null;
const agentPhone =
dto.customsClearingAgentPhone?.trim() ||
booking.customsClearingAgentPhone ||
null;
if (!agentName || !agentEmail || !agentPhone) {
throw new BadRequestException(
'Customs clearing agent name, email and phone are required to complete this booking.',
);
}
await this.bookingsRepository.update(booking.id, {
customsClearingAgent: agentName,
customsClearingAgentEmail: agentEmail,
customsClearingAgentPhone: agentPhone,
} as never);
}
// No expiry gate here on purpose: this booking was already initiated
// before the contract lapsed (createUnderContract/initiateUnderContract
// already checked expiry at start). Finishing an in-flight booking must
@@ -955,8 +987,7 @@ export class ContractBookingService {
await this.bookingsRepository.update(booking.id, {
cargoTypeId: this.resolveCargoTypeId(contract, dto),
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...(await this.resolveBulkCargoFields(contract, dto)),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
// Completion is where the cargo — and therefore the price — is fixed, so
// it is also where the billing currency is chosen. A bare instance was
@@ -1533,8 +1564,10 @@ export class ContractBookingService {
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
@@ -1938,6 +1971,107 @@ export class ContractBookingService {
return tons > 0 ? tons : null;
}
/**
* Bulk cargo columns for the booking row, resolved against the commodity's
* unit of measure:
*
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
* `bulkTotalWeightTons` (legacy behaviour).
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
* the wagon count (customer on the portal, GL in the backoffice). The
* count is validated so each wagon's even share (tons ÷ wagons) fits what
* one wagon of this cargo may carry; the optional item count is stored as
* information only and never prices or sizes anything.
*
* Container contracts (and payloads without bulk lines) pass through with
* the legacy zero/null values.
*/
private async resolveBulkCargoFields(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<{
cargoTotalWeightVgm: number;
bulkTotalWeightTons: number | null;
bulkRequestedWagons: number | null;
bulkItemCount: number | null;
}> {
const legacy = {
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
bulkRequestedWagons: null as number | null,
bulkItemCount: null as number | null,
};
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
return legacy;
}
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
if (!cargoTypeId) return legacy;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { id: cargoTypeId },
relations: { wagonTypes: true },
});
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
return legacy;
}
const tons = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
0,
);
const items = dto.bulkLines.reduce(
(sum, l) => sum + Number(l.itemCount ?? 0),
0,
);
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
if (!(wagons >= 1)) {
throw new BadRequestException(
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
);
}
if (!(tons > 0)) {
throw new BadRequestException('Cargo weight in tons is required.');
}
this.assertWagonShareFits(cargoType, tons, wagons);
return {
cargoTotalWeightVgm: tons,
bulkTotalWeightTons: null,
bulkRequestedWagons: wagons,
bulkItemCount: items > 0 ? Math.floor(items) : null,
};
}
/**
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
* configured skip the check (allocation falls back to the default rating).
*/
private assertWagonShareFits(
cargoType: CargoType,
tons: number,
wagons: number,
): void {
const allowed = (cargoType.wagonTypes ?? []).filter(
(wt) => Number(wt.capacityTons) > 0,
);
if (!allowed.length) return;
const maxPerWagon = Math.max(
...allowed.map((wt) =>
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
),
);
const share = tons / wagons;
if (share > maxPerWagon) {
throw new BadRequestException(
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
);
}
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
@@ -2261,8 +2395,7 @@ export class ContractBookingService {
contractRouteId: route?.id ?? null,
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
...(await this.resolveBulkCargoFields(contract, dto)),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>

View File

@@ -428,6 +428,8 @@ export class ContractTransitionService {
contract.customsClearingEnabled,
// Bulk templates are keyed by the contract's cargo type.
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
contract.serviceType?.includesEthiopianCustomsOnly,
);
if (!active) return null;
return {

View File

@@ -4,6 +4,7 @@ import {
IsArray,
IsBoolean,
IsDateString,
IsEmail,
IsIn,
IsInt,
IsNumber,
@@ -11,6 +12,7 @@ import {
IsString,
IsUUID,
Matches,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
@Type(() => CreateBulkLineDto)
bulkLines?: CreateBulkLineDto[];
@ApiPropertyOptional({
minimum: 1,
description:
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
'Required when the cargo type is measured by wagons, ignored otherwise.',
})
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
requestedWagons?: number;
@ApiPropertyOptional({
description: 'What the containers carry — captured per booking (container freight).',
})
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
@IsString()
cargoFreeText?: string;
@ApiPropertyOptional({
maxLength: 200,
description:
'Customs clearing agent name. Required at completion of a without-customs ' +
'import/export booking (the service enforces it); ignored on customs contracts.',
})
@IsOptional()
@IsString()
@MaxLength(200)
customsClearingAgent?: string;
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
@IsOptional()
@IsEmail()
@MaxLength(200)
customsClearingAgentEmail?: string;
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
@IsOptional()
@IsString()
@MaxLength(50)
customsClearingAgentPhone?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()

View File

@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
resolveBulkTons: () => 0,
resolveBulkWeightTons: () => 0,
resolveBulkCargoFields: async () => ({
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
bulkRequestedWagons: null,
bulkItemCount: null,
}),
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
handlingCounts: () => ({
hazardousQuantity: 0,

View File

@@ -1,7 +1,12 @@
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
/**
* How the bulk commodity a rate is scoped to is counted
* (cargo_types.unit_of_measure). NUMBER_OF_WAGONS cargo is weighed in tons and
* offers the same PER_TON / PER_WAGON units as PER_TON cargo — only the
* booking form (which also asks for a wagon count) treats it differently.
*/
export type CargoUom = 'PER_TON' | 'PER_ITEM' | 'NUMBER_OF_WAGONS' | null | undefined;
/**
* Units billed against a booking's bulk quantity. That quantity is recorded in

View File

@@ -21,6 +21,19 @@ export const TRAIN_SCHEDULE_STATUSES = [
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
/** One clicked loading or unloading window at a yard (ISO timestamps). */
export interface StationWorkPhaseLog {
startedAt?: string | null;
endedAt?: string | null;
startedByUserId?: string | null;
endedByUserId?: string | null;
}
export interface StationWorkLog {
loading?: StationWorkPhaseLog;
unloading?: StationWorkPhaseLog;
}
@Entity({ schema: 'freight', name: 'train_schedules' })
@Index(['scheduledDepartureDate'])
@Index(['status'])
@@ -157,6 +170,15 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'planned_wagon_real_cuts', type: 'jsonb', nullable: true })
plannedWagonRealCuts?: string[] | null;
/**
* Per-station loading/unloading time windows, clicked by yard operators:
* `{ [yardId]: { loading?: {...}, unloading?: {...} } }`. Booking load/unload
* is gated on the matching window having been STARTED at that yard; end is
* informational (elapsed time reporting). ISO strings, editable after the fact.
*/
@Column({ name: 'station_work_logs', type: 'jsonb', nullable: true })
stationWorkLogs?: Record<string, StationWorkLog> | null;
/** OPEN = accepting/holding bookings; FULL = train filled; CLOSED = manually closed. Orthogonal to `status`. */
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;

View File

@@ -241,6 +241,45 @@ describe('BookingBatchService — PAID reconcile', () => {
).not.toHaveBeenCalled();
});
it('ensurePaidBookingAllocated never re-places a MANUAL_ONLY booking (removed from a train by staff)', async () => {
dataSource.getRepository().findOne.mockResolvedValue({
...paidBooking,
trainScheduleId: null,
schedulingStatus: 'MANUAL_ONLY',
} as unknown as Booking);
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
expect(dataSource.getRepository().update).not.toHaveBeenCalled();
});
it('ensurePaidBookingAllocated skips a MANUAL_ONLY booking even when still pinned to a schedule', async () => {
dataSource.getRepository().findOne.mockResolvedValue({
...paidBooking,
schedulingStatus: 'MANUAL_ONLY',
} as unknown as Booking);
await service.ensurePaidBookingAllocated(bookingId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled();
});
it('reconcilePaidUnlinked leaves MANUAL_ONLY bookings alone', async () => {
bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([
{ ...paidBooking, schedulingStatus: 'MANUAL_ONLY' },
]);
await service.reconcilePaidUnlinked(scheduleId);
expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled();
expect(
trainSchedulingService.previewPaidBookingWagonShortage,
).not.toHaveBeenCalled();
});
it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => {
const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0);
const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined);

View File

@@ -76,7 +76,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -577,6 +577,11 @@ export class BookingBatchService implements OnModuleInit {
// train 30s after being cancelled. Never resurrect a dead booking.
if (["CANCELLED", "EXPIRED", "REJECTED", "COMPLETED"].includes(booking.status))
return;
// Staff removed this booking from a train (dispatch left-behind / manual
// unassign) — every auto-allocation rescue below must leave it alone, or
// the next document review / sweep silently retakes the space it was
// pulled from. Only a manual staff assignment may re-place it.
if (booking.schedulingStatus === "MANUAL_ONLY") return;
if (!booking.trainScheduleId) {
// A paid booking with no train is money taken and nothing boarding. The
// hold was expired before the payment landed (webhook lag beat the
@@ -1553,6 +1558,8 @@ export class BookingBatchService implements OnModuleInit {
for (const booking of unlinked) {
// Held on purpose (paid, no wagon free) — the cron must not undo it.
if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue;
// Removed from a train by staff — manual re-assignment only.
if (booking.schedulingStatus === "MANUAL_ONLY") continue;
if (await this.holdIfWagonShort(scheduleId, booking)) continue;
await this.allocate(scheduleId, booking, "paid");
this.logger.log(
@@ -2884,7 +2891,8 @@ export class BookingBatchService implements OnModuleInit {
.map((o) => ({
...o,
free: c.stock?.availableFor([o.wagonTypeId], leg) ?? 0,
takePerWagon: bulkTonsPerWagon(
takePerWagon: bulkTonsPerWagonFor(
booking,
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,
@@ -4722,7 +4730,8 @@ export class BookingBatchService implements OnModuleInit {
const cargoTons = bookingCargoTons(booking);
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon), so divide by the cap where one is configured for this type.
const tonsPerWagon = bulkTonsPerWagon(
const tonsPerWagon = bulkTonsPerWagonFor(
booking,
booking.cargoType,
booking.cargoType?.wagonTypes?.[0]?.id,
capacityTons,
@@ -4798,7 +4807,8 @@ export class BookingBatchService implements OnModuleInit {
const wagonTypeId = o.wagonTypeId as string;
// Each type sized on its OWN per-wagon tonnage cap, not just its rating
// — a type capped lower swallows less per wagon.
const tonsPerWagon = bulkTonsPerWagon(
const tonsPerWagon = bulkTonsPerWagonFor(
booking,
booking.cargoType,
wagonTypeId,
o.dims.capacityTons,
@@ -5210,7 +5220,8 @@ export class BookingBatchService implements OnModuleInit {
.map((o) => ({
...o,
free: stock.availableFor([o.wagonTypeId], leg),
takePerWagon: bulkTonsPerWagon(
takePerWagon: bulkTonsPerWagonFor(
booking,
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,

View File

@@ -18,7 +18,11 @@ import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import {
StationWorkLog,
StationWorkPhaseLog,
TrainSchedule,
} from '../train-schedules/entities/train-schedule.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -77,6 +81,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
this.assertStationWorkStarted(schedule, booking.originYardId, 'loading');
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
@@ -166,6 +171,7 @@ export class BookingJourneyService {
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
// Intercity has no clearance/delivery tail — unloading completes it. Import/
@@ -294,6 +300,9 @@ export class BookingJourneyService {
// dispatch — assertTrainAtYard allows origin loading in that state, so
// the UI position must agree or origin Load buttons grey out wrongly.
trainAtYardId: latest?.yardId ?? schedule.originStationId,
// Per-yard loading/unloading time windows — the UI derives its
// start/end buttons and the load/unload gating from these.
stationWorkLogs: schedule.stationWorkLogs ?? {},
yards: [...byYard.values()],
};
}
@@ -414,6 +423,62 @@ export class BookingJourneyService {
return rows.map((r) => r.id);
}
/**
* Record a station's loading/unloading time-window click (or edit it — an
* explicit `at` on an already-set edge overwrites the timestamp under the
* same permission that set it). Rules: end needs start, start ≤ end, no
* future times. Stored as ISO strings in train_schedules.station_work_logs.
* ponytail: read-modify-write on the jsonb — two operators clicking the same
* schedule in the same instant can clobber one edge; move to jsonb_set if
* that ever bites.
*/
async recordStationWork(
scheduleId: string,
yardId: string,
phase: 'loading' | 'unloading',
edge: 'start' | 'end',
at?: string,
userId?: string | null,
) {
const schedule = await this.getSchedule(scheduleId);
const when = at ? new Date(at) : new Date();
if (Number.isNaN(when.getTime())) {
throw new BadRequestException('Invalid timestamp');
}
if (when.getTime() > Date.now() + 60_000) {
throw new BadRequestException(`${phase} ${edge} time cannot be in the future`);
}
const logs: Record<string, StationWorkLog> = schedule.stationWorkLogs ?? {};
const entry: StationWorkLog = logs[yardId] ?? {};
const ph: StationWorkPhaseLog = entry[phase] ?? {};
if (edge === 'end') {
if (!ph.startedAt) {
throw new BadRequestException(`Start ${phase} at this station first`);
}
if (when.getTime() < new Date(ph.startedAt).getTime()) {
throw new BadRequestException(`${phase} end cannot be before its start`);
}
ph.endedAt = when.toISOString();
ph.endedByUserId = userId ?? null;
} else {
if (ph.endedAt && when.getTime() > new Date(ph.endedAt).getTime()) {
throw new BadRequestException(`${phase} start cannot be after its end`);
}
ph.startedAt = when.toISOString();
ph.startedByUserId = userId ?? null;
}
entry[phase] = ph;
logs[yardId] = entry;
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { stationWorkLogs: logs });
return { scheduleId, yardId, phase, ...ph };
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
@@ -481,6 +546,27 @@ export class BookingJourneyService {
}
}
/**
* Loading/unloading a booking is only allowed inside a started work window
* at that yard — the operator must click "Start loading"/"Start unloading"
* (recordStationWork) before touching cargo. The window's END is not checked:
* a straggler booking can still be confirmed after the end click, and the
* operator can push the end time later (it's editable) if that matters.
* Lives here (not the controller) so the checkpoint-driven autoUnloadAtYard
* path is gated too — the user wants unloading fully manual.
*/
private assertStationWorkStarted(
schedule: TrainSchedule,
yardId: string,
phase: 'loading' | 'unloading',
): void {
if (!schedule.stationWorkLogs?.[yardId]?.[phase]?.startedAt) {
throw new BadRequestException(
`Start ${phase} at this station first — the ${phase} time window has not been started`,
);
}
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has

View File

@@ -18,14 +18,19 @@ import {
TrainSchedulingCreate,
TrainSchedulingEditTrainNumber,
TrainSchedulingLoad,
TrainSchedulingLoadingEnd,
TrainSchedulingLoadingStart,
TrainSchedulingReschedule,
TrainSchedulingUnload,
TrainSchedulingUnloadingEnd,
TrainSchedulingUnloadingStart,
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../../common/booking-guards";
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
import { StationWorkDto } from "../dto/station-work.dto";
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
@@ -614,6 +619,68 @@ export class TrainSchedulingController {
return this.bookingJourneyService.listYardWork(id);
}
@Post("schedules/:id/stations/:yardId/loading/start")
@TrainSchedulingLoadingStart()
@ApiOperation({
summary:
"Start (or correct, via `at`) this station's loading time window — required before bookings can be loaded there",
})
startStationLoading(
@Param("id", ParseUUIDPipe) id: string,
@Param("yardId", ParseUUIDPipe) yardId: string,
@Body() dto: StationWorkDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.recordStationWork(
id, yardId, "loading", "start", dto.at, resolveAuthUserId(user),
);
}
@Post("schedules/:id/stations/:yardId/loading/end")
@TrainSchedulingLoadingEnd()
@ApiOperation({ summary: "End (or correct, via `at`) this station's loading time window" })
endStationLoading(
@Param("id", ParseUUIDPipe) id: string,
@Param("yardId", ParseUUIDPipe) yardId: string,
@Body() dto: StationWorkDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.recordStationWork(
id, yardId, "loading", "end", dto.at, resolveAuthUserId(user),
);
}
@Post("schedules/:id/stations/:yardId/unloading/start")
@TrainSchedulingUnloadingStart()
@ApiOperation({
summary:
"Start (or correct, via `at`) this station's unloading time window — required before bookings can be unloaded there",
})
startStationUnloading(
@Param("id", ParseUUIDPipe) id: string,
@Param("yardId", ParseUUIDPipe) yardId: string,
@Body() dto: StationWorkDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.recordStationWork(
id, yardId, "unloading", "start", dto.at, resolveAuthUserId(user),
);
}
@Post("schedules/:id/stations/:yardId/unloading/end")
@TrainSchedulingUnloadingEnd()
@ApiOperation({ summary: "End (or correct, via `at`) this station's unloading time window" })
endStationUnloading(
@Param("id", ParseUUIDPipe) id: string,
@Param("yardId", ParseUUIDPipe) yardId: string,
@Body() dto: StationWorkDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.recordStationWork(
id, yardId, "unloading", "end", dto.at, resolveAuthUserId(user),
);
}
@Post("schedules/:id/bookings/:bookingId/load")
@TrainSchedulingLoad()
@ApiOperation({

View File

@@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsISO8601, IsOptional } from 'class-validator';
/**
* A station loading/unloading window click. `at` omitted = "now" (the button
* click); `at` given = record or correct the timestamp after the fact — same
* endpoint, same permission.
*/
export class StationWorkDto {
@ApiPropertyOptional({ description: 'ISO timestamp; omitted = now. Never in the future.' })
@IsOptional()
@IsISO8601()
at?: string;
}

View File

@@ -170,7 +170,7 @@ describe('TrainSchedulingService', () => {
wagonAllocationContainerItemsRepository as never,
wagonAllocationBulkLoadsRepository as never,
trainCheckpointEventsRepository as never,
{} as never, // trainCompositionRemovalLogRepository
{ create: jest.fn() } as never, // trainCompositionRemovalLogRepository
{
autoUnloadArrivedBookings: jest.fn(),
autoUnloadExportAtDjibouti: jest.fn(),
@@ -182,7 +182,7 @@ describe('TrainSchedulingService', () => {
{
autoArriveAtFinalYard: jest.fn().mockResolvedValue([]),
} as never, // bookingJourneyService
{ dispatched: jest.fn(), arrived: jest.fn() } as never, // bookingNotifier
{ dispatched: jest.fn(), arrived: jest.fn(), removedFromTrain: jest.fn() } as never, // bookingNotifier
{ getLogoImageUrl: jest.fn().mockResolvedValue(null) } as never, // logoSettings
);
@@ -1134,6 +1134,107 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)');
});
it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => {
const loadList = {
generatedAt: '2026-07-17T08:00:00.000Z',
trainScheduleId: 'schedule-1',
trainNumber: '7002',
route: 'DCT/SGTD → GMP',
origin: 'DCT/SGTD',
destination: 'GMP',
totalBookings: 2,
wagons: [
{
sequenceNo: 1,
wagonNumber: 'W-ICY',
boardYard: 'Dire Dawa Port',
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'ICY-001' }],
},
],
},
{
sequenceNo: 2,
wagonNumber: 'W-IMP',
boardYard: null,
alightYard: null,
allocations: [
{
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
},
],
},
],
operation: { status: {} },
};
const html = (service as never as {
buildImportLoadListHtml: (l: unknown) => string;
}).buildImportLoadListHtml(loadList);
expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT');
// Departure station of the leg slot is its board yard, not the origin.
expect(html).toContain('<td>Dire Dawa Port</td>');
// Only the origin-loaded container counts; the leg slot's tallies separately.
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => {
const sizedAllocation = {
...loadedAllocation,
containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }],
};
const legWagon = { ...makeWagon(2, 'W-LEG', [sizedAllocation]), id: 'slot-leg' };
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [sizedAllocation]), id: 'slot-1' }, legWagon] },
scheduleBookings: [],
};
const html = (service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]),
});
expect(html).toContain('TO LOAD AT DIRE DAWA PORT');
expect(html).toContain('<span>Total containers</span><strong>1</strong>');
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
direction: 'EXPORT',
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }] },
scheduleBookings: [],
};
const build = (service as never as {
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
}).buildExportLoadListHtml.bind(service);
const withChanges = build(schedule, {
consistChangesAtStop: [
{ action: 'ADD', wagonNumber: 'W-1002' },
{ action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
],
});
expect(withChanges).toContain('Consist changed at this stop');
expect(withChanges).toContain('Coupled: W-1002');
expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
const withoutChanges = build(schedule, {});
expect(withoutChanges).not.toContain('Consist changed at this stop');
});
it('lists loaded empty containers by number and states they are empty', () => {
const schedule = {
id: 'schedule-1',
@@ -1264,6 +1365,25 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('2 (1 empty)');
});
it('keeps whole-route cargo whose allocation never left PLANNED (import flow) on board', () => {
// The import flow confirms loading at schedule level and never flips the
// allocation to LOADED — the cargo is still on the train until DEPARTED.
const schedule = {
trainSet: {
wagons: [
{ ...makeWagon(1, 'W-IMP', [allocWith({ status: 'PLANNED' })]), status: 'RESERVED' },
{ ...makeWagon(2, 'W-ICY', [allocWith({ status: 'LOADED', bookingId: 'booking-2' })]), status: 'RESERVED', boardYardId: 'yard-mid' },
],
},
scheduleBookings: [],
};
const { wagons } = onBoardView(schedule);
const byNumber = wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: unknown[] }>;
expect(byNumber.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-IMP', 'W-ICY']);
expect(byNumber[0].allocations).toHaveLength(1);
});
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
@@ -1640,6 +1760,85 @@ describe('TrainSchedulingService', () => {
});
});
describe('unassignBooking — MANUAL_ONLY status', () => {
const scheduleId = 'sched-rm-1';
const removed = makeBooking('bk-rm', 'BKG-RM', 100, 5, '20FT', 5, undefined, undefined, undefined, {
status: 'PAID',
wagonsRequired: 5,
});
const graph = {
id: scheduleId,
status: 'DRAFT',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
trainSetId: 'ts-rm',
trainSet: {
id: 'ts-rm',
locomotive,
trainId: null,
wagons: [{ id: 'tsw-rm-1', allocations: [{ id: 'alloc-rm-1', bookingId: 'bk-rm' }] }],
},
scheduleBookings: [{ bookingId: 'bk-rm' }],
};
const txManager = {
getRepository: jest.fn(() => ({
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
save: jest.fn().mockResolvedValue(undefined),
create: jest.fn((x: unknown) => x),
})),
};
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(graph);
bookingsRepository.findById = jest.fn().mockResolvedValue(removed);
bookingsRepository.updateSchedulingFields.mockResolvedValue(undefined);
dataSource.transaction.mockImplementation(
async (fn: (m: unknown) => Promise<void>) => fn(txManager),
);
jest
.spyOn(
service as never as { getTrainScheduleById: (id: string) => Promise<unknown> },
'getTrainScheduleById' as never,
)
.mockResolvedValue({ id: scheduleId } as never);
});
it('marks a staff-removed paid booking MANUAL_ONLY and fully detaches it', async () => {
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
expect(bookingsRepository.updateSchedulingFields).toHaveBeenCalledWith(
'bk-rm',
expect.objectContaining({
schedulingStatus: 'MANUAL_ONLY',
trainScheduleId: null,
wagonsRequired: null,
}),
expect.anything(),
);
expect(trainScheduleBookingsRepository.deleteByScheduleAndBooking).toHaveBeenCalledWith(
scheduleId,
'bk-rm',
expect.anything(),
);
expect(wagonAllocationContainerItemsRepository.deleteByAllocationIds).toHaveBeenCalledWith(
['alloc-rm-1'],
expect.anything(),
);
});
it('never marks ELIGIBLE — a removed booking must not rejoin the auto pool', async () => {
await service.unassignBooking(scheduleId, 'bk-rm', 'user-1');
const updates = bookingsRepository.updateSchedulingFields.mock.calls.map((c) => c[1]);
expect(updates.some((u) => u.schedulingStatus === 'ELIGIBLE')).toBe(false);
});
});
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {

View File

@@ -55,7 +55,10 @@ import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import {
TrainSchedule,
type StationWorkPhaseLog,
} from '../../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
@@ -156,7 +159,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
consistViolations,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
@@ -2415,7 +2418,12 @@ export class TrainSchedulingService {
);
const booking = await this.bookingsRepository.findById(bookingId);
const schedulingStatus = this.resolvePostUnassignStatus(booking);
// Removed from a train by staff → MANUAL_ONLY: the paid booking must not
// be auto re-placed by any allocation sweep (it would retake the space it
// was just pulled from). Staff re-assign it manually; assign resets the
// status to SCHEDULED. Schedule *cancellation* keeps the old behaviour
// (resolvePostUnassignStatus) — there the train died, not the booking.
const schedulingStatus = SchedulingStatus.ManualOnly;
// Clear the schedule pointer too: unassign fully detaches the booking from
// this train. Leaving trainScheduleId set glued the booking to a schedule
// that may then be dispatched/cancelled/deleted, orphaning it — the
@@ -2894,6 +2902,31 @@ export class TrainSchedulingService {
schedule = reloaded;
}
}
// Loading is tracked per station: dispatching with cargo still to board at
// the origin marks it loaded (checklist + auto-load below), so the origin's
// loading time window must have been started first — same gate the
// per-booking load endpoint enforces.
const originBoarders = await this.unloadedOriginBoarderIds(
scheduleId,
schedule.originStationId,
);
const boardersToLoad = dto.loadedBookingIds
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
: originBoarders;
const originLoadingLog =
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
throw new BadRequestException(
'Start loading at the origin station before dispatching with cargo to load',
);
}
// A train never departs mid-loading: once the origin's loading window was
// opened (or there is cargo to load), it must be ENDED before dispatch.
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
throw new BadRequestException(
'End the loading window at the origin station before dispatching',
);
}
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
@@ -2939,7 +2972,7 @@ export class TrainSchedulingService {
actualDepartureAt: now,
trainNumber,
// Freeze the wagon plan the moment the train leaves the editable phase.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Dispatched,
now,
@@ -3374,6 +3407,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,
@@ -3400,6 +3438,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,
@@ -3440,7 +3480,19 @@ 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).
const slotYardLabels = await this.yardLabelsById(
(schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId),
);
const pendingBoardYardLabelBySlot = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.boardYardId)
.map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']),
);
const html = this.buildExportLoadListHtml(schedule, {
pendingBoardYardLabelBySlot,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
@@ -3458,8 +3510,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.
*/
@@ -3475,7 +3530,9 @@ export class TrainSchedulingService {
})
.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(
@@ -3510,6 +3567,16 @@ export class TrainSchedulingService {
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
// Couples/switches logged AT THIS STOP — what staff standing here actually
// just did to the consist. Bare trims (REMOVE, no replacement) are left
// out: nothing new to point staff at for those. Origin adjustments (a
// different yard) don't show up on this stop's document.
const consistChangesAtStop = last
? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) },
order: { occurredAt: 'DESC' },
})
: [];
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
@@ -3517,6 +3584,7 @@ export class TrainSchedulingService {
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3565,6 +3633,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?: {
@@ -3574,6 +3651,13 @@ 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>;
// Intercity (Marshalling 2) only: couples/switches logged at the stop
// this document is printed at (see ScheduleWagonAdjustmentLog). Origin
// import/export docs never pass this, so they render no such box.
consistChangesAtStop?: ScheduleWagonAdjustmentLog[];
},
): string {
const esc = (value: unknown) =>
@@ -3637,6 +3721,7 @@ export class TrainSchedulingService {
</tr>`,
];
}
const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
return allocations.map((allocation) => {
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
@@ -3648,7 +3733,7 @@ export class TrainSchedulingService {
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
return `<tr>
${wagonCells}
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()}` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
<td>${esc(companyName)}</td>
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
<td>${esc(chassisNumbers)}</td>
@@ -3685,18 +3770,27 @@ export class TrainSchedulingService {
(wagon.allocations ?? []).length === 0 &&
!emptiesByWagon.get(Number(wagon.sequenceNo))?.length,
).length;
const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id);
const totalWeight = wagons.reduce(
(sum, wagon) =>
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
sum +
(loadsHere(wagon)
? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 0),
0,
);
// 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.
let count40ft = 0, count20ft = 0;
// Cargo boarding downstream is not on this train yet — it tallies separately.
let count40ft = 0, count20ft = 0, pendingContainers = 0;
wagons.forEach((wagon) => {
(wagon.allocations ?? []).forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
@@ -3763,6 +3857,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
${emptyContainers.length ? `<div class="tile"><span>Empty containers</span><strong>${esc(emptyContainers.length)}</strong></div>` : ''}
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
@@ -3773,6 +3868,24 @@ export class TrainSchedulingService {
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
</div>
${
opts?.consistChangesAtStop?.length
? `<div class="notice">
<b>Consist changed at this stop:</b>
${(() => {
const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD');
const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH');
return [
coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '',
switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '',
]
.filter(Boolean)
.join(' &nbsp;|&nbsp; ');
})()}
</div>`
: ''
}
<table>
<thead>
<tr>
@@ -3936,19 +4049,30 @@ export class TrainSchedulingService {
.replace(/'/g, '&#39;');
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
const status = loadList.operation.status;
// A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the
// physical train this Djibouti-side document is checked against, so it must
// stay out of the loaded tallies or the gate count stops matching.
const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard;
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),
sum +
(loadsHere(wagon)
? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0)
: 0),
0,
);
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
// Container count summary (40ft, 20ft)
let count40ft = 0, count20ft = 0;
// Container count summary (40ft, 20ft) — loaded at origin vs. en route
let count40ft = 0, count20ft = 0, pendingContainers = 0;
loadList.wagons.forEach((wagon) => {
wagon.allocations.forEach((allocation) => {
(allocation.containerItems ?? []).forEach((item) => {
if (!loadsHere(wagon)) {
pendingContainers++;
return;
}
const size = this.resolveContainerItemSize(item);
if (size === 40) count40ft++;
else if (size === 20) count20ft++;
@@ -3963,8 +4087,8 @@ export class TrainSchedulingService {
<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) {
@@ -3989,7 +4113,7 @@ export class TrainSchedulingService {
<td>${esc(allocation.loadType)}</td>
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
<td>${esc(sealNumbers || '-')}</td>
<td></td>
<td>${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''}</td>
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
</tr>`;
},
@@ -4062,6 +4186,7 @@ export class TrainSchedulingService {
<div class="tile"><span>Containers 40ft</span><strong>${esc(count40ft)}</strong></div>
<div class="tile"><span>Containers 20ft</span><strong>${esc(count20ft)}</strong></div>
<div class="tile"><span>Total containers</span><strong>${esc(count40ft + count20ft)}</strong></div>
${pendingContainers ? `<div class="tile"><span>To load en route</span><strong>${esc(pendingContainers)} containers</strong></div>` : ''}
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
</div>
@@ -4406,6 +4531,48 @@ export class TrainSchedulingService {
}
/** Track payload for a schedule: ordered stations, logged checkpoints, current position. */
/** Attach `startedByName` / `endedByName` to each work-window phase (one iam lookup). */
private async stationWorkLogsWithNames(
logs: TrainSchedule['stationWorkLogs'],
): Promise<Record<string, unknown>> {
const workLogs = logs ?? {};
const userIds = [
...new Set(
Object.values(workLogs)
.flatMap((log) => [
log.loading?.startedByUserId,
log.loading?.endedByUserId,
log.unloading?.startedByUserId,
log.unloading?.endedByUserId,
])
.filter((id): id is string => Boolean(id)),
),
];
const rows: Array<{ id: string; name: string | null }> = userIds.length
? await this.dataSource.query(
`SELECT id, COALESCE(username, email) AS name FROM iam.users WHERE id = ANY($1::uuid[])`,
[userIds],
)
: [];
const nameById = new Map(rows.map((r) => [r.id, r.name]));
const withNames = (phase?: StationWorkPhaseLog) =>
phase
? {
...phase,
startedByName: phase.startedByUserId
? nameById.get(phase.startedByUserId) ?? null
: null,
endedByName: phase.endedByUserId ? nameById.get(phase.endedByUserId) ?? null : null,
}
: undefined;
return Object.fromEntries(
Object.entries(workLogs).map(([yardId, log]) => [
yardId,
{ loading: withNames(log.loading), unloading: withNames(log.unloading) },
]),
);
}
async getScheduleCheckpoints(scheduleId: string) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -4446,6 +4613,10 @@ export class TrainSchedulingService {
origin: stations[0]?.label ?? null,
destination: stations[stations.length - 1]?.label ?? null,
stations,
// Per-yard loading/unloading time windows for the track page's
// start/end buttons and elapsed-time display — with the recorder's
// display name resolved so staff see WHO started/ended each window.
stationWorkLogs: await this.stationWorkLogsWithNames(schedule.stationWorkLogs),
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
@@ -4920,6 +5091,15 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
// Arrival happens BEFORE unloading: the train is marked arrived whenever
// it physically gets there, and the destination's unloading window opens
// afterwards. The bulk booking sweep (autoArriveAtFinalYard) only runs
// when that window is already open — otherwise final-yard bookings stay
// IN_TRANSIT and are unloaded per booking once staff start unloading
// (the per-booking endpoint enforces the window itself).
const destinationUnloadingStarted = Boolean(
schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt,
);
// The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
@@ -4932,7 +5112,7 @@ export class TrainSchedulingService {
{
actualArrivalAt: now,
// Freeze the plan before the wagons below are released to their yards.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Arrived,
now,
@@ -4960,7 +5140,12 @@ export class TrainSchedulingService {
// operator didn't unload individually get their arrival stamped now as a
// bulk fallback. Mid-corridor bookings are NOT touched — their arrival is
// their own unload (possibly already done while the train kept rolling).
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
// Runs only when the destination's unloading window is already open —
// otherwise arrival precedes unloading and staff unload per booking
// after starting the window.
if (destinationUnloadingStarted) {
await this.bookingJourneyService.autoArriveAtFinalYard(manager, schedule, now);
}
// Release every locomotive of the set (not just the legacy primary) and move it
// to the destination yard where it physically arrived.
@@ -5306,7 +5491,7 @@ export class TrainSchedulingService {
bookingWindowStatus: 'CLOSED',
windowPhase: 'DONE',
// Freeze the plan before the wagons below are released back to the yard.
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
wagonAllocationSnapshot: await this.buildWagonAllocationSnapshot(
schedule,
TrainScheduleStatusEnum.Cancelled,
now,
@@ -6093,11 +6278,11 @@ export class TrainSchedulingService {
* physical wagons, so the historical allocation survives those wagons being
* re-pinned onto later trains. `capturedStatus` is the status being applied.
*/
private buildWagonAllocationSnapshot(
private async buildWagonAllocationSnapshot(
schedule: TrainSchedule,
capturedStatus: TrainScheduleStatusEnum,
capturedAt: Date,
): WagonAllocationSnapshot {
): Promise<WagonAllocationSnapshot> {
const slots = [...(schedule.trainSet?.wagons ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((wagon) => ({
@@ -6121,10 +6306,41 @@ export class TrainSchedulingService {
})),
}));
// A built train hauls EVERY coupled wagon, empties included. After this
// transition the physical wagons are released and re-pinned to later
// trains, so capture the empty consist here — it is the only durable
// record of which empties rode this departure (history + yard tracking).
const coveredPhysicalIds = new Set(
slots.map((slot) => slot.physicalWagonId).filter(Boolean),
);
const trainWagons = schedule.trainSet?.trainId
? await this.dataSource.getRepository(Wagon).find({
where: { trainId: schedule.trainSet.trainId },
relations: { wagonType: true },
order: { sequenceNumber: 'ASC' },
})
: [];
const emptyConsistWagons = trainWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
physicalWagonId: wagon.id,
physicalWagonNumber: wagon.wagonNumber ?? null,
sequenceNo: wagon.sequenceNumber ?? slots.length + index + 1,
wagonTypeId: wagon.wagonTypeId ?? null,
wagonTypeCode: wagon.wagonType?.code ?? null,
wagonTypeName: wagon.wagonType?.name ?? null,
capacityTons: Number(wagon.wagonType?.capacityTons ?? 0),
tareWeightTons: wagon.wagonType
? Number(wagon.wagonType.tareWeightTons)
: null,
lengthMeters: Number(wagon.wagonType?.lengthMeters ?? 0),
}));
return {
capturedStatus,
capturedAt: capturedAt.toISOString(),
slots,
emptyConsistWagons,
};
}
@@ -9430,6 +9646,8 @@ export class TrainSchedulingService {
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'bulkTotalWeightTons'
| 'bulkRequestedWagons'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
@@ -9460,7 +9678,7 @@ export class TrainSchedulingService {
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
// PER_TON cargo may cap tons per wagon below the rating (sugar 50T on a 70T
// wagon) — more wagons for the same cargo, so more tare to pull.
const tonsPerWagon = bulkTonsPerWagon(booking.cargoType, wagonTypeId, dims.capacityTons);
const tonsPerWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wagonTypeId, dims.capacityTons);
const byWeight = cargo > 0 && tonsPerWagon > 0 ? Math.ceil(cargo / tonsPerWagon) : 0;
// Break-bulk (PER_ITEM): indivisible items occupy more wagons than raw
// tonnage suggests — their tare must be pulled too (batch dimsFor parity).
@@ -9592,7 +9810,36 @@ export class TrainSchedulingService {
0,
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
);
const emptyConsistWagons = rawConsistWagons
// Frozen schedules: the live wagon↔train joins no longer describe this
// departure, so the empty consist is read from the snapshot captured at
// dispatch/arrival — that keeps "which wagons ran empty" in the history
// views. Snapshots from before empties were recorded simply have none.
const frozenEmptyConsistWagons = (snapshot?.emptyConsistWagons ?? []).map(
(wagon) => ({
id: wagon.physicalWagonId,
sequenceNo: wagon.sequenceNo,
capacityTons: roundTons(wagon.capacityTons),
lengthMeters: roundTons(wagon.lengthMeters),
assignedWeightTons: 0,
tareWeightTons:
wagon.tareWeightTons != null ? roundTons(wagon.tareWeightTons) : null,
status: 'EMPTY',
boardYardId: null,
alightYardId: null,
physicalWagonId: wagon.physicalWagonId,
physicalWagonNumber: wagon.physicalWagonNumber,
wagonType: wagon.wagonTypeId
? {
id: wagon.wagonTypeId,
code: wagon.wagonTypeCode ?? '',
name: wagon.wagonTypeName ?? '',
}
: null,
allocations: [],
consistOnly: true,
}),
);
const liveEmptyConsistWagons = rawConsistWagons
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
.map((wagon, index) => ({
// Physical wagon id — there is no TrainSetWagon slot behind this
@@ -9625,6 +9872,9 @@ export class TrainSchedulingService {
allocations: [],
consistOnly: true,
}));
const emptyConsistWagons = isWagonAllocationFrozen
? frozenEmptyConsistWagons
: liveEmptyConsistWagons;
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
@@ -9945,6 +10195,10 @@ export class TrainSchedulingService {
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// Per-yard loading/unloading time windows (start/end clicks) — the
// detail page shows the origin's loading window; dispatch requires it
// started when cargo boards there.
stationWorkLogs: schedule.stationWorkLogs ?? {},
// Gross ceiling the validator holds each leg to: the set's weakest
// locomotive pull limit plus its overage tolerance. Booking weightTons
// above are gross too, so the strip can sum them per leg against this.

View File

@@ -5,6 +5,7 @@ import {
bulkItemWagonsForAllowedTypes,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
bulkTonWagonsForAllowedTypes,
bulkTonWagonsRequired,
bulkWagonsForAllowedTypes,
@@ -177,6 +178,19 @@ describe('train-capacity.util', () => {
expect(bulkTonWagonsForAllowedTypes(bulk(200), cargoType, 70)).toBe(3);
});
it('NUMBER_OF_WAGONS: a requested count wins over the tonnage-derived one', () => {
const req = { ...bulk(100), bulkRequestedWagons: 40 };
// 100T on 70T wagons is 2 by tonnage — the customer asked for 40.
expect(bulkTonWagonsRequired(req, null, 'nw5', 70)).toBe(40);
expect(bulkWagonsForAllowedTypes(req, { wagonTypes: [{ id: 'nw5', capacityTons: 70 }] }, 70)).toBe(40);
// Each wagon then carries the even share, not rated capacity.
expect(bulkTonsPerWagonFor(req, null, 'nw5', 70)).toBe(2.5);
// ceil(tons / evenShare) must land exactly on the requested count.
const awkward = { ...bulk(100), bulkRequestedWagons: 3 };
const share = bulkTonsPerWagonFor(awkward, null, 'nw5', 70);
expect(Math.ceil(100 / share)).toBe(3);
});
it('routes PER_ITEM and PER_TON through one call', () => {
expect(bulkWagonsForAllowedTypes(bulk(200), sugar, 70)).toBe(4);
// PER_ITEM still wins where an item count is present.

View File

@@ -114,6 +114,43 @@ export function bookingCargoTons(booking: {
);
}
/**
* Customer-requested wagon count of a NUMBER_OF_WAGONS bulk booking; 0 when
* the booking carries none (every other cargo unit). The request was validated
* against wagon capacity at booking creation, so sizing code honours it
* verbatim instead of deriving a count from tonnage.
*/
export function requestedBulkWagons(booking: {
bulkRequestedWagons?: number | string | null;
}): number {
const n = Math.floor(num(booking.bulkRequestedWagons));
return n > 0 ? n : 0;
}
/**
* Booking-aware {@link bulkTonsPerWagon}: a NUMBER_OF_WAGONS booking fixed its
* wagon count, so each wagon carries tons ÷ requested (the even spread the
* customer asked for), never more. Rounded UP to 3 decimals so
* ceil(tons / perWagon) lands exactly on the requested count instead of one
* over on float error. Other bookings get the cargo-type figure unchanged.
*/
export function bulkTonsPerWagonFor(
booking: Parameters<typeof bookingCargoTons>[0] & {
bulkRequestedWagons?: number | string | null;
},
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
const base = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
const requested = requestedBulkWagons(booking);
if (!requested) return base;
const tons = bookingCargoTons(booking);
if (!(tons > 0)) return base;
const evenShare = Math.ceil((tons / requested) * 1000) / 1000;
return base > 0 ? Math.min(base, evenShare) : evenShare;
}
/**
* Wagons a break-bulk (PER_ITEM) bulk booking needs. Items are indivisible, so
* floor how many whole items fit one wagon, then ceil the wagon count:
@@ -184,11 +221,16 @@ export function bulkTonsPerWagon(
* usable per-wagon figure, so callers can fall back as before.
*/
export function bulkTonWagonsRequired(
booking: Parameters<typeof bookingCargoTons>[0],
booking: Parameters<typeof bookingCargoTons>[0] & {
bulkRequestedWagons?: number | string | null;
},
cargoType: ItemFitCargoType | undefined,
wagonTypeId: string | null | undefined,
capacityTons: number | string | null | undefined,
): number {
// NUMBER_OF_WAGONS: the customer fixed the count — honour it verbatim.
const requested = requestedBulkWagons(booking);
if (requested) return requested;
const perWagon = bulkTonsPerWagon(cargoType, wagonTypeId, capacityTons);
const tons = bookingCargoTons(booking);
if (!(perWagon > 0) || !(tons > 0)) return 0;

View File

@@ -7,7 +7,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
bulkTonWagonsRequired,
consistViolations,
} from '../train-capacity.util';
@@ -193,8 +193,11 @@ export function buildBulkWagonPlan(
// pool with uncapped tonnage either: its wagons stop at the cap, so 200T needs
// 4 wagons and pooling it at 70T would plan 3. Capped bookings are sized on
// their own cap; only genuinely uncapped tonnage pools at rated capacity.
// A NUMBER_OF_WAGONS booking is "capped" at its even share (tons ÷ requested),
// so it plans exactly the requested count.
const cappedTonSlotsByBooking = bookings.map((b, i) =>
itemSlotsByBooking[i] > 0 || bulkTonsPerWagon(b.cargoType, wagonType.id, capacity) >= capacity
itemSlotsByBooking[i] > 0 ||
bulkTonsPerWagonFor(b, b.cargoType, wagonType.id, capacity) >= capacity
? 0
: bulkTonWagonsRequired(b, b.cargoType, wagonType.id, capacity),
);
@@ -330,6 +333,7 @@ function allocateBookingsToSlots(
// bookings that column is an item COUNT, not tons.
remainingWeightTons: roundTons(bookingCargoTons(booking)),
cargoType: booking.cargoType,
booking,
}));
let bookingIndex = 0;
@@ -343,10 +347,17 @@ function allocateBookingsToSlots(
const booking = remaining[bookingIndex];
// A PER_TON loading cap (sugar 50T on a 70T wagon) binds the FILL as well
// as the wagon count — the plan reserved a wagon per capped chunk, so
// pouring rated capacity into it would leave the last wagon empty.
// pouring rated capacity into it would leave the last wagon empty. A
// NUMBER_OF_WAGONS booking fills each wagon its even share (tons ÷
// requested) for the same reason.
const takeCap = Math.min(
wagonRemaining,
bulkTonsPerWagon(booking.cargoType, slot.wagonTypeId, slot.capacityTons),
bulkTonsPerWagonFor(
booking.booking,
booking.cargoType,
slot.wagonTypeId,
slot.capacityTons,
),
);
const allocatedWeightTons = roundTons(
Math.min(takeCap, booking.remainingWeightTons),

View File

@@ -6,6 +6,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
bulkWagonsForAllowedTypes,
} from './train-capacity.util';
import {
@@ -179,7 +180,7 @@ const shortageFor = (
let seatable = 0;
let usedWagons = 0;
for (const { wt, free } of freeByType) {
const perWagon = bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons));
const perWagon = bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons));
if (!(perWagon > 0) || free <= 0) continue;
seatable += free * perWagon;
usedWagons += free;
@@ -188,7 +189,7 @@ const shortageFor = (
const bestPerWagon = Math.max(
1,
...candidates.map((wt) =>
bulkTonsPerWagon(booking.cargoType, wt.id, Number(wt.capacityTons)),
bulkTonsPerWagonFor(booking, booking.cargoType, wt.id, Number(wt.capacityTons)),
),
);
return {
@@ -583,7 +584,8 @@ export function planWagonsWithStock(params: {
if (perItem ? remainingItems <= 0 : remainingWeight <= 0) break;
const wagonType = candidates.find((wt) => wt.id === open.slot.wagonTypeId);
if (!wagonType) continue;
const room = bulkTonsPerWagon(
const room = bulkTonsPerWagonFor(
booking,
booking.cargoType,
open.slot.wagonTypeId,
Number(open.slot.capacityTons),
@@ -640,7 +642,20 @@ export function planWagonsWithStock(params: {
openedSlot.freeItems = itemBudgetOf(openedSlot) - takeItems;
remainingItems -= takeItems;
} else {
take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight));
// NUMBER_OF_WAGONS: each wagon takes the even share (tons / requested),
// not the full per-wagon cap — the loop then opens exactly that count.
take = roundTons(
Math.min(
openedSlot.freeCapacityTons,
bulkTonsPerWagonFor(
booking,
booking.cargoType,
openedSlot.slot.wagonTypeId,
openedSlot.slot.capacityTons,
),
remainingWeight,
),
);
}
addAllocation(
openedSlot.slot,

View File

@@ -0,0 +1,33 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
export class CreateWagonDetachRequestDto {
@ApiProperty({
enum: WagonDetachRequestAction,
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
})
@IsEnum(WagonDetachRequestAction)
action!: WagonDetachRequestAction;
@ApiProperty({
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
maxLength: 500,
})
@IsString()
@IsNotEmpty()
@MaxLength(500)
reason!: string;
}
export class DecideWagonDetachRequestDto {
@ApiPropertyOptional({
description: 'Decision note — required when rejecting, optional when approving.',
maxLength: 500,
})
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,69 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export enum WagonDetachRequestAction {
Detach = 'DETACH',
Maintenance = 'MAINTENANCE',
}
export enum WagonDetachRequestStatus {
Pending = 'PENDING',
Approved = 'APPROVED',
Rejected = 'REJECTED',
}
/**
* Approval gate for detaching a wagon (or sending it to maintenance) from a
* train that is on a SCHEDULED run.
*
* A draft-schedule or unscheduled train is edited freely; once the run is
* SCHEDULED, pulling a wagon out changes a departure customers already booked
* against, so it becomes a two-person action: one staffer requests with a
* reason, another (holding trains:approve_wagon_detach) approves — approval
* executes the detach immediately. Rows are never deleted: decided rows are
* the audit trail of who asked, who decided, and why.
*/
@Entity({ schema: 'freight', name: 'wagon_detach_requests' })
@Index(['trainId'])
@Index(['trainId', 'status'])
export class WagonDetachRequest extends BaseEntity {
@Column({ name: 'train_id', type: 'uuid' })
trainId!: string;
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
/** Snapshot — the audit trail must read correctly if the wagon is renumbered or deleted. */
@Column({ name: 'wagon_number', type: 'varchar', length: 50 })
wagonNumber!: string;
@Column({ name: 'action', type: 'varchar', length: 20 })
action!: WagonDetachRequestAction;
@Column({ name: 'reason', type: 'varchar', length: 500 })
reason!: string;
@Column({
name: 'status',
type: 'enum',
enum: WagonDetachRequestStatus,
enumName: 'wagon_detach_requests_status_enum',
default: WagonDetachRequestStatus.Pending,
})
status!: WagonDetachRequestStatus;
/** IAM user id of the requester. The approver must be a different person. */
@Column({ name: 'requested_by', type: 'uuid', nullable: true })
requestedBy?: string | null;
/** IAM user id of the approver/rejecter; null while pending. */
@Column({ name: 'decided_by', type: 'uuid', nullable: true })
decidedBy?: string | null;
@Column({ name: 'decided_at', type: 'timestamptz', nullable: true })
decidedAt?: Date | null;
/** Required on reject, optional on approve. */
@Column({ name: 'decision_note', type: 'varchar', length: 500, nullable: true })
decisionNote?: string | null;
}

View File

@@ -25,6 +25,10 @@ import { BuildTrainDto } from './dto/build-train.dto';
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
import {
CreateWagonDetachRequestDto,
DecideWagonDetachRequestDto,
} from './dto/wagon-detach-request.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
@@ -47,6 +51,7 @@ import { TrainBuilderService } from './train-builder.service';
FREIGHT_PERMS.trains.changeWagonYard,
FREIGHT_PERMS.trains.toggleActive,
FREIGHT_PERMS.trains.disband,
FREIGHT_PERMS.trains.approveWagonDetach,
])
export class TrainBuilderController {
constructor(private readonly trainBuilderService: TrainBuilderService) {}
@@ -206,6 +211,74 @@ export class TrainBuilderController {
);
}
@Get(':id/detach-requests')
@ApiOperation({
summary:
'Detach/maintenance approval requests of this train, newest first — pending and decided alike (the audit trail)',
})
detachRequests(@Param('id', ParseUUIDPipe) id: string) {
return this.trainBuilderService.listDetachRequests(id);
}
@Post(':id/wagons/:wagonId/detach-requests')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({
summary:
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
})
createDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@Body() dto: CreateWagonDetachRequestDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.createDetachRequest(
id,
wagonId,
dto,
resolveAuthUserId(user),
);
}
@Post(':id/detach-requests/:requestId/approve')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({
summary:
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
})
approveDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto?: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'APPROVE',
resolveAuthUserId(user),
dto?.note,
);
}
@Post(':id/detach-requests/:requestId/reject')
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
rejectDetachRequest(
@Param('id', ParseUUIDPipe) id: string,
@Param('requestId', ParseUUIDPipe) requestId: string,
@CurrentUser() user: AuthUserPayload,
@Body() dto: DecideWagonDetachRequestDto,
) {
return this.trainBuilderService.decideDetachRequest(
id,
requestId,
'REJECT',
resolveAuthUserId(user),
dto.note,
);
}
@Post(':id/reorder-wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })

View File

@@ -30,8 +30,14 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import {
WagonDetachRequest,
WagonDetachRequestAction,
WagonDetachRequestStatus,
} from './entities/wagon-detach-request.entity';
import {
buildPaginationMeta,
normalizePagination,
@@ -751,32 +757,43 @@ export class TrainBuilderService {
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string, userId?: string | null) {
const pending = await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
importTrainNumber: null,
exportTrainNumber: null,
});
await this.resequenceWagons(manager, train.id);
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
);
await this.assertDetachNeedsNoApproval(manager, id);
return this.removeWagonCore(manager, id, wagonId, userId);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Transactional body of removeWagon — also runs under an approved detach request. */
private async removeWagonCore(
manager: EntityManager,
id: string,
wagonId: string,
userId?: string | null,
): Promise<PendingWindowCheck | null> {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
await this.assertDetachableAndReleaseStaleSlots(manager, wagon);
await manager.getRepository(Wagon).update(wagon.id, {
trainId: null,
sequenceNumber: null,
status: WagonStatus.Available,
importTrainNumber: null,
exportTrainNumber: null,
});
await this.resequenceWagons(manager, train.id);
return this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
);
}
/**
* Detach one wagon AND flag it for maintenance: it leaves the consist and
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
@@ -789,6 +806,22 @@ export class TrainBuilderService {
note?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
await this.assertDetachNeedsNoApproval(manager, id);
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
}
/** Transactional body of sendWagonToMaintenance — also runs under an approved request. */
private async sendWagonToMaintenanceCore(
manager: EntityManager,
id: string,
wagonId: string,
userId?: string | null,
note?: string | null,
): Promise<PendingWindowCheck | null> {
{
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
@@ -851,6 +884,191 @@ export class TrainBuilderService {
userId ?? null,
yardId,
);
}
}
/**
* Direct-detach guard: while this train carries a live SCHEDULED run,
* removing a wagon changes a departure customers already booked against, so
* it is a two-person action — refuse here and point at the request flow.
* DRAFT stays freely editable; DISPATCHED is already frozen by
* getEditableTrain (the train is IN_SERVICE).
*/
private async assertDetachNeedsNoApproval(
manager: EntityManager,
trainId: string,
): Promise<void> {
const scheduled = await this.findScheduledRun(manager, trainId);
if (scheduled) {
throw new ConflictException(
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
);
}
}
private async findScheduledRun(
manager: EntityManager,
trainId: string,
): Promise<{ id: string; reference: string | null } | null> {
const rows: { id: string; reference: string | null }[] = await manager.query(
`SELECT ts.id, ts.reference
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.status = 'SCHEDULED'
AND ts.deleted_at IS NULL
LIMIT 1`,
[trainId],
);
return rows[0] ?? null;
}
/**
* File a detach/maintenance approval request for a wagon on a SCHEDULED
* train. The request carries the reason; a different staffer with
* trains:approve_wagon_detach decides it (approval executes the detach).
*/
async createDetachRequest(
id: string,
wagonId: string,
dto: CreateWagonDetachRequestDto,
userId?: string | null,
) {
return this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
if (!wagon || wagon.trainId !== train.id) {
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
}
const scheduled = await this.findScheduledRun(manager, train.id);
if (!scheduled) {
throw new ConflictException(
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
);
}
// Refuse up front what an approval could never execute (booked
// allocations pin the wagon) — but release nothing yet: slots are only
// touched when the approved detach actually runs.
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
const repo = manager.getRepository(WagonDetachRequest);
const open = await repo.findOne({
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
});
if (open) {
throw new ConflictException(
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
);
}
return repo.save(
repo.create({
trainId: train.id,
wagonId: wagon.id,
wagonNumber: wagon.wagonNumber,
action: dto.action,
reason: dto.reason.trim(),
requestedBy: userId ?? null,
}),
);
});
}
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
async listDetachRequests(trainId: string) {
const rows: Array<{
id: string;
wagonId: string;
wagonNumber: string;
action: string;
reason: string;
status: string;
requestedById: string | null;
requestedBy: string | null;
requestedAt: Date;
decidedBy: string | null;
decidedAt: Date | null;
decisionNote: string | null;
}> = await this.dataSource.query(
`SELECT r.id,
r.wagon_id AS "wagonId",
r.wagon_number AS "wagonNumber",
r.action,
r.reason,
r.status,
r.requested_by AS "requestedById",
COALESCE(ru.username, ru.email) AS "requestedBy",
r.created_at AS "requestedAt",
COALESCE(du.username, du.email) AS "decidedBy",
r.decided_at AS "decidedAt",
r.decision_note AS "decisionNote"
FROM freight.wagon_detach_requests r
LEFT JOIN iam.users ru ON ru.id = r.requested_by
LEFT JOIN iam.users du ON du.id = r.decided_by
WHERE r.train_id = $1
AND r.deleted_at IS NULL
ORDER BY r.created_at DESC
LIMIT 100`,
[trainId],
);
return rows;
}
/**
* Decide a pending request. Approve executes the detach (or maintenance
* move) in the same transaction that stamps the decision, so an approved row
* can never exist without its detach having happened. The requester cannot
* approve their own request; a rejection must carry a note.
*/
async decideDetachRequest(
id: string,
requestId: string,
decision: 'APPROVE' | 'REJECT',
userId?: string | null,
note?: string | null,
) {
const pending = await this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(WagonDetachRequest);
const request = await repo.findOne({
where: { id: requestId, trainId: id },
lock: { mode: 'pessimistic_write' },
});
if (!request) {
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
}
if (request.status !== WagonDetachRequestStatus.Pending) {
throw new ConflictException(
`This request was already ${request.status.toLowerCase()}`,
);
}
const decisionNote = note?.trim() || null;
if (decision === 'REJECT') {
if (!decisionNote) {
throw new BadRequestException('A note explaining the rejection is required');
}
await repo.update(request.id, {
status: WagonDetachRequestStatus.Rejected,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return null;
}
// The 4-eyes point of the gate: requester and approver are different people.
if (request.requestedBy && userId && request.requestedBy === userId) {
throw new ConflictException(
'You filed this request — a different staff member must approve it',
);
}
const pendingCheck =
request.action === WagonDetachRequestAction.Maintenance
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
: await this.removeWagonCore(manager, id, request.wagonId, userId);
await repo.update(request.id, {
status: WagonDetachRequestStatus.Approved,
decidedBy: userId ?? null,
decidedAt: new Date(),
decisionNote,
});
return pendingCheck;
});
await this.reconcileWindowAfterConsistChange(pending);
return this.getComposition(id);
@@ -893,6 +1111,7 @@ export class TrainBuilderService {
private async assertDetachableAndReleaseStaleSlots(
manager: EntityManager,
wagon: Wagon,
opts: { checkOnly?: boolean } = {},
): Promise<void> {
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
await manager.query(
@@ -915,6 +1134,7 @@ export class TrainBuilderService {
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
);
}
if (opts.checkOnly) return;
await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id));
for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) {
const remaining = await manager.getRepository(TrainSetWagon).find({

View File

@@ -4,13 +4,17 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { WagonDetachRequest } from './entities/wagon-detach-request.entity';
import { TrainBuilderController } from './train-builder.controller';
import { TrainBuilderService } from './train-builder.service';
import { TrainsController } from './trains.controller';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule],
imports: [
TypeOrmModule.forFeature([Train, TrainLocomotive, WagonDetachRequest]),
TrainSchedulingModule,
],
controllers: [TrainsController, TrainBuilderController],
providers: [TrainsService, TrainBuilderService],
exports: [TrainsService, TrainBuilderService],