mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(' | ');
|
||||
})()}
|
||||
</div>`
|
||||
: ''
|
||||
}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -3936,19 +4049,30 @@ export class TrainSchedulingService {
|
||||
.replace(/'/g, ''');
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user