mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
bulkItemWagonsRequired,
|
||||
bulkTonsPerWagon,
|
||||
bulkTonsPerWagonFor,
|
||||
consistViolations,
|
||||
deriveTrainCapacityFromLocomotive,
|
||||
combinedLocomotiveLimits,
|
||||
@@ -2884,6 +2884,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');
|
||||
@@ -4436,6 +4455,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,
|
||||
@@ -4852,6 +4874,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.
|
||||
@@ -9362,6 +9401,8 @@ export class TrainSchedulingService {
|
||||
Booking,
|
||||
| 'freightType'
|
||||
| 'cargoTotalWeightVgm'
|
||||
| 'bulkTotalWeightTons'
|
||||
| 'bulkRequestedWagons'
|
||||
| 'wagonsRequired'
|
||||
| 'bookingContainers'
|
||||
| 'cargoType'
|
||||
@@ -9392,7 +9433,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).
|
||||
@@ -9877,6 +9918,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