Merge pull request #1414 from Tria-plc/freight_feature/usermanagement

feat: enhance train scheduling and contract management features
This commit is contained in:
marshal
2026-08-26 00:46:17 +03:00
committed by GitHub
67 changed files with 2998 additions and 255 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
bookingGrossWeightTons,
deriveTrainCapacityFromLocomotive,
sizePartialOfferWagons,
@@ -2884,7 +2884,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 +4723,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 +4800,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 +5213,8 @@ export class BookingBatchService implements OnModuleInit {
.map((o) => ({
...o,
free: stock.availableFor([o.wagonTypeId], leg),
takePerWagon: bulkTonsPerWagon(
takePerWagon: bulkTonsPerWagonFor(
booking,
booking.cargoType,
o.wagonTypeId,
o.dims.capacityTons,

View File

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

View File

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

View File

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

View File

@@ -156,7 +156,7 @@ import {
bookingCargoTons,
bulkItemsFitFor,
bulkItemWagonsRequired,
bulkTonsPerWagon,
bulkTonsPerWagonFor,
consistViolations,
deriveTrainCapacityFromLocomotive,
combinedLocomotiveLimits,
@@ -2894,6 +2894,25 @@ 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;
if (
boardersToLoad.length &&
!schedule.stationWorkLogs?.[schedule.originStationId]?.loading?.startedAt
) {
throw new BadRequestException(
'Start loading at the origin station before dispatching with cargo to load',
);
}
// 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');
@@ -4446,6 +4465,9 @@ 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.
stationWorkLogs: schedule.stationWorkLogs ?? {},
currentSequenceNo,
checkpoints: events.map((e) => ({
id: e.id,
@@ -4920,6 +4942,23 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
throw new BadRequestException('Only DISPATCHED trains can arrive');
}
// Arrival bulk-marks every booking destined for the final yard as arrived
// (autoArriveAtFinalYard) — unloading is tracked per station, so the
// destination's unloading time window must be started before that sweep
// may run. Skipped when nothing on the train alights at the final yard.
const alightsAtFinal = (schedule.scheduleBookings ?? []).some(
(sb) =>
sb.booking?.destinationYardId === schedule.destinationStationId &&
sb.booking?.status === 'IN_TRANSIT',
);
if (
alightsAtFinal &&
!schedule.stationWorkLogs?.[schedule.destinationStationId]?.unloading?.startedAt
) {
throw new BadRequestException(
'Start unloading at the destination station before marking the train arrived',
);
}
// The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
@@ -9430,6 +9469,8 @@ export class TrainSchedulingService {
Booking,
| 'freightType'
| 'cargoTotalWeightVgm'
| 'bulkTotalWeightTons'
| 'bulkRequestedWagons'
| 'wagonsRequired'
| 'bookingContainers'
| 'cargoType'
@@ -9460,7 +9501,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).
@@ -9945,6 +9986,10 @@ export class TrainSchedulingService {
// Ordered corridor stops (route milestones; falls back to the two
// endpoints) — lets the UI draw per-segment occupancy and label legs.
stops: this.mapScheduleStops(schedule),
// Per-yard loading/unloading time windows (start/end clicks) — the
// detail page shows the origin's loading window; dispatch requires it
// started when cargo boards there.
stationWorkLogs: schedule.stationWorkLogs ?? {},
// Gross ceiling the validator holds each leg to: the set's weakest
// locomotive pull limit plus its overage tolerance. Booking weightTons
// above are gross too, so the strip can sum them per leg against this.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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