diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts new file mode 100644 index 000000000..7050c1c40 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddWagonTrainNumbers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-wagon EXPORT/IMPORT run numbers, editable from the wagon form. + * + * Nullable with no default: a wagon is not on a run until an operator says so. + * Mirrors the width of trains.export_train_number / trains.import_train_number + * (varchar 20) so the two stay comparable. + */ +export class AddWagonTrainNumbers2270000000000 implements MigrationInterface { + name = 'AddWagonTrainNumbers2270000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + ADD COLUMN IF NOT EXISTS export_train_number varchar(20), + ADD COLUMN IF NOT EXISTS import_train_number varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagons + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts index d1939c9f5..408b13be5 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -20,6 +20,16 @@ export class CreateWagonDto { // Tare weight and payload capacity are not accepted here: they belong to the // wagon type and are resolved through wagonTypeId. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). */ + @IsOptional() + @IsString() + exportTrainNumber?: string; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). */ + @IsOptional() + @IsString() + importTrainNumber?: string; + @IsOptional() @IsEnum(WagonStatus) status?: WagonStatus; diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index b66fc3f9d..7bfb59e1d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -43,6 +43,14 @@ export class Wagon extends BaseEntity { // Tare weight and payload capacity are properties of the wagon TYPE — read them // through `wagonType`, never off the individual wagon. + /** EXPORT run number — odd, Ethiopia → Djibouti (e.g. 8001). Null until set. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + + /** IMPORT run number — even, Djibouti → Ethiopia (e.g. 8002). Null until set. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + @Column({ type: 'varchar', length: 20, default: WagonStatus.Available }) status!: WagonStatusType; diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 8c5dd83c8..a43ff0e67 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -38,6 +38,8 @@ export class WagonsService { if (dto.trainId === undefined) wagon.trainId = null; if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; if (dto.currentYardId === undefined) wagon.currentYardId = null; + if (dto.exportTrainNumber === undefined) wagon.exportTrainNumber = null; + if (dto.importTrainNumber === undefined) wagon.importTrainNumber = null; return this.wagonRepo.save(wagon); } diff --git a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx index 7262e7927..dfb27ed36 100644 --- a/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/fleet/FleetFormDialog.tsx @@ -271,8 +271,14 @@ const FleetFormDialog = ({ return; } if (!validate()) return; + // Derived fields are never edited, so form state for them can be stale (or + // seeded from the record) — recompute before building the payload. + const submitted: Record = { ...values }; + fields.forEach((field) => { + if (field.derivedValue) submitted[field.name] = field.derivedValue(values); + }); const payload = Object.fromEntries( - Object.entries(values) + Object.entries(submitted) .map(([key, value]) => { if (value === FLEET_SELECT_NONE || value === "") return [key, undefined]; @@ -294,6 +300,23 @@ const FleetFormDialog = ({ // only by verification and never hand-edited. const isDisabled = Boolean(field.disabled || field.faydaLocked); + // Computed from other fields (e.g. the import run implied by the export + // run) — read-only, and recomputed here rather than read from form state. + if (field.derivedValue) { + return ( + + ); + } + if (field.type === "radio") { return ( ) => string; /** * Field is owned by the Fayda identity — populated only by verification and * never hand-edited. Rendered disabled in the form. @@ -116,6 +123,32 @@ const WAGON_STATUS_OPTIONS = [ { label: "Detained", value: Freight.WagonStatus.Detained }, ]; +/** + * EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The + * even IMPORT run (Djibouti → Ethiopia) is fixed by the export run, so choosing + * an export number fully determines the import one. Listed explicitly rather + * than computed as export+1, so a pair that ever breaks that convention stays + * correct here. + */ +const TRAIN_RUN_PAIRS: Record = { + "8001": "8002", + "81001": "81002", + "82001": "82002", + "83001": "83002", + "84001": "84002", + "85001": "85002", + "86001": "86002", + "87001": "87002", + "88001": "88002", + "8901": "8902", + "9001": "9002", +}; + +const EXPORT_TRAIN_OPTIONS = Object.keys(TRAIN_RUN_PAIRS).map((run) => ({ + label: run, + value: run, +})); + export const FLEET_RESOURCES: FleetResourceConfig[] = [ @@ -271,6 +304,26 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, ], formFields: [ + // Run numbers are optional — a wagon sits in the fleet unassigned to any + // run until an operator picks an export run. The import run is fixed by + // that choice, so it is derived rather than typed. + { + name: "exportTrainNumber", + label: "Export train number", + type: "select", + description: "Odd — Ethiopia → Djibouti runs", + placeholder: "e.g. 8001", + options: EXPORT_TRAIN_OPTIONS, + }, + { + name: "importTrainNumber", + label: "Import train number", + type: "text", + description: "Even — Djibouti → Ethiopia runs", + placeholder: "e.g. 8002", + derivedValue: (values) => + TRAIN_RUN_PAIRS[String(values.exportTrainNumber ?? "")] ?? "", + }, { name: "wagonNumber", label: "Wagon number", type: "text", required: true }, { name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" }, { name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" }, @@ -278,6 +331,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [ { name: "notes", label: "Notes", type: "textarea" }, ], emptyValues: { + exportTrainNumber: "", + importTrainNumber: "", wagonNumber: "", wagonTypeId: "", currentYardId: "",