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:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -95,6 +95,24 @@ export const TrainSchedulingLoad = () =>
export const TrainSchedulingUnload = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unload);
/**
* Per-station loading/unloading time windows — the four buttons are four
* permissions so start and end can be granted to different people. The same
* endpoint that records a click also edits it (explicit `at`), so each
* permission covers editing its own timestamp too.
*/
export const TrainSchedulingLoadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingStart);
export const TrainSchedulingLoadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.loadingEnd);
export const TrainSchedulingUnloadingStart = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingStart);
export const TrainSchedulingUnloadingEnd = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.unloadingEnd);
export const TrainSchedulingCancel = () =>
BookingStaff(FREIGHT_PERMS.trainScheduling.cancel);

View File

@@ -122,6 +122,8 @@ export class ContractDocumentViewModelBuilder {
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,
);
dynamicTemplate = dynamicSource
? {

View File

@@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Adds the customs clearing agent's contact details to freight.bookings.
*
* The agent moved from the contract to the booking: on a without-customs
* service the customer now names their agent (name, email, phone) when
* completing each booking, instead of once at contract creation. The existing
* `customs_clearing_agent` column keeps the name; these two columns add the
* contact info. Nullable — customs-bundled and legacy bookings have none.
*/
export class BookingClearingAgentContact3710000000000 implements MigrationInterface {
name = 'BookingClearingAgentContact3710000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS customs_clearing_agent_email varchar(200),
ADD COLUMN IF NOT EXISTS customs_clearing_agent_phone varchar(50)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customs_clearing_agent_email,
DROP COLUMN IF EXISTS customs_clearing_agent_phone
`);
}
}

View File

@@ -0,0 +1,24 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-station loading/unloading time windows on a schedule, operator-clicked:
* { [yardId]: { loading?: { startedAt, endedAt, startedByUserId, endedByUserId },
* unloading?: { same } } }
* Booking load/unload is gated on the matching window having been started.
*/
export class ScheduleStationWorkLogs3720000000000 implements MigrationInterface {
name = 'ScheduleStationWorkLogs3720000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS station_work_logs jsonb
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs
`);
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Approval gate for detaching a wagon (or sending it to maintenance) from a
* train whose run is already SCHEDULED.
*
* Before scheduling, the consist is the builder's to edit. After scheduling,
* pulling a wagon changes a departure customers booked against, so it becomes
* a two-person action: one staffer files a request with a reason, another
* staffer (with trains:approve_wagon_detach) approves it — approval executes
* the detach on the spot. Rows are never deleted; decided rows are the audit
* trail of who asked, who decided, and why.
*
* One PENDING row per (train, wagon) at a time — a second request while one is
* undecided is a coordination failure, not a workflow (partial unique index).
*/
export class WagonDetachRequests3730000000000 implements MigrationInterface {
name = 'WagonDetachRequests3730000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$ BEGIN
CREATE TYPE freight.wagon_detach_requests_status_enum
AS ENUM ('PENDING', 'APPROVED', 'REJECTED');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_detach_requests (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
train_id uuid NOT NULL REFERENCES freight.trains (id),
wagon_id uuid NOT NULL REFERENCES freight.wagons (id),
-- Snapshot: the audit trail must still read correctly after the wagon
-- is renumbered or deleted.
wagon_number varchar(50) NOT NULL,
action varchar(20) NOT NULL,
reason varchar(500) NOT NULL,
status freight.wagon_detach_requests_status_enum NOT NULL DEFAULT 'PENDING',
-- Who asked and who decided. Both recorded: the point of the gate is
-- that they are different people.
requested_by uuid,
decided_by uuid,
decided_at timestamptz,
decision_note varchar(500),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train
ON freight.wagon_detach_requests (train_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_detach_requests_train_status
ON freight.wagon_detach_requests (train_id, status)
`);
// The workflow invariant, enforced where it cannot race: at most one
// undecided request per wagon per train.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_wagon_detach_requests_one_pending
ON freight.wagon_detach_requests (train_id, wagon_id)
WHERE status = 'PENDING' AND deleted_at IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`);
await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* NUMBER_OF_WAGONS cargo unit: the customer books a wagon COUNT alongside the
* bulk weight. `bulk_requested_wagons` drives allocation and PER_WAGON pricing;
* `bulk_item_count` is the optional informational item count entered with it.
* Nullable — every other cargo unit leaves both empty.
*/
export class BookingBulkRequestedWagons3740000000000 implements MigrationInterface {
name = 'BookingBulkRequestedWagons3740000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS bulk_requested_wagons int,
ADD COLUMN IF NOT EXISTS bulk_item_count int
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS bulk_requested_wagons,
DROP COLUMN IF EXISTS bulk_item_count
`);
}
}

View File

@@ -0,0 +1,118 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
/**
* Third customs-clearing option on contract templates: Ethiopian-customs-only
* (the Service Provider clears the Ethiopian side only, Djibouti stays with
* the Client), matching service types with includes_ethiopian_customs_only.
*
* - ethiopian_customs_only column on contract_templates (bulk variant flag;
* the seeded container variants carry it in the code suffix instead, like
* the existing _CUSTOMS/_NO_CUSTOMS pair).
* - The bulk unique index and intercity check widen to the new flag.
* - Seeds the two new system container templates from the defaults pack.
*/
const SEEDED_CODES = [
'IMPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
'EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS',
] as const;
export class EthiopianCustomsContractTemplates3750000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD COLUMN IF NOT EXISTS ethiopian_customs_only boolean
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction,
COALESCE(with_customs, false), COALESCE(ethiopian_customs_only, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
AND (ethiopian_customs_only IS NOT TRUE OR with_customs IS TRUE)
)
)
`);
for (const code of SEEDED_CODES) {
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
if (!seed) throw new Error(`Missing contract template default for ${code}`);
await queryRunner.query(
`INSERT INTO freight.contract_templates
(id, code, name, description, document_title, whereas_clauses, articles,
is_active, is_system, created_at, updated_at)
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
true, true, now(), now()
WHERE NOT EXISTS (
SELECT 1 FROM freight.contract_templates
WHERE code = $1::varchar AND deleted_at IS NULL
)`,
[
seed.code,
seed.name,
seed.description,
seed.documentTitle,
JSON.stringify(seed.whereasClauses),
JSON.stringify(
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
),
],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.contract_templates WHERE code = ANY($1) AND is_system = true`,
[[...SEEDED_CODES]],
);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP CONSTRAINT IF EXISTS ck_bulk_intercity_no_customs
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
ADD CONSTRAINT ck_bulk_intercity_no_customs CHECK (
cargo_type_id IS NULL
OR (
trade_direction IN ('IMPORT', 'EXPORT', 'INTERCITY')
AND (trade_direction = 'INTERCITY') = (with_customs IS NULL)
)
)
`);
await queryRunner.query(
`DROP INDEX IF EXISTS freight.uq_contract_templates_cargo_dir_customs`,
);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS uq_contract_templates_cargo_dir_customs
ON freight.contract_templates
(cargo_type_id, trade_direction, COALESCE(with_customs, false))
WHERE deleted_at IS NULL AND cargo_type_id IS NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.contract_templates
DROP COLUMN IF EXISTS ethiopian_customs_only
`);
}
}

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,
@@ -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.

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],

View File

@@ -4,7 +4,7 @@ import type {
} from "../../modules/contract-templates/entities/contract-template.entity";
/**
* Default article packs for the ten contract templates, transcribed from the
* Default article packs for the fourteen contract templates, transcribed from the
* signed EDR contract documents (test/contrat_docs). Article bodies use the
* dynamic-article text format: one clause per line, "- " prefix for bullets
* nested under the previous clause, single-line body = plain paragraph.
@@ -23,7 +23,8 @@ export interface ContractTemplateSeed {
/**
* A base pack keyed by direction/freight only. Each one is transcribed from a
* signed EDR contract and is split at the bottom of this file into the
* `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores.
* `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio the template table
* actually stores.
*/
type ContractTemplateBase = Omit<ContractTemplateSeed, "code">;
@@ -883,7 +884,35 @@ Settle assessed duties and taxes within the period notified by the Service Provi
),
];
/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */
/**
* Articles appended to the `_ETHIOPIAN_CUSTOMS` variant: the Service Provider
* clears the Ethiopian side only, while Djibouti clearing stays with the
* Client. Article ids match the full-customs pack so downstream checks treat
* both as customs-clearing variants.
*/
const ETHIOPIAN_CUSTOMS_ARTICLES: Array<Omit<ContractTemplateArticle, "order">> = [
a(
"customs-clearing",
"Ethiopian Customs Clearing Services",
`The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement at the customs stations of Ethiopia only, including declaration, lodgement, and follow-up as applicable to the agreed corridor.
Customs clearing at Djibouti is not included in this Agreement and remains the sole responsibility of the Client.
The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction.
Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance.
The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`,
),
a(
"customs-client-duties",
"Client Obligations for Ethiopian Customs Clearing",
`Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent in Ethiopia for the duration of this Agreement.
Complete customs clearing at Djibouti and deliver the cargo customs-cleared on the Djibouti side, together with the supporting release documents, in time for the scheduled railway loading.
Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request.
Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate.
Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation, or from delayed Djibouti-side clearing.
Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`,
),
];
/** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */
function splitByCustoms(
base: ContractTemplateBase,
codeStem: string,
@@ -896,6 +925,14 @@ function splitByCustoms(
description: `${base.description} Customs clearing is performed by the Service Provider.`,
articles: [...base.articles, ...CUSTOMS_ARTICLES],
},
{
...base,
code: `${codeStem}_ETHIOPIAN_CUSTOMS` as ContractTemplateCode,
name: `${base.name} (Ethiopian customs clearing only)`,
description: `${base.description} Only Ethiopian customs clearing is performed by the Service Provider; Djibouti clearing is handled by the Client.`,
documentTitle: `${base.documentTitle} (Ethiopian Customs Clearing Only)`,
articles: [...base.articles, ...ETHIOPIAN_CUSTOMS_ARTICLES],
},
{
...base,
code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode,
@@ -907,7 +944,8 @@ function splitByCustoms(
}
/**
* Ten templates: import and export each split by customs clearing, intercity
* Fourteen templates: import and export each split by customs clearing option
* (full, Ethiopian-only, none), intercity
* not split at all — it is a domestic Ethiopian movement that crosses no
* border, so there is no customs leg to contract for.
*/

View File

@@ -1012,6 +1012,14 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:trains:change_wagon_yard",
"Change yard of a coupled wagon",
),
// Supervisor-only: NOT part of FLEET_GRANULAR_KEYS — detach requests are
// filed under trains:assign_wagons, but deciding them is a separate grant so
// the requester and approver are different people.
perm(
"e1c00001-0001-4000-8000-000000000011",
"edr_freight_app:trains:approve_wagon_detach",
"Approve wagon detach/maintenance requests",
),
perm(
"e1d00001-0001-4000-8000-000000000001",
"edr_freight_app:routes:view",
@@ -1483,6 +1491,30 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:train_scheduling:unload",
"Confirm cargo unloaded (import, export, intercity)",
),
// Per-station loading/unloading time windows: the four buttons are separate
// permissions so start and end can be granted to different people. Booking
// load/unload additionally requires the matching window to have been started
// at that yard.
perm(
"a2a00001-0001-4000-8000-000000000008",
"edr_freight_app:train_scheduling:loading_start",
"Start a station's loading window",
),
perm(
"a2a00001-0001-4000-8000-000000000009",
"edr_freight_app:train_scheduling:loading_end",
"End a station's loading window",
),
perm(
"a2a00001-0001-4000-8000-000000000010",
"edr_freight_app:train_scheduling:unloading_start",
"Start a station's unloading window",
),
perm(
"a2a00001-0001-4000-8000-000000000011",
"edr_freight_app:train_scheduling:unloading_end",
"End a station's unloading window",
),
];
// L. Administration & settings (split from the coarse admin umbrella)
@@ -2019,6 +2051,11 @@ export const FREIGHT_PERMS = {
*/
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
// Per-station loading/unloading time-window buttons (start/end pairs).
loadingStart: "edr_freight_app:train_scheduling:loading_start",
loadingEnd: "edr_freight_app:train_scheduling:loading_end",
unloadingStart: "edr_freight_app:train_scheduling:unloading_start",
unloadingEnd: "edr_freight_app:train_scheduling:unloading_end",
dispatch: "edr_freight_app:train_scheduling:dispatch",
markPaid: "edr_freight_app:train_scheduling:mark_paid",
expireBooking: "edr_freight_app:train_scheduling:expire_booking",
@@ -2185,6 +2222,8 @@ export const FREIGHT_PERMS = {
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
/** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */
approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach",
},
routes: {
view: "edr_freight_app:routes:view",
@@ -2627,6 +2666,10 @@ export const ROLE_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.loadingStart,
FREIGHT_PERMS.trainScheduling.loadingEnd,
FREIGHT_PERMS.trainScheduling.unloadingStart,
FREIGHT_PERMS.trainScheduling.unloadingEnd,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,
@@ -2849,6 +2892,10 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.update,
FREIGHT_PERMS.trainScheduling.load,
FREIGHT_PERMS.trainScheduling.unload,
FREIGHT_PERMS.trainScheduling.loadingStart,
FREIGHT_PERMS.trainScheduling.loadingEnd,
FREIGHT_PERMS.trainScheduling.unloadingStart,
FREIGHT_PERMS.trainScheduling.unloadingEnd,
FREIGHT_PERMS.trainScheduling.cancel,
FREIGHT_PERMS.trainScheduling.reschedule,
FREIGHT_PERMS.trainScheduling.rulesManage,

View File

@@ -128,14 +128,27 @@ export function BookingRouteServiceCard({
background: "#F8FAFC",
}}
>
<Group gap={10} align="center">
<FileText size={15} color="#64748B" />
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
<Group gap={10} align="flex-start" wrap="nowrap">
<FileText size={15} color="#64748B" style={{ marginTop: 2 }} />
<Stack gap={2}>
<Text fz={13} fw={500} c="#374151">
Customs clearing agent:{" "}
<Text component="span" fw={700} c="#10202F">
{booking.customsClearingAgent}
</Text>
</Text>
</Text>
{(booking.customsClearingAgentEmail ||
booking.customsClearingAgentPhone) && (
<Text fz={12.5} c="#64748B">
{[
booking.customsClearingAgentEmail,
booking.customsClearingAgentPhone,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
</Stack>
</Group>
</Box>
) : null}

View File

@@ -130,6 +130,7 @@ interface LineErrors {
interface BulkErrors {
quantity?: string;
wagons?: string;
hazardous?: string;
reefer?: string;
}
@@ -175,6 +176,8 @@ interface ContainerLineDraft {
interface BulkDraft {
cargoWeightTons: string;
itemCount: string;
/** NUMBER_OF_WAGONS cargo only: wagons this shipment needs. */
requestedWagons: string;
hazardousQuantity: string;
reeferQuantity: string;
}
@@ -203,7 +206,19 @@ function emptyLine(size: string): ContainerLineDraft {
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" {
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
// The cargo type's own configured unit wins; the pricing-line sniff below is
// the legacy fallback for contracts loaded without the cargoScope relation.
const configured = contract.cargoScope?.find(
(scope) => scope.cargoType?.unitOfMeasure,
)?.cargoType?.unitOfMeasure;
if (
configured === "PER_TON" ||
configured === "PER_ITEM" ||
configured === "NUMBER_OF_WAGONS"
) {
return configured;
}
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
(li) => li.unit === "per_item",
);
@@ -322,6 +337,7 @@ export default function GlCreateBookingForm() {
const [bulk, setBulk] = useState<BulkDraft>({
cargoWeightTons: "",
itemCount: "",
requestedWagons: "",
hazardousQuantity: "0",
reeferQuantity: "0",
});
@@ -521,16 +537,18 @@ export default function GlCreateBookingForm() {
})),
);
} else if (lines.bulk) {
setBulk({
setBulk((b) => ({
cargoWeightTons:
lines.bulk.cargoWeightTons != null
? String(lines.bulk.cargoWeightTons)
lines.bulk!.cargoWeightTons != null
? String(lines.bulk!.cargoWeightTons)
: "",
itemCount:
lines.bulk.itemCount != null ? String(lines.bulk.itemCount) : "",
hazardousQuantity: String(lines.bulk.hazardousQuantity ?? 0),
lines.bulk!.itemCount != null ? String(lines.bulk!.itemCount) : "",
// The request never carries a wagon count — GL enters it here.
requestedWagons: b.requestedWagons,
hazardousQuantity: String(lines.bulk!.hazardousQuantity ?? 0),
reeferQuantity: "0",
});
}));
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
@@ -618,6 +636,7 @@ export default function GlCreateBookingForm() {
returnQuantity: Number(l.returnQuantity || 0),
})),
bulkQuantity: Number(bulk.cargoWeightTons || bulk.itemCount || 0),
bulkRequestedWagons: Number(bulk.requestedWagons || 0),
bulkHazardousQuantity: Number(bulk.hazardousQuantity || 0),
bulkReeferQuantity: Number(bulk.reeferQuantity || 0),
}),
@@ -979,6 +998,12 @@ export default function GlCreateBookingForm() {
if (Number.isNaN(qty) || qty <= 0) {
errs.quantity = "Enter a quantity greater than 0.";
}
if (bulkUom === "NUMBER_OF_WAGONS") {
const wagons = Number(bulk.requestedWagons || 0);
if (!Number.isInteger(wagons) || wagons < 1) {
errs.wagons = "Enter the number of wagons needed (at least 1).";
}
}
const h = Number(bulk.hazardousQuantity || 0);
if (Number.isNaN(h) || h < 0) {
errs.hazardous = "Enter a valid hazardous quantity.";
@@ -1017,7 +1042,10 @@ export default function GlCreateBookingForm() {
line.every((e) => !e.containerNumber && !e.vgmTons),
) &&
!cargoDescriptionError
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
: !bulkErrors.quantity &&
!bulkErrors.wagons &&
!bulkErrors.hazardous &&
!bulkErrors.reefer;
// COMPLETION never blocks on an odd 20ft total: a customs instance can share
// the wagon via the manual pair (consolidationActive), and anything else is
@@ -1152,6 +1180,9 @@ export default function GlCreateBookingForm() {
reeferQuantity: Number(bulk.reeferQuantity || 0) || undefined,
},
];
if (bulkUom === "NUMBER_OF_WAGONS" && bulk.requestedWagons !== "") {
payload.requestedWagons = Number(bulk.requestedWagons);
}
}
return payload;
@@ -2049,6 +2080,27 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{bulkUom === "NUMBER_OF_WAGONS" && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Number of wagons needed"
placeholder="e.g. 40"
description="The cargo weight is spread evenly across these wagons; a per-wagon rate bills this count."
min={1}
step={1}
value={bulk.requestedWagons}
error={showErrors ? bulkErrors.wagons : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
requestedWagons: e.currentTarget.value,
}))
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isHazardous && (
<TextInput
type="number"

View File

@@ -28,6 +28,8 @@ export interface GlShipmentQuantities {
}>;
/** Bulk: tons (or item count) + hazardous/reefer qty. */
bulkQuantity: number;
/** NUMBER_OF_WAGONS cargo: the wagon count GL enters (0 otherwise). */
bulkRequestedWagons: number;
bulkHazardousQuantity: number;
bulkReeferQuantity: number;
}
@@ -132,7 +134,6 @@ export function computeGlShipmentTotal(
}
}
} else {
const qty = q.bulkQuantity;
const rate =
rateFor(
(i) =>
@@ -140,6 +141,9 @@ export function computeGlShipmentTotal(
!i.isClearance &&
!i.conditionalOn,
) ?? items[0];
// NUMBER_OF_WAGONS cargo: a per-wagon base rate bills the requested count.
const qty =
rate?.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
if (rate && qty > 0) {
lines.push({
label: rate.label,
@@ -179,8 +183,14 @@ export function computeGlShipmentTotal(
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
// depends on the wagon capacity the train stocks — shown at real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
const tons = q.bulkQuantity;
if (
lashing &&
(lashing.unit === "per_ton" ||
lashing.unit === "per_item" ||
(lashing.unit === "per_wagon" && q.bulkRequestedWagons > 0))
) {
const tons =
lashing.unit === "per_wagon" ? q.bulkRequestedWagons : q.bulkQuantity;
if (tons > 0) {
lines.push({
label: lashing.label,
@@ -208,6 +218,8 @@ export function computeGlShipmentTotal(
: boxes;
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
qty = q.bulkQuantity;
} else if (cl.unit === "per_wagon") {
qty = q.bulkRequestedWagons;
} else if (cl.unit === "flat") {
qty = 1;
}

View File

@@ -5,18 +5,30 @@ import {
Group,
Pagination,
Paper,
Select,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { ArrowRightLeft, Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
@@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
// Attach the selection to a DIFFERENT built train: pick a target, reuse the
// same assign endpoint with that train's id. The builder attach is
// yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is
// out on a run rejects server-side and is disabled here too.
const { toast } = useToast();
const [targetTrainId, setTargetTrainId] = useState<string | null>(null);
const trainsQuery = useQuery(
api.trainBuilder.list.queryOptions({
input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } },
enabled: canAttach,
staleTime: 60_000,
}),
);
const trainOptions = (trainsQuery.data?.items ?? [])
.filter((t) => t.id !== trainId)
.map((t) => ({
value: t.id,
label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""}${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`,
disabled: t.status === "IN_SERVICE",
}));
const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const handleAttachOther = async () => {
if (!targetTrainId || !selected.size) return;
const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId);
try {
await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] });
toast({
title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`,
});
setSelected(new Set());
setTargetTrainId(null);
void query.refetch();
} catch (error) {
toast({
title: "Could not attach to the other train",
description: parseError(error, "The target train may be out on a run."),
variant: "destructive",
});
}
};
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
@@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
<Group gap="sm" align="flex-end" wrap="wrap">
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
<Select
size="sm"
w={280}
searchable
clearable
placeholder="Or pick another train…"
maxDropdownHeight={350}
data={trainOptions}
value={targetTrainId}
onChange={setTargetTrainId}
nothingFoundMessage="No other built trains"
/>
<Button
variant="light"
leftSection={<ArrowRightLeft size={16} />}
disabled={selected.size === 0 || !targetTrainId}
loading={attachOther.isPending}
onClick={() => void handleAttachOther()}
>
Attach to that train
</Button>
</Group>
) : null}
</Group>

View File

@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
@@ -115,6 +116,7 @@ export function LogPassYardWorkModal({
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canUnload = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.unload);
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
@@ -135,6 +137,7 @@ export function LogPassYardWorkModal({
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
const unload = useMutation(api.trainScheduling.unloadScheduleBooking.mutationOptions());
// "Leave behind": the cargo is not on the train — unassign frees its wagons
// and returns the booking to the pool for a later schedule. Reversible (the
// booking can be re-assigned), so no extra confirm step.
@@ -144,6 +147,13 @@ export function LogPassYardWorkModal({
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
const arrivals: YardWorkBookingRow[] = yard?.toUnload ?? [];
const pendingBoarders = boarders.filter((r) => !r.loadedAt);
// Loading/unloading time windows at this station: the server rejects booking
// load/unload until the matching window is started, so the buttons mirror it.
const workLog = station
? yardWorkQuery.data?.stationWorkLogs?.[station.yardId]
: undefined;
const loadingStarted = Boolean(workLog?.loading?.startedAt);
const unloadingStarted = Boolean(workLog?.unloading?.startedAt);
const doLogPass = () => {
if (!station) return;
@@ -165,7 +175,9 @@ export function LogPassYardWorkModal({
description: isFinal
? undefined
: arrivals.some((r) => r.canUnload)
? "Bookings arriving here have been marked arrived."
? unloadingStarted
? "Bookings arriving here have been marked arrived."
: "Start unloading, then unload each arriving booking."
: undefined,
});
void yardWorkQuery.refetch();
@@ -201,6 +213,27 @@ export function LogPassYardWorkModal({
);
};
const doUnload = (row: YardWorkBookingRow) => {
unload.mutate(
{ scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} unloaded`,
description: `Cargo left the train at ${station?.label ?? "this yard"}.`,
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not unload booking",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const doLeave = (row: YardWorkBookingRow) => {
leave.mutate(
{ id: scheduleId, bookingId: row.id },
@@ -264,10 +297,22 @@ export function LogPassYardWorkModal({
title="Arriving at this yard"
count={arrivals.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
{!logged ? (
<Text size="xs" c="dimmed">
Logging the pass marks the loaded bookings below as Arrived
(import/export) or Completed (intercity) automatically.
Log the pass, start unloading, then unload each booking below.
</Text>
) : !unloadingStarted && arrivals.some((r) => r.canUnload) ? (
<Text size="xs" c="dimmed">
Start unloading first bookings can only be unloaded inside a
started unloading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
@@ -279,6 +324,7 @@ export function LogPassYardWorkModal({
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Arrived</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -303,6 +349,36 @@ export function LogPassYardWorkModal({
{row.arrivedAt ? fmtDate(row.arrivedAt) : "—"}
</Text>
</Table.Td>
<Table.Td>
{row.canUnload ? (
<Tooltip
label={
!canUnload
? "You don't have permission to unload cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !unloadingStarted
? "Start unloading first"
: "Confirm cargo unloaded off the train"
}
>
<Button
size="compact-xs"
variant="light"
color="teal"
leftSection={<PackageCheck size={13} />}
disabled={!canUnload || !logged || !unloadingStarted}
loading={
unload.isPending &&
unload.variables?.bookingId === row.id
}
onClick={() => doUnload(row)}
>
Unload
</Button>
</Tooltip>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -321,11 +397,24 @@ export function LogPassYardWorkModal({
title="Boarding at this yard"
count={boarders.length}
/>
{station ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!logged && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Log the pass first the train must be at {station?.label} before
cargo can be loaded.
</Text>
) : !loadingStarted && pendingBoarders.length > 0 ? (
<Text size="xs" c="dimmed">
Start loading first bookings can only be loaded inside a started
loading window.
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
@@ -389,16 +478,18 @@ export function LogPassYardWorkModal({
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
: !loadingStarted
? "Start loading first"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
@@ -467,16 +558,22 @@ export function LogPassYardWorkModal({
Close
</Button>
{!logged ? (
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
onClick={doLogPass}
<Tooltip
label="Start unloading first — arrival marks the remaining bookings arrived, so the unloading window must be open"
disabled={!(isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload))}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
disabled={isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload)}
onClick={doLogPass}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
</Tooltip>
) : null}
</Group>
</Group>

View File

@@ -799,6 +799,17 @@ export function ScheduleWorkspacePanel({
{group.rows.map((b) => {
const ref = b.reference ?? b.id.slice(0, 8);
const journey = journeyById.get(b.id);
// Server gates load/unload on the yard's started work
// window (Start loading/unloading buttons) — mirror it.
const loadWindowStarted = Boolean(
b.originYardId &&
schedule.stationWorkLogs?.[b.originYardId]?.loading?.startedAt,
);
const unloadWindowStarted = Boolean(
b.destinationYardId &&
schedule.stationWorkLogs?.[b.destinationYardId]?.unloading
?.startedAt,
);
const riding = b.status === "IN_TRANSIT";
const done = ["ARRIVED", "COMPLETED", "DELIVERED"].includes(
b.status ?? "",
@@ -845,7 +856,9 @@ export function ScheduleWorkspacePanel({
label={
!canLoad
? "You don't have permission to load cargo"
: boardHere
: boardHere && !loadWindowStarted
? `Start loading at ${group.label} first`
: boardHere
? `Load cargo onto the train at ${group.label}`
: passed
? `Train already passed ${group.label} — this cargo missed its stop`
@@ -860,7 +873,7 @@ export function ScheduleWorkspacePanel({
variant="filled"
color="edr-green"
radius="md"
disabled={!boardHere || !canLoad}
disabled={!boardHere || !canLoad || !loadWindowStarted}
leftSection={<PackageCheck size={13} />}
loading={
loadJourney.isPending &&
@@ -877,9 +890,11 @@ export function ScheduleWorkspacePanel({
{showTruckToTrain ? (
<Tooltip
label={
canLoad
? "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
: "You don't have permission to load cargo"
!canLoad
? "You don't have permission to load cargo"
: !loadWindowStarted
? `Start loading at ${group.label} first`
: "Customer truck loaded straight onto the wagon — no warehouse receipt, no GRN. Sets direct truck-to-train handover and loads."
}
withArrow
>
@@ -888,7 +903,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="blue"
radius="md"
disabled={!canLoad}
disabled={!canLoad || !loadWindowStarted}
leftSection={<Truck size={13} />}
loading={truckToTrainPending === b.id}
onClick={() =>
@@ -908,9 +923,11 @@ export function ScheduleWorkspacePanel({
label={
!canUnload
? "You don't have permission to unload cargo"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
: alightHere && !unloadWindowStarted
? "Start unloading at this yard first"
: alightHere
? "Unload at this yard — stamps the booking's arrival"
: "Unloads when the train reaches its destination yard"
}
withArrow
>
@@ -919,7 +936,7 @@ export function ScheduleWorkspacePanel({
variant="light"
color="orange"
radius="md"
disabled={!alightHere || !canUnload}
disabled={!alightHere || !canUnload || !unloadWindowStarted}
leftSection={<PackageOpen size={13} />}
loading={
unloadJourney.isPending &&

View File

@@ -0,0 +1,266 @@
import {
ActionIcon,
Badge,
Button,
Group,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation } from "@tanstack/react-query";
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtTime = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
const from = new Date(fromIso).getTime();
const to = toIso ? new Date(toIso).getTime() : Date.now();
const mins = Math.max(0, Math.round((to - from) / 60_000));
const h = Math.floor(mins / 60);
const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
};
/** Pencil-popover to correct an already-recorded start/end timestamp. */
function EditTimeButton({
label,
value,
disabled,
disabledReason,
minDate,
maxDate,
onSave,
saving,
}: {
label: string;
value: string;
disabled: boolean;
disabledReason: string;
minDate?: Date;
maxDate?: Date;
onSave: (at: Date) => void;
saving: boolean;
}) {
const [opened, setOpened] = useState(false);
const [draft, setDraft] = useState<Date | null>(null);
useEffect(() => {
if (opened) setDraft(new Date(value));
}, [opened, value]);
return (
<Popover opened={opened} onChange={setOpened} withArrow shadow="md" position="bottom">
<Popover.Target>
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
disabled={disabled}
onClick={() => setOpened((o) => !o)}
>
<Pencil size={12} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<DateTimePicker
label={`Correct ${label} time`}
value={draft}
onChange={(v) => setDraft(v ? new Date(v) : null)}
minDate={minDate}
maxDate={maxDate ?? new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={280}
/>
<Group justify="flex-end" gap="xs">
<Button size="compact-xs" variant="default" onClick={() => setOpened(false)}>
Cancel
</Button>
<Button
size="compact-xs"
loading={saving}
disabled={!draft}
onClick={() => {
if (draft) {
onSave(draft);
setOpened(false);
}
}}
>
Save
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
/**
* Start/End buttons + elapsed time for one station's loading OR unloading
* window. Booking load/unload at the yard is server-gated on the window having
* been started, so these buttons come first in the operator's flow. Each of
* the four buttons (start/end × loading/unloading) is its own permission, and
* the pencil edits a recorded time under the same permission that set it.
*/
export function StationWorkControls({
scheduleId,
yardId,
phase,
log,
}: {
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
log?: StationWorkPhaseLog | null;
}) {
const { user } = useAuth();
const { toast } = useToast();
const canStart = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingStart
: FREIGHT_PERMS.trainScheduling.unloadingStart,
);
const canEnd = hasPermission(
user,
phase === "loading"
? FREIGHT_PERMS.trainScheduling.loadingEnd
: FREIGHT_PERMS.trainScheduling.unloadingEnd,
);
const record = useMutation(api.trainScheduling.recordStationWork.mutationOptions());
// Re-render each minute so the running elapsed time ticks while unended.
const [, setTick] = useState(0);
useEffect(() => {
if (!log?.startedAt || log?.endedAt) return;
const t = setInterval(() => setTick((n) => n + 1), 60_000);
return () => clearInterval(t);
}, [log?.startedAt, log?.endedAt]);
const doRecord = (edge: "start" | "end", at?: Date) => {
record.mutate(
{ scheduleId, yardId, phase, edge, ...(at ? { at: at.toISOString() } : {}) },
{
onSuccess: () =>
toast({
title: `${phase === "loading" ? "Loading" : "Unloading"} ${edge} recorded`,
}),
onError: (err) =>
toast({
title: `Could not record ${phase} ${edge}`,
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const title = phase === "loading" ? "Loading" : "Unloading";
const started = Boolean(log?.startedAt);
const ended = Boolean(log?.endedAt);
return (
<Group gap="sm" wrap="wrap" align="center">
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
{title}
{ended ? " done" : started ? " in progress" : " not started"}
</Badge>
{!started ? (
<Tooltip
label={
canStart
? `Record the moment ${phase} work begins at this station`
: `You don't have permission to start ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PlayCircle size={14} />}
disabled={!canStart}
loading={record.isPending}
onClick={() => doRecord("start")}
>
Start {phase}
</Button>
</Tooltip>
) : (
<>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}
disabled={!canStart}
disabledReason={`You don't have permission to edit the ${phase} start`}
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
onSave={(at) => doRecord("start", at)}
saving={record.isPending}
/>
{ended ? (
<EditTimeButton
label={`${phase} end`}
value={log!.endedAt!}
disabled={!canEnd}
disabledReason={`You don't have permission to edit the ${phase} end`}
minDate={new Date(log!.startedAt!)}
onSave={(at) => doRecord("end", at)}
saving={record.isPending}
/>
) : null}
</Group>
{!ended ? (
<Tooltip
label={
canEnd
? `Record the moment ${phase} work is finished at this station`
: `You don't have permission to end ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="orange"
leftSection={<StopCircle size={14} />}
disabled={!canEnd}
loading={record.isPending}
onClick={() => doRecord("end")}
>
End {phase}
</Button>
</Tooltip>
) : null}
</>
)}
</Group>
);
}

View File

@@ -481,6 +481,12 @@ export const URL_CONSTANTS = {
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
STATION_WORK: (
id: string,
yardId: string,
phase: "loading" | "unloading",
edge: "start" | "end",
) => `/train-scheduling/schedules/${id}/stations/${yardId}/${phase}/${edge}`,
BOOKING_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>

View File

@@ -107,6 +107,11 @@ export const FREIGHT_PERMS = {
/** Confirm cargo loaded/unloaded at a yard — import, export, and intercity alike. */
load: "edr_freight_app:train_scheduling:load",
unload: "edr_freight_app:train_scheduling:unload",
/** Per-station loading/unloading time-window buttons (start/end pairs). */
loadingStart: "edr_freight_app:train_scheduling:loading_start",
loadingEnd: "edr_freight_app:train_scheduling:loading_end",
unloadingStart: "edr_freight_app:train_scheduling:unloading_start",
unloadingEnd: "edr_freight_app:train_scheduling:unloading_end",
cancel: "edr_freight_app:train_scheduling:cancel",
reschedule: "edr_freight_app:train_scheduling:reschedule",
rulesManage: "edr_freight_app:train_scheduling:rules_manage",
@@ -225,6 +230,8 @@ export const FREIGHT_PERMS = {
changeWagonYard: "edr_freight_app:trains:change_wagon_yard",
toggleActive: "edr_freight_app:trains:toggle_active",
disband: "edr_freight_app:trains:disband",
/** Decide detach/maintenance requests on a SCHEDULED train (4-eyes gate). */
approveWagonDetach: "edr_freight_app:trains:approve_wagon_detach",
},
routes: {
view: "edr_freight_app:routes:view",

View File

@@ -62,15 +62,45 @@ function isBulk(template: ContractTemplate): boolean {
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
// domestic and crosses no border, so it has no customs variant at all — hence
// null rather than false, which would wrongly read as a deliberate "client
// clears its own customs" choice.
function customsVariant(template: ContractTemplate): boolean | null {
if (isBulk(template)) return template.withCustoms ?? null;
if (template.code.endsWith("_NO_CUSTOMS")) return false;
if (template.code.endsWith("_CUSTOMS")) return true;
// null rather than "WITHOUT", which would wrongly read as a deliberate "client
// clears its own customs" choice. "ETHIOPIAN" is the with-customs variant
// restricted to Ethiopian-side clearing (Djibouti stays with the client).
type CustomsVariant = "WITH" | "WITHOUT" | "ETHIOPIAN" | null;
function customsVariant(template: ContractTemplate): CustomsVariant {
if (isBulk(template)) {
if (template.withCustoms == null) return null;
if (!template.withCustoms) return "WITHOUT";
return template.ethiopianCustomsOnly ? "ETHIOPIAN" : "WITH";
}
if (template.code.endsWith("_NO_CUSTOMS")) return "WITHOUT";
if (template.code.endsWith("_ETHIOPIAN_CUSTOMS")) return "ETHIOPIAN";
if (template.code.endsWith("_CUSTOMS")) return "WITH";
return null;
}
const CUSTOMS_BADGE: Record<
Exclude<CustomsVariant, null>,
{ label: string; color: string; tooltip: string }
> = {
WITH: {
label: "With customs",
color: "teal",
tooltip: "Used when the contract has customs clearing enabled",
},
ETHIOPIAN: {
label: "Ethiopian customs",
color: "indigo",
tooltip:
"Used when the service type includes Ethiopian customs clearing only — Djibouti clearing stays with the client",
},
WITHOUT: {
label: "No customs",
color: "gray",
tooltip: "Used when the client handles its own customs clearing",
},
};
// Bulk templates carry the direction on the row; the fixed container codes
// carry it as the code prefix.
function directionOf(template: ContractTemplate): string {
@@ -116,7 +146,7 @@ export default function ContractTemplatesPage() {
<PageContainer>
<PageHeader
title="Contract templates"
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
subtitle="The container contract documents are built in — one per trade direction and customs-clearing option (with customs, Ethiopian customs only, without). Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
action={
canCreate ? (
<Button
@@ -285,9 +315,17 @@ function CreateTemplateModal({
onChange={setWithCustoms}
data={[
{ value: "true", label: "With customs clearing" },
{ value: "ethiopian", label: "Ethiopian customs only" },
{ value: "false", label: "Without customs clearing" },
]}
/>
{withCustoms === "ethiopian" && (
<Text size="xs" c="dimmed" mt={6}>
Used for service types marked Ethiopian customs only: the
Service Provider clears the Ethiopian side, Djibouti clearing
stays with the client.
</Text>
)}
</div>
)}
@@ -316,8 +354,15 @@ function CreateTemplateModal({
{
cargoTypeId,
tradeDirection: direction,
// Omitted for intercity — the API rejects the flag there.
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
// Omitted for intercity — the API rejects the flags there.
...(intercity
? {}
: {
withCustoms: withCustoms !== "false",
...(withCustoms === "ethiopian"
? { ethiopianCustomsOnly: true }
: {}),
}),
},
{
onSuccess: (template) =>
@@ -394,16 +439,9 @@ function TemplateCard({
</Group>
<Group gap={6} wrap="nowrap">
{customs !== null && (
<Tooltip
label={
customs
? "Used when the contract has customs clearing enabled"
: "Used when the client handles its own customs clearing"
}
withArrow
>
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
{customs ? "With customs" : "No customs"}
<Tooltip label={CUSTOMS_BADGE[customs].tooltip} withArrow>
<Badge size="sm" variant="light" color={CUSTOMS_BADGE[customs].color}>
{CUSTOMS_BADGE[customs].label}
</Badge>
</Tooltip>
)}

View File

@@ -95,6 +95,7 @@ const FORM_FIELDS: FormFieldDef[] = [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
{ label: "Per ton (bulk)", value: "PER_TON" },
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
{ label: "Based on number of wagons", value: "NUMBER_OF_WAGONS" },
],
},
{
@@ -597,7 +598,11 @@ function CargoRow({
{node.unitOfMeasure ? (
<Tooltip label="How bookings measure this cargo" withArrow>
<Badge size="xs" variant="light" color="teal" radius="sm">
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
{node.unitOfMeasure === "PER_ITEM"
? "Per item"
: node.unitOfMeasure === "NUMBER_OF_WAGONS"
? "By wagons"
: "Per ton"}
</Badge>
</Tooltip>
) : null}

View File

@@ -12,6 +12,7 @@ import {
Tabs,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
@@ -55,7 +56,10 @@ import { api } from "@/services/api";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import type {
TrainCompositionWagon,
WagonDetachRequestRow,
} from "@/services/trainBuilder.service";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -95,8 +99,26 @@ export default function TrainBuilderDetailPage() {
setMaintenanceTarget(null);
setMaintenanceNote("");
};
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
// request (with reason) and executed by a second staffer's approval.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
} | null>(null);
const [requestReason, setRequestReason] = useState("");
const closeRequest = () => {
setRequestTarget(null);
setRequestReason("");
};
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
const [rejectNote, setRejectNote] = useState("");
const closeReject = () => {
setRejectTarget(null);
setRejectNote("");
};
const { user } = useAuth();
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
@@ -121,12 +143,42 @@ export default function TrainBuilderDetailPage() {
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const detachRequestsQuery = useQuery(
api.trainBuilder.detachRequests.queryOptions({
input: { id },
enabled: Boolean(id),
}),
);
const createDetachRequest = useMutation(
api.trainBuilder.createDetachRequest.mutationOptions(),
);
const approveDetachRequest = useMutation(
api.trainBuilder.approveDetachRequest.mutationOptions(),
);
const rejectDetachRequest = useMutation(
api.trainBuilder.rejectDetachRequest.mutationOptions(),
);
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
// dispatched train is frozen outright (composition.editable is false).
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
(s) => s.status === "SCHEDULED",
);
const detachRequests = useMemo(
() => detachRequestsQuery.data ?? [],
[detachRequestsQuery.data],
);
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
const pendingWagonIds = useMemo(
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
[detachRequests],
);
// The diagram memoizes off its `locomotives`/`wagons` props; building those
// arrays inline in JSX would hand it a new identity on every render and
// re-normalize + repaint every car for each keystroke or pending mutation.
@@ -217,15 +269,33 @@ export default function TrainBuilderDetailPage() {
},
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
if (pendingWagonIds.has(wagonId)) {
toast({
title: "A detach request for this wagon is already pending approval",
});
return;
}
const wagon = wagons?.find((w) => w.id === wagonId);
if (wagon) setRequestTarget({ wagon, action });
},
[pendingWagonIds, wagons, toast],
);
const handleRemove = useCallback(
(wagonId: string) => {
if (!trainId) return;
if (requiresDetachApproval) {
openDetachRequest(wagonId, "DETACH");
return;
}
void withToast(
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
"Could not detach wagon",
);
},
[withToast, removeWagon.mutateAsync, trainId],
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -248,8 +318,14 @@ export default function TrainBuilderDetailPage() {
[withToast, setWagonsYard.mutateAsync, trainId],
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
[],
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
},
[requiresDetachApproval, openDetachRequest],
);
if (compositionQuery.isLoading) {
@@ -487,6 +563,117 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600}>Detach approvals</Text>
{pendingDetachRequests.length ? (
<Badge color="yellow" variant="light">
{pendingDetachRequests.length} pending
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed">
While this train is on a scheduled run, detaching a wagon (or sending it to
maintenance) needs a second staff member's approval. Decided requests stay
here as the audit trail.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
return (
<Group key={req.id} justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs">
<Text size="sm" fw={600} ff="monospace">
{req.wagonNumber}
</Text>
<Badge
size="sm"
variant="light"
color={req.action === "MAINTENANCE" ? "orange" : "red"}
>
{req.action === "MAINTENANCE" ? "To maintenance" : "Detach"}
</Badge>
<Badge
size="sm"
variant="light"
color={
req.status === "PENDING"
? "yellow"
: req.status === "APPROVED"
? "green"
: "gray"
}
>
{req.status}
</Badge>
</Group>
<Text size="xs" c="dimmed">
Requested by {req.requestedBy ?? "unknown"} ·{" "}
{new Date(req.requestedAt).toLocaleString()} — {req.reason}
</Text>
{req.status !== "PENDING" ? (
<Text size="xs" c="dimmed">
{req.status === "APPROVED" ? "Approved" : "Rejected"} by{" "}
{req.decidedBy ?? "unknown"}
{req.decidedAt ? ` · ${new Date(req.decidedAt).toLocaleString()}` : ""}
{req.decisionNote ? ` — ${req.decisionNote}` : ""}
</Text>
) : null}
</Stack>
{req.status === "PENDING" && canApproveDetach ? (
<Group gap="xs" wrap="nowrap">
<Tooltip
label="You filed this request — a different staff member must approve it"
disabled={!isOwn}
withArrow
>
<Button
size="compact-sm"
color="green"
disabled={isOwn}
loading={approveDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await approveDetachRequest.mutateAsync({
id: composition.id,
requestId: req.id,
});
toast({
title: `Wagon ${req.wagonNumber} ${
req.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
}, "Could not approve request")
}
>
Approve
</Button>
</Tooltip>
<Button
size="compact-sm"
variant="light"
color="red"
onClick={() => setRejectTarget(req)}
>
Reject
</Button>
</Group>
) : req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
</Text>
) : null}
</Group>
);
})}
</Stack>
</Card>
) : null}
<Stack gap="sm">
<TrainCompositionDiagram
locomotives={diagramLocomotives}
@@ -681,6 +868,129 @@ export default function TrainBuilderDetailPage() {
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
</Text>
}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{requestTarget?.wagon.wagonNumber}
</Text>{" "}
is not detached now your request goes to a staff member with approval
rights, and the{" "}
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
happens the moment they approve it.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeRequest}>
Keep in consist
</Button>
<Button
color={requestTarget?.action === "MAINTENANCE" ? "orange" : "red"}
leftSection={
requestTarget?.action === "MAINTENANCE" ? (
<Wrench size={16} />
) : (
<Trash2 size={16} />
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
});
closeRequest();
}, "Could not file the request")
}
>
Request approval
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={closeReject}
title={<Text fw={600}>Reject this request?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{rejectTarget?.wagonNumber}
</Text>{" "}
stays in the consist. The requester sees your note in the request history.
</Text>
<Textarea
label="Why is it rejected?"
placeholder="Required"
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
autosize
minRows={2}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeReject}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectNote.trim()}
loading={rejectDetachRequest.isPending}
onClick={() =>
void withToast(async () => {
await rejectDetachRequest.mutateAsync({
id: composition.id,
requestId: rejectTarget!.id,
note: rejectNote.trim(),
});
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
closeReject();
}, "Could not reject the request")
}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={deactivateOpen}
onClose={() => setDeactivateOpen(false)}

View File

@@ -17,6 +17,7 @@ import {
Text,
ThemeIcon,
Title,
Tooltip,
} from "@mantine/core";
import { isAxiosError } from "axios";
import {
@@ -65,6 +66,7 @@ import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPane
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -490,6 +492,17 @@ export default function TrainScheduleV2DetailPage() {
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
// Origin loading time window: dispatch (which marks the ticked boarders
// loaded) is server-rejected until "Start loading" was clicked for the
// origin yard, so the button mirrors that gate.
const originLoadingLog = originYardId
? schedule.stationWorkLogs?.[originYardId]?.loading
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -936,6 +949,27 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Group>
</Paper>
{originYardId ? (
<Paper p="md" radius="lg" withBorder>
<Stack gap={6}>
<Text fw={600} size="sm">
Loading at {schedule.originStation?.label ?? "the origin yard"}
</Text>
<StationWorkControls
scheduleId={scheduleId}
yardId={originYardId}
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
</Text>
) : null}
</Stack>
</Paper>
) : null}
<Group>
{canDispatch ? (
<Button
@@ -1560,6 +1594,14 @@ export default function TrainScheduleV2DetailPage() {
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"}
tick what was loaded
</Text>
{originYardId ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={originYardId}
phase="loading"
log={originLoadingLog}
/>
) : null}
<Text size="xs" c="dimmed">
Unticked bookings are left behind: removed from this train, their
wagons freed, and the booking returned to the pool for a later
@@ -1656,14 +1698,20 @@ export default function TrainScheduleV2DetailPage() {
>
Cancel
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
onClick={() => void runDispatch()}
<Tooltip
label="Start loading at the origin station first — dispatch marks the ticked bookings loaded"
disabled={!dispatchNeedsLoadingStart}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={dispatch.isPending}
disabled={dispatchNeedsLoadingStart}
onClick={() => void runDispatch()}
>
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
</Button>
</Tooltip>
</Group>
</Stack>
</Modal>

View File

@@ -241,6 +241,7 @@ import {
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
type WagonDetachRequestRow,
} from "./trainBuilder.service";
import {
trainSchedulingService,
@@ -897,6 +898,24 @@ export const api = {
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
),
recordStationWork: endpoint<
{
scheduleId: string;
yardId: string;
phase: "loading" | "unloading";
edge: "start" | "end";
at?: string;
},
import("@/types/trainScheduling").StationWorkPhaseLog
>(
"train-scheduling",
"station-work",
({ scheduleId, yardId, phase, edge, at }) =>
trainSchedulingService.recordStationWork(scheduleId, yardId, phase, edge, at),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingLoadResult
@@ -2248,6 +2267,51 @@ export const api = {
seedComposition,
),
// Key derives to ["train-builder", "detachRequests", input] — the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every consist edit.
detachRequests: endpoint<{ id: string }, WagonDetachRequestRow[]>(
"train-builder",
"detachRequests",
({ id }) => trainBuilderService.detachRequests(id).then((r) => r.data),
),
createDetachRequest: endpoint<
{ id: string; wagonId: string; action: "DETACH" | "MAINTENANCE"; reason: string },
WagonDetachRequestRow
>(
"train-builder",
"createDetachRequest",
({ id, wagonId, action, reason }) =>
trainBuilderService.createDetachRequest(id, wagonId, { action, reason }).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
approveDetachRequest: endpoint<
{ id: string; requestId: string; note?: string },
TrainComposition
>(
"train-builder",
"approveDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.approveDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_WAGON_INVALIDATIONS,
seedComposition,
),
rejectDetachRequest: endpoint<
{ id: string; requestId: string; note: string },
TrainComposition
>(
"train-builder",
"rejectDetachRequest",
({ id, requestId, note }) =>
trainBuilderService.rejectDetachRequest(id, requestId, note).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
reorderWagons: endpoint<{ id: string; wagonIds: string[] }, TrainComposition>(
"train-builder",
"reorderWagons",

View File

@@ -34,7 +34,12 @@ export interface ContractTemplate {
* Null for intercity — domestic movements have no customs leg.
*/
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).
*/
ethiopianCustomsOnly?: boolean | null;
/** The seeded container templates — cannot be deleted. */
isSystem: boolean;
createdAt: string;
updatedAt: string;
@@ -45,6 +50,8 @@ export interface CreateContractTemplatePayload {
tradeDirection: BulkTemplateDirection;
/** Omitted for INTERCITY — the API rejects the flag there. */
withCustoms?: boolean;
/** Ethiopian-side clearing only; requires withCustoms: true. */
ethiopianCustomsOnly?: boolean;
name?: string;
description?: string;
}

View File

@@ -386,6 +386,22 @@ export interface UpdateScheduleWagonYardsPayload {
export type UpdateScheduleWagonYardsResult = ScheduleWagonYards & { warnings: string[] };
/** One detach/maintenance approval request — pending or decided (audit trail). */
export interface WagonDetachRequestRow {
id: string;
wagonId: string;
wagonNumber: string;
action: "DETACH" | "MAINTENANCE";
reason: string;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedById: string | null;
requestedBy: string | null;
requestedAt: string;
decidedBy: string | null;
decidedAt: string | null;
decisionNote: string | null;
}
export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
@@ -428,6 +444,29 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`, {
note,
}),
/** Requests to detach a wagon from a SCHEDULED train, newest first. */
detachRequests: (id: string) =>
apiClient.get<WagonDetachRequestRow[]>(`${BASE}/${id}/detach-requests`),
/** File a detach/maintenance approval request (reason required). */
createDetachRequest: (
id: string,
wagonId: string,
payload: { action: "DETACH" | "MAINTENANCE"; reason: string },
) =>
apiClient.post<WagonDetachRequestRow>(
`${BASE}/${id}/wagons/${wagonId}/detach-requests`,
payload,
),
/** Approve a pending request — executes the detach immediately. */
approveDetachRequest: (id: string, requestId: string, note?: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/approve`, {
note,
}),
/** Reject a pending request — a note explaining why is required. */
rejectDetachRequest: (id: string, requestId: string, note: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/detach-requests/${requestId}/reject`, {
note,
}),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */

View File

@@ -471,6 +471,23 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
/** Start/end (or correct, via `at`) a station's loading/unloading time window. */
recordStationWork: async (
scheduleId: string,
yardId: string,
phase: "loading" | "unloading",
edge: "start" | "end",
at?: string,
): Promise<import("@/types/trainScheduling").StationWorkPhaseLog> => {
const response = await client.post<
import("@/types/trainScheduling").StationWorkPhaseLog
>(
URL_CONSTANTS.TRAIN_SCHEDULING.STATION_WORK(scheduleId, yardId, phase, edge),
at ? { at } : {},
);
return unwrap(response.data);
},
loadScheduleBooking: async (
scheduleId: string,
bookingId: string,

View File

@@ -234,6 +234,8 @@ export interface BookingDetail {
equipmentReturn?: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string | null;
customsClearingAgentEmail?: string | null;
customsClearingAgentPhone?: string | null;
/** ET clearance queue: every required document approved (pre-finalize). */
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */

View File

@@ -778,6 +778,8 @@ export interface TrainScheduleDetail {
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
maxGrossWeightTons?: number | null;
/** Train length ceiling incl. overage tolerance — per-leg length is held to it. */
@@ -892,6 +894,19 @@ export interface TrackStation {
code: string;
}
/** 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;
}
export interface TrainCheckpoint {
id: string;
sequenceNo: number;
@@ -912,6 +927,8 @@ export interface TrainTrackResponse {
origin: string | null;
destination: string | null;
stations: TrackStation[];
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
}
@@ -1160,6 +1177,8 @@ export interface YardWorkResult {
scheduleId: string;
scheduleStatus: string;
trainAtYardId: string | null;
/** Per-yard loading/unloading time windows (start/end operator clicks). */
stationWorkLogs?: Record<string, StationWorkLog>;
yards: YardWorkYard[];
}

View File

@@ -648,14 +648,9 @@ export default function NewContractPage({
: {}),
// Customs bundling is a property of the chosen service, not of a stored
// form flag — derive it here so stale drafts can't misreport it. A
// non-bundled contract still records the customer's own clearing agent.
...(serviceType?.includesCustoms
? { customsClearingEnabled: true }
: {
customsClearingEnabled: false,
customsClearingAgent:
data.customsClearingAgent?.trim() || undefined,
}),
// non-bundled contract collects the clearing agent per booking, at
// booking completion — nothing on the contract.
customsClearingEnabled: Boolean(serviceType?.includesCustoms),
cargoScope,
routes,
};

View File

@@ -239,7 +239,19 @@ export default function NewShipmentPage() {
function bulkUnitOfMeasure(
contract: Freight.IContract,
): "PER_TON" | "PER_ITEM" {
): "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS" {
// The cargo type's own configured unit wins; the pricing-line sniff below is
// the legacy fallback for contracts loaded without the cargoScope relation.
const configured = contract.cargoScope?.find(
(scope) => scope.cargoType?.unitOfMeasure,
)?.cargoType?.unitOfMeasure;
if (
configured === "PER_TON" ||
configured === "PER_ITEM" ||
configured === "NUMBER_OF_WAGONS"
) {
return configured;
}
const hasPerItem = contract.pricingBreakdown?.lineItems?.some(
(li) => li.unit === "per_item",
);
@@ -288,6 +300,10 @@ function mapBookingToShipmentValues(
: "",
withReturn: booking.equipmentReturn === "WITH_RETURN",
cargoDescription: b.cargoFreeText ?? "",
// The agent entered at the first completion stays on a resubmit.
customsClearingAgent: booking.customsClearingAgent ?? "",
customsClearingAgentEmail: booking.customsClearingAgentEmail ?? "",
customsClearingAgentPhone: booking.customsClearingAgentPhone ?? "",
...(b.contractRouteId ? { contractRouteId: b.contractRouteId } : {}),
};
if (contract.freightType === "CONTAINER") {
@@ -317,9 +333,9 @@ function mapBookingToShipmentValues(
.filter((s): s is "20ft" | "40ft" => s === "20ft" || s === "40ft");
values.containers = (sizes.length ? sizes : (["20ft", "40ft"] as const)).map(lineFor);
} else {
const perItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
const uom = bulkUnitOfMeasure(contract);
const amount = Number(b.cargoTotalWeightVgm ?? 0);
if (perItem) {
if (uom === "PER_ITEM") {
values.itemCount = amount ? String(amount) : "";
values.cargoWeightTons =
b.bulkTotalWeightTons != null
@@ -327,6 +343,17 @@ function mapBookingToShipmentValues(
: "";
} else {
values.cargoWeightTons = amount ? String(amount) : "";
if (uom === "NUMBER_OF_WAGONS") {
const wagons = Number(
(b as { bulkRequestedWagons?: number | string | null })
.bulkRequestedWagons ?? 0,
);
const items = Number(
(b as { bulkItemCount?: number | string | null }).bulkItemCount ?? 0,
);
values.requestedWagons = wagons ? String(wagons) : "";
values.itemCount = items ? String(items) : "";
}
}
values.bulkHazardousQuantity = String(Number(b.bulkHazardousQuantity ?? 0));
values.bulkReeferQuantity = String(Number(b.bulkReeferQuantity ?? 0));
@@ -413,6 +440,11 @@ function NewShipmentBookingForm({
// (mirrors the ScheduleStep picker's visibility).
requiresTrain:
contract.tradeDirection === "EXPORT" && Boolean(completeBookingId),
// Without-customs import/export completion collects the customer's own
// clearing agent per booking (this page never renders for a customs
// contract — see the gate above). Intercity has no border to clear.
requiresClearingAgent:
Boolean(completeBookingId) && contract.tradeDirection !== "DOMESTIC",
}),
),
mode: "onChange",
@@ -575,7 +607,20 @@ function NewShipmentBookingForm({
Number(values.bulkReeferQuantity || 0) || undefined,
},
],
...(bulkUnitOfMeasure(contract) === "NUMBER_OF_WAGONS" &&
values.requestedWagons
? { requestedWagons: Number(values.requestedWagons) }
: {}),
}),
// Customer's own clearing agent — collected at completion; the server
// requires all three for a without-customs import/export booking.
...(values.customsClearingAgent?.trim()
? {
customsClearingAgent: values.customsClearingAgent.trim(),
customsClearingAgentEmail: values.customsClearingAgentEmail.trim(),
customsClearingAgentPhone: values.customsClearingAgentPhone.trim(),
}
: {}),
...(values.notes ? { notes: values.notes } : {}),
};
}
@@ -710,6 +755,10 @@ function NewShipmentBookingForm({
)}
<RouteStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
{Boolean(completeBookingId) &&
contract.tradeDirection !== "DOMESTIC" && (
<ClearingAgentStep form={form} />
)}
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
{contract.freightType === "CONTAINER" &&
@@ -1410,7 +1459,11 @@ function CargoStep({
const isContainer = contract.freightType === "CONTAINER";
// Break-bulk (PER_ITEM) cargo needs BOTH the item count (which prices it) and
// the total tonnage (which sizes the wagons); PER_TON needs tonnage only.
const isPerItem = bulkUnitOfMeasure(contract) === "PER_ITEM";
// NUMBER_OF_WAGONS needs the tonnage PLUS the wagon count (an optional item
// count may ride along as information).
const bulkUom = bulkUnitOfMeasure(contract);
const isPerItem = bulkUom === "PER_ITEM";
const isByWagons = bulkUom === "NUMBER_OF_WAGONS";
// Sizes enabled by the contract scope.
const sizes = useMemo(
() =>
@@ -1766,7 +1819,9 @@ function CargoStep({
description={
isPerItem
? "Combined weight of all the items — used to work out how many wagons the shipment needs."
: undefined
: isByWagons
? "Spread evenly across the wagons you request below."
: undefined
}
placeholder="e.g. 1200"
min={0}
@@ -1777,6 +1832,47 @@ function CargoStep({
/>
)}
/>
{isByWagons && (
<>
<Controller
name="itemCount"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Number of items (optional)"
placeholder="e.g. 500"
min={0}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="requestedWagons"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="number"
onKeyDown={blockNegative}
label="Number of wagons needed *"
description="Your cargo is allocated exactly this many wagons; a per-wagon rate bills this count."
placeholder="e.g. 40"
min={1}
step={1}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</>
)}
{contract.isHazardous && (
<Controller
name="bulkHazardousQuantity"
@@ -1892,6 +1988,71 @@ function EquipmentReturnStep({ form }: { form: ShipmentForm }) {
);
}
/**
* Completion of a without-customs import/export booking: the customer names
* their own customs clearing agent per booking — name, email and phone are
* all required (the schema and the server both enforce it).
*/
function ClearingAgentStep({ form }: { form: ShipmentForm }) {
return (
<StepCard>
<StepHeader
icon={<FileText size={22} />}
title="Customs Clearing Agent"
description="Your service does not include customs clearance — enter the agent handling customs for this booking."
/>
<Stack gap="sm">
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
label="Agent name *"
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Group grow align="flex-start">
<Controller
name="customsClearingAgentEmail"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="email"
label="Agent email *"
placeholder="agent@example.com"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
<Controller
name="customsClearingAgentPhone"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
type="tel"
label="Agent phone *"
placeholder="+251 9…"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
</Group>
</Stack>
</StepCard>
);
}
function NotesSection({ form }: { form: ShipmentForm }) {
return (
<StepCard>

View File

@@ -107,7 +107,6 @@ export function contractToFormValues(
lng: contract.lastMileDeliveryLng ?? null,
},
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? "",
cargoType: isContainer ? "container" : "bulk",
enabledContainerSizes:

View File

@@ -156,7 +156,6 @@ export const contractFormSchema = z
.enum(["with_return", "without_return"])
.default("without_return"),
customsClearingEnabled: z.boolean().default(false),
customsClearingAgent: z.string().default(""),
// ── Cargo SCOPE (no quantities) ──
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
@@ -274,7 +273,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "without_return",
customsClearingEnabled: false,
customsClearingAgent: "",
cargoType: "container",
enabledContainerSizes: [...CONTAINER_SIZES],
@@ -307,7 +305,6 @@ export const contractStepFields: Record<
"serviceTypeId",
"equipmentReturn",
"customsClearingEnabled",
"customsClearingAgent",
"firstMile",
"lastMile",
],

View File

@@ -131,7 +131,6 @@ export function Step1ContractType({
"customsClearingEnabled",
contract.customsClearingEnabled ?? false,
);
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
// ── Route (single route per contract) ──
const routes = contract.routes ?? [];

View File

@@ -4,7 +4,6 @@ import {
Check,
Container,
FileCheck2,
FileText,
Info,
// PackageCheck,
ShieldCheck,
@@ -306,10 +305,6 @@ export function Step2ServiceType({
if (form.getValues("customsClearingEnabled") !== desired) {
form.setValue("customsClearingEnabled", desired, { shouldDirty: true });
}
// A bundled-customs service never carries a customer-named agent.
if (desired && form.getValues("customsClearingAgent")) {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [includesCustoms, form]);
// A hidden mile must not leak a stale enabled=true into the payload. The
@@ -357,13 +352,6 @@ export function Step2ServiceType({
// shipment (at booking, or on the shipment request when GL books). Intercity
// still bills in ETB, but that is applied at booking time, not here.
const isIntercity = operationType === "intercity";
useEffect(() => {
// The customs clearing agent field is hidden for intercity — drop any value
// carried over from a draft or an operation-type switch.
if (isIntercity && form.getValues("customsClearingAgent")) {
form.setValue("customsClearingAgent", "", { shouldDirty: true });
}
}, [isIntercity, form]);
return (
<Stack gap={18}>
@@ -566,8 +554,9 @@ export function Step2ServiceType({
)}
{/* Intercity (domestic) moves never cross a border, so no customs
clearing agent is collected. */}
{/* Without bundled customs the customer names their own clearing
agent per booking, at booking completion — nothing to collect
on the contract. Intercity never crosses a border. */}
{isIntercity ? null : includesCustoms ? (
<Box
px={16}
@@ -614,57 +603,7 @@ export function Step2ServiceType({
</Box>
</Group>
</Box>
) : (
<Controller
name="customsClearingAgent"
control={form.control}
render={({ field, fieldState }) => (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: "1.5px solid #E6ECF2",
background: "#fff",
}}
>
<Group gap={13} align="flex-start" wrap="nowrap" mb="sm">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F4F7",
color: "#64748B",
}}
>
<FileText size={18} />
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
Customs Clearing Agent
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
Enter the name of your customs clearing agent for this
contract.
</Text>
</Box>
</Group>
<TextInput
{...field}
placeholder="Customs clearing agent name"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
</Box>
)}
/>
)}
) : null}
</div>
</Stack>
)}

View File

@@ -216,15 +216,13 @@ export function Step8Review({
// Customs is a property of the chosen service (bundled → Global Logistics),
// not of the stored form flag — a stale draft flag must not misreport it.
// Without bundling, the customer may still name their own clearing agent.
const ownAgent = values.customsClearingAgent?.trim();
// Without bundling, the customer names their own agent per booking, at
// booking completion — nothing is recorded on the contract.
const customsTag: { label: string; color: string } = isIntercity
? { label: "Not applicable · domestic", color: "gray" }
: serviceType?.includesCustoms || values.customsClearingEnabled
? { label: "EDR handles it · Global Logistics", color: "edr-green" }
: ownAgent
? { label: `Own agent · ${ownAgent}`, color: "blue" }
: { label: "Not requested", color: "gray" };
: { label: "Own agent · named per booking", color: "blue" };
// Mirror the step-2 gating: imports never truck the first mile, exports never
// truck the last mile, and a service that doesn't bundle a mile can't have it.

View File

@@ -24,7 +24,7 @@ export interface ShipmentValidationContext {
* per-line "with return" quantity, validated like hazardous/reefer.
*/
withReturnService?: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM";
unitOfMeasure?: "PER_TON" | "PER_ITEM" | "NUMBER_OF_WAGONS";
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
* staff pick later, so no shipment day is chosen. Defaults to true.
@@ -35,6 +35,12 @@ export interface ShipmentValidationContext {
* customer picks for the chosen day. Defaults to false.
*/
requiresTrain?: boolean;
/**
* Completion of a without-customs import/export booking: the customer's own
* clearing agent (name, email, phone) is required per booking. Defaults to
* false — direct drawdown creates and intercity never collect it.
*/
requiresClearingAgent?: boolean;
}
// ISO 6346: 3-letter owner code + category id (U/J/Z) + 6-digit serial + check digit.
@@ -92,8 +98,15 @@ const shipmentFormBase = z.object({
cargoDescription: z.string().default(""),
cargoWeightTons: z.string().default(""),
itemCount: z.string().default(""),
// NUMBER_OF_WAGONS cargo only: wagons this shipment needs (required then).
requestedWagons: z.string().default(""),
bulkHazardousQuantity: z.string().default("0"),
bulkReeferQuantity: z.string().default("0"),
// Customer's own customs clearing agent — collected per booking when the
// service does not bundle customs (required at completion, see superRefine).
customsClearingAgent: z.string().default(""),
customsClearingAgentEmail: z.string().default(""),
customsClearingAgentPhone: z.string().default(""),
notes: z.string().default(""),
});
@@ -121,6 +134,30 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
});
}
if (ctx.requiresClearingAgent) {
if (!data.customsClearingAgent.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter your customs clearing agent's name.",
});
}
if (!z.email().safeParse(data.customsClearingAgentEmail.trim()).success) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgentEmail"],
message: "Enter a valid email for your clearing agent.",
});
}
if (!data.customsClearingAgentPhone.trim()) {
refineCtx.addIssue({
code: "custom",
path: ["customsClearingAgentPhone"],
message: "Enter your clearing agent's phone number.",
});
}
}
// No default currency — the customer must pick one before submitting.
if (!data.paymentCurrency) {
refineCtx.addIssue({
@@ -264,6 +301,20 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
}
}
// NUMBER_OF_WAGONS cargo: the wagon count is the customer's order — the
// weight spreads evenly across it (the server also checks each wagon's
// share against wagon capacity).
if (ctx.unitOfMeasure === "NUMBER_OF_WAGONS") {
const wagons = Number(data.requestedWagons || 0);
if (!Number.isInteger(wagons) || wagons < 1) {
refineCtx.addIssue({
code: "custom",
path: ["requestedWagons"],
message: "Enter the number of wagons needed (at least 1).",
});
}
}
const boundBulkPortion = (
on: boolean,
raw: string,
@@ -321,8 +372,12 @@ export const initialShipmentFormValues: DeepPartial<ShipmentFormValues> = {
cargoDescription: "",
cargoWeightTons: "",
itemCount: "",
requestedWagons: "",
bulkHazardousQuantity: "0",
bulkReeferQuantity: "0",
customsClearingAgent: "",
customsClearingAgentEmail: "",
customsClearingAgentPhone: "",
notes: "",
};
@@ -336,6 +391,7 @@ export const shipmentStepFields: Record<
"cargoDescription",
"cargoWeightTons",
"itemCount",
"requestedWagons",
"bulkHazardousQuantity",
"bulkReeferQuantity",
"withReturn",

View File

@@ -35,6 +35,10 @@ export function formatAmount(amount: number | string | null | undefined) {
* PER_TON cargo has no item count and falls back the other way for legacy rows.
*/
function bulkQtyForUnit(values: ShipmentFormValues, unit: string): number {
// NUMBER_OF_WAGONS cargo: a per_wagon rate bills the wagon count the
// customer requested (0 when the cargo is not wagon-requested — the caller
// then skips the line, matching the "shown at real pricing" fallback).
if (unit === "per_wagon") return Number(values.requestedWagons || 0);
return unit === "per_item"
? Number(values.itemCount || 0)
: Number(values.cargoWeightTons || values.itemCount || 0);
@@ -190,7 +194,12 @@ export function computeShipmentTotal(
// amount; per-wagon depends on the wagon capacity the train stocks — shown at
// real pricing.
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
if (
lashing &&
(lashing.unit === "per_ton" ||
lashing.unit === "per_item" ||
lashing.unit === "per_wagon")
) {
const qty = bulkQtyForUnit(values, lashing.unit);
if (qty > 0) {
lines.push({
@@ -217,7 +226,11 @@ export function computeShipmentTotal(
cl.unit === "per_wagon"
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
: boxes;
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
} else if (
cl.unit === "per_ton" ||
cl.unit === "per_item" ||
cl.unit === "per_wagon"
) {
qty = bulkQtyForUnit(values, cl.unit);
} else if (cl.unit === "flat") {
qty = 1;

View File

@@ -1018,8 +1018,18 @@ export interface CreateBookingUnderContractDto {
equipmentReturn?: string;
containers?: CreateBookingContainerLineDto[];
bulkLines?: CreateBulkLineDto[];
/**
* 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.
*/
requestedWagons?: number;
/** What the containers carry — captured per booking (container freight). */
cargoFreeText?: string;
/** Customer's own clearing agent — required at completion of a without-customs import/export booking. */
customsClearingAgent?: string;
customsClearingAgentEmail?: string;
customsClearingAgentPhone?: string;
notes?: string;
}

View File

@@ -73,6 +73,13 @@ export enum BookingType {
export enum CargoUnitOfMeasure {
PerTon = "PER_TON",
PerItem = "PER_ITEM",
/**
* Wagon-request cargo: the customer states the wagon count the shipment
* needs alongside its weight (and an optional item count). Allocation uses
* the requested count verbatim, spreading the weight evenly; a PER_WAGON
* rate bills that count, a PER_TON rate bills the weight.
*/
NumberOfWagons = "NUMBER_OF_WAGONS",
}
export enum BookingStatus {
@@ -704,6 +711,8 @@ export interface IBooking extends BaseEntity {
// (multi-truck self-haul lives in ICustomerTruck[], fetched via the
// /customer-trucks endpoint; the fields above are the booking-level flag.)
customsClearingAgent?: string | null;
customsClearingAgentEmail?: string | null;
customsClearingAgentPhone?: string | null;
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
originYard?: IYard | null;