diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 68bbdcba4..9962208a4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -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); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 36e4e34d0..6a64f6199 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -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 ? { diff --git a/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts new file mode 100644 index 000000000..0590d6395 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3710000000000-BookingClearingAgentContact.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customs_clearing_agent_email, + DROP COLUMN IF EXISTS customs_clearing_agent_phone + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts new file mode 100644 index 000000000..6998895c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3720000000000-ScheduleStationWorkLogs.ts @@ -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 { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS station_work_logs jsonb + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS station_work_logs + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts new file mode 100644 index 000000000..1c7fcb3db --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3730000000000-WagonDetachRequests.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_detach_requests`); + await queryRunner.query(`DROP TYPE IF EXISTS freight.wagon_detach_requests_status_enum`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts new file mode 100644 index 000000000..64c9ac2c7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3740000000000-BookingBulkRequestedWagons.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS bulk_requested_wagons, + DROP COLUMN IF EXISTS bulk_item_count + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts new file mode 100644 index 000000000..564972333 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3750000000000-EthiopianCustomsContractTemplates.ts @@ -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 { + 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 { + 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 + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 17d1ba2dc..808421a0c 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -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. + } } } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c85e15cfb..67a486068 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts index b822387da..1c1b304c9 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/bulk-template-direction.spec.ts @@ -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, ); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts index 803814967..50a912a1d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -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()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts index 1df3b1302..58c452f0c 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.repository.ts @@ -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 { - 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 { return this.repository .createQueryBuilder("t") @@ -60,6 +73,9 @@ export class ContractTemplatesRepository extends BaseRepository = { 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 { 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 { 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; diff --git a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts index 9f7579dbd..7676749f3 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/dto/contract-template.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index e070aa1e4..f94e5d52f 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 051d0eabe..f5e00f503 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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 }) => diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 48c90fcbc..936176f86 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -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 { diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index f50ca9d4d..0592dafc4 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts index 4152ffb30..d493147b9 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index ee64768c2..fd7754844 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index fdd76861d..a1f927a77 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -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 | 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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 625ccfda7..9e16bba60 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 56b28fb7f..c7256d82d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -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 = 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 { @@ -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 diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 494522595..efa08a927 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -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({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts new file mode 100644 index 000000000..6100b037a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/station-work.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 0fd0304b3..2340f7b10 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index ed476a6e1..05f386a4c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 63feb6e8c..8b980be11 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -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[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[0], + booking: Parameters[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; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 2df626a84..b4e87d398 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -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), diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 449033bfc..92e4b441d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts new file mode 100644 index 000000000..afe219bfa --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/wagon-detach-request.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts new file mode 100644 index 000000000..acaaed464 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/entities/wagon-detach-request.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 21431a1a4..46c95de95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -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' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index d7cfc29c5..09f6559f7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -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 { + 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 { + { 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 { + 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 { 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({ diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 6009ebef7..1f50681cd 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -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], diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 6255c90fc..5ddc99cca 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -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; @@ -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> = [ + 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. */ diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 8204ee2d3..430af77fd 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx index 67851ab30..641541634 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingRouteServiceCard.tsx @@ -128,14 +128,27 @@ export function BookingRouteServiceCard({ background: "#F8FAFC", }} > - - - - Customs clearing agent:{" "} - - {booking.customsClearingAgent} + + + + + Customs clearing agent:{" "} + + {booking.customsClearingAgent} + - + {(booking.customsClearingAgentEmail || + booking.customsClearingAgentPhone) && ( + + {[ + booking.customsClearingAgentEmail, + booking.customsClearingAgentPhone, + ] + .filter(Boolean) + .join(" · ")} + + )} + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 0ca021937..ff59b1b65 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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({ 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" && ( + + setBulk((b) => ({ + ...b, + requestedWagons: e.currentTarget.value, + })) + } + radius={10} + styles={fieldStyles} + /> + )} {contract.isHazardous && ( ; /** 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; } diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx index 72428c329..a05dc51b3 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/DetachedWagonsPanel.tsx @@ -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>(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(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({ {canAttach ? ( - + + +