diff --git a/.gitignore b/.gitignore index 8fa8bca90..cf36ca979 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # build output **/dist/ .next/ +**/out/ coverage/ *.tsbuildinfo **/*.tsbuildinfo @@ -62,3 +63,6 @@ integration/.it-shards.yaml *.crt secrets/ certs/ +branch_structure.json +temp_auto_push.bat +temp_interactive_push.bat diff --git a/CLAUDE.md b/CLAUDE.md index a20fbaee7..f070313f6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +32,7 @@ copying a pattern across: | `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 | | `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 | | `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 | +| `edr-landing` | `@edr/landing` | **Next.js** static export | 5163 | Those are the **fallbacks compiled into the code**, not what you will be running. Every port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite @@ -43,8 +44,16 @@ the whole team and the low ports are contested — see the workspace root `CLAUD Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace packages (see `pnpm-workspace.yaml`). -`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace -package and is not built, linted, or type-checked. Leave it alone unless asked. +`apps/edr-landing/` is the public front door at `edrsc.com`: one static page that routes +visitors to the passenger or freight app. It is a Next.js **static export** +(`output: 'export'`), so its build artifact is `out/`, not `.next/`. It depends on no +workspace package — not even `@edr/ui-common`, whose Tailwind 4 tokens do not fit its +Tailwind 3 setup. + +Its two destinations come from `NEXT_PUBLIC_PASSENGER_URL` and `NEXT_PUBLIC_FREIGHT_URL` +(each an origin; the entry path is appended in `src/lib/apps.ts`). A static export inlines +those at **build** time, so they must be passed as Docker build args — setting them in the +runtime environment does nothing. `apps/edr-gps-tracker/` is a separate service with its own `.env.example`. diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 84e3e4b7a..dea1a6949 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; +import { PublicationsModule } from "./modules/publications/publications.module"; import { OtpModule } from "./modules/otp/otp.module"; import { HealthModule } from "./modules/health/health.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; @@ -108,6 +109,7 @@ import { ExportsModule } from "./modules/exports/exports.module"; import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module"; import { VehiclesModule } from "./modules/vehicles/vehicles.module"; import { DriversModule } from "./modules/drivers/drivers.module"; +import { TrainCrewModule } from "./modules/train-crew/train-crew.module"; import { FuelModule } from "./modules/fuel/fuel.module"; import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; import { ComplianceModule } from "./modules/compliance/compliance.module"; @@ -231,6 +233,7 @@ if (!process.env.APPLICATION_NAME) { LogoSettingsModule, ContractTemplatesModule, SupportContentModule, + PublicationsModule, OtpModule, HealthModule, RuleEngineModule, @@ -250,6 +253,7 @@ if (!process.env.APPLICATION_NAME) { UserTradeAccessModule, VehiclesModule, DriversModule, + TrainCrewModule, FuelModule, MaintenanceModule, ComplianceModule, diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 8e03ac3f0..f88661950 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -17,9 +17,9 @@ export default registerAs("app", () => ({ portalBaseUrl: ( process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173" ).replace(/\/+$/, ""), + // Train weight/length are not env-configured: they come from locomotive + // configuration (see TrainSchedulingService.resolveTrainLimitConfig). trainScheduling: { - maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500), - maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). 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 6a64f6199..6cbb3cfd9 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 @@ -124,6 +124,8 @@ export class ContractDocumentViewModelBuilder { (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, // Ethiopian-customs-only service types resolve to the Ethiopian variant. contract.serviceType?.includesEthiopianCustomsOnly, + // An empty-equipment contract resolves to the carriage-only paper. + contract.cargoCondition, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts index a7b007617..b993aa987 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.spec.ts @@ -96,3 +96,55 @@ describe('ContractRateScheduleBuilder', () => { expect(s.isEmpty).toBe(true); }); }); + +/** + * Empty and laden freight are separate tariffs on the same lanes. Each + * contract's schedule must show only its own, or the printed paper quotes a + * price the customer is not being charged. + */ +describe('ContractRateScheduleBuilder — empty container contracts', () => { + const ladenImport = rate({ + appliesTo: 'CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'CONTAINER_IMPORT', + rateValue: 900, + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }); + + const emptyImport = rate({ + appliesTo: 'EMPTY_CONTAINER', + tradeDirection: 'IMPORT', + rateType: 'EMPTY_CONTAINER_IMPORT', + rateValue: 250, + originYard: { label: 'Negad' } as never, + destinationYard: { label: 'Mojo Dry Port' } as never, + containerType: { label: '40ft GP' } as never, + }); + + const builder = new ContractRateScheduleBuilder({ + findLiveRatesDetailed: jest.fn().mockResolvedValue([ladenImport, emptyImport]), + } as never); + + it('shows only the empty lane on an empty contract', async () => { + const schedule = await builder.build('IMP', 'CON', 'EMPTY'); + + expect(schedule.freightLanes).toHaveLength(1); + expect(schedule.freightLanes[0].amount).toBe('250'); + }); + + it('shows only the laden lane on a laden contract', async () => { + const schedule = await builder.build('IMP', 'CON', 'LADEN'); + + expect(schedule.freightLanes).toHaveLength(1); + expect(schedule.freightLanes[0].amount).toBe('900'); + }); + + it('treats a contract with no condition as laden', async () => { + const schedule = await builder.build('IMP', 'CON'); + + expect(schedule.freightLanes).toHaveLength(1); + expect(schedule.freightLanes[0].amount).toBe('900'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 8990e1b43..56d23ce79 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder { async build( direction: ContractDirection, freight: ContractFreight, + cargoCondition?: string | null, ): Promise { + const isEmpty = cargoCondition === 'EMPTY'; const rates = await this.ratesService.findLiveRatesDetailed(); const freightLanes: RateScheduleRow[] = []; @@ -93,7 +95,7 @@ export class ContractRateScheduleBuilder { for (const rate of rates) { if (this.isBaseFreight(rate)) { - if (this.baseFreightMatches(rate, direction, freight)) { + if (this.baseFreightMatches(rate, direction, freight, isEmpty)) { freightLanes.push(this.laneRow(rate)); } continue; @@ -140,6 +142,7 @@ export class ContractRateScheduleBuilder { rate.trigger === 'ALWAYS' && (rate.appliesTo === 'BULK' || rate.appliesTo === 'CONTAINER' || + rate.appliesTo === 'EMPTY_CONTAINER' || rate.appliesTo === 'INTERCITY') ); } @@ -148,7 +151,19 @@ export class ContractRateScheduleBuilder { rate: Rate, direction: ContractDirection, freight: ContractFreight, + isEmpty = false, ): boolean { + // Empty and laden are separate tariffs on the same lanes, so each contract + // shows only its own. Without this an empty contract would print the laden + // lane prices it is not being charged. + if (isEmpty) { + return ( + rate.appliesTo === 'EMPTY_CONTAINER' && + rate.tradeDirection === (direction === 'EXP' ? 'EXPORT' : 'IMPORT') + ); + } + if (rate.appliesTo === 'EMPTY_CONTAINER') return false; + // Domestic contracts price off intercity rates; the freight kind is carried // in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER). if (direction === 'DOM') { diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 517934bb9..bcea28541 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -140,6 +140,8 @@ export class ContractViewModelBuilder { const rateSchedule = await this.rateScheduleBuilder.build( template.direction, template.freight, + // Empty bookings print the empty tariff, never the laden lane prices. + booking.cargoCondition, ); const signatures = await this.loadSignatures(bookingId); const logoImageUrl = await this.logoSettings.getLogoImageUrl(); diff --git a/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts new file mode 100644 index 000000000..d44f57931 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-Publications.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Public document library for the freight portal (PDFs, Markdown write-ups, + * PowerPoint decks about the platform), managed from the backoffice. Each row + * is one whole file stored in MinIO under `publications/` — a re-upload + * replaces the object and the row's file columns, there is no per-version + * history table like `support_documents` has. + */ +export class Publications3850000000000 implements MigrationInterface { + name = 'Publications3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.publications ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + title varchar(200) NOT NULL, + description text, + category varchar(60), + file_key varchar(512) NOT NULL, + file_name varchar(255) NOT NULL, + file_mime_type varchar(120) NOT NULL, + file_size_bytes bigint NOT NULL, + sort_order integer NOT NULL DEFAULT 0, + published boolean NOT NULL DEFAULT true, + published_at timestamptz, + uploaded_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + // Serves the public list: published rows in display order. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_publications_published_sort + ON freight.publications (published, sort_order) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts b/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts new file mode 100644 index 000000000..95456bbd0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3850000000000-TrainCrewMembers.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew + * composition). Separate from freight.drivers, which registers road/last-mile + * truck drivers and shares none of these fields. + * + * Role and nationality are stored as varchar rather than PG enums so adding a + * crew role later is an application change, not a type migration. The partial + * unique index keys on name + role — the roster has no employee number yet, so + * that is the only identity available to block an accidental re-entry; it is + * scoped to live rows so a soft-deleted member does not hold the name hostage. + */ +export class TrainCrewMembers3850000000000 implements MigrationInterface { + name = 'TrainCrewMembers3850000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_crew_members ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + first_name varchar(100) NOT NULL, + last_name varchar(100) NOT NULL, + role varchar(32) NOT NULL, + nationality varchar(16) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'ACTIVE', + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_train_crew_role CHECK (role IN ( + 'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN', + 'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER' + )), + CONSTRAINT chk_train_crew_nationality CHECK (nationality IN ( + 'ETHIOPIAN','DJIBOUTIAN' + )), + CONSTRAINT chk_train_crew_status CHECK (status IN ( + 'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE' + )) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_crew_members_role + ON freight.train_crew_members (role) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality + ON freight.train_crew_members (nationality) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_crew_members_status + ON freight.train_crew_members (status) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active + ON freight.train_crew_members (is_active) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role + ON freight.train_crew_members (lower(first_name), lower(last_name), role) + WHERE deleted_at IS NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts new file mode 100644 index 000000000..8d0f52110 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-AddDjfPaymentsCurrency.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds DJF to `freight.payments_currency_enum` — the only currency column in + * the schema backed by a real Postgres enum (every other currency column is + * a plain varchar and needed no migration). + * + * This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD + * VALUE` cannot be used within the same transaction that added it (Postgres + * restriction, still true on PG 12+), and migrations here run one-per- + * transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert + * that writes 'DJF' into `payments.currency` to this file. + */ +export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface { + name = 'AddDjfPaymentsCurrency3860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`); + } + + public async down(): Promise { + // Postgres cannot drop a single enum value. Reverting would require + // recreating the type and every dependent column/constraint — out of + // scope for a currency addition; leave it in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts new file mode 100644 index 000000000..04dba29ba --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3860000000000-TrainCrewAssignments.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2). + * + * `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1 + * splits four drivers across the Dire Dawa boundary, and a driver who is + * Primary on one run is Assistant on the next. `role` is snapshotted so a later + * roster edit cannot rewrite the crew of a run that already departed. + * + * The partial unique index on (schedule, segment, duty_role) applies to drivers + * only — one segment cannot have two Primaries, while four federal police on + * the same run carry no segment or duty role and are unconstrained by it. + */ +export class TrainCrewAssignments3860000000000 implements MigrationInterface { + name = 'TrainCrewAssignments3860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_crew_assignments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id uuid NOT NULL, + crew_member_id uuid NOT NULL + REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT, + role varchar(32) NOT NULL, + duty_role varchar(16), + segment varchar(24), + crewing_case varchar(8), + layover_start_at timestamptz, + layover_end_at timestamptz, + duty_start_at timestamptz, + duty_end_at timestamptz, + status varchar(16) NOT NULL DEFAULT 'PLANNED', + notes text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_crew_assignment_duty_role CHECK ( + duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF') + ), + CONSTRAINT chk_crew_assignment_segment CHECK ( + segment IS NULL OR segment IN + ('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR') + ), + CONSTRAINT chk_crew_assignment_case CHECK ( + crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2') + ), + CONSTRAINT chk_crew_assignment_status CHECK ( + status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED') + ) + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule + ON freight.train_crew_assignments (train_schedule_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_crew_assignments_member + ON freight.train_crew_assignments (crew_member_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_crew_assignments_status + ON freight.train_crew_assignments (status) + `); + // Nobody holds two seats on one run. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member + ON freight.train_crew_assignments (train_schedule_id, crew_member_id) + WHERE deleted_at IS NULL + `); + // One Primary (and one Assistant) per segment — drivers only. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot + ON freight.train_crew_assignments (train_schedule_id, segment, duty_role) + WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL + `); + // Monthly overtime rollups scan a member's duty spans (§3.1). + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window + ON freight.train_crew_assignments (crew_member_id, duty_start_at) + WHERE duty_start_at IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts new file mode 100644 index 000000000..6353e8664 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-AddDjfManualPaymentSetting.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the DJF toggle to `manual_payment_settings`, alongside the existing + * `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF + * invoices are bank-transfer-settleable from day one. + */ +export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface { + name = 'AddDjfManualPaymentSetting3870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts new file mode 100644 index 000000000..2feaef0b4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3870000000000-DropCrewingCase.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop `crewing_case` from train crew assignments. + * + * The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock + * §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to + * its own need instead — any number of drivers, each carrying their own segment + * and duty role — so the case has nothing left to select and the column no + * longer has a meaning. The §1.1 territorial boundary is unaffected: it is a + * per-driver rule and still enforced. + */ +export class DropCrewingCase3870000000000 implements MigrationInterface { + name = 'DropCrewingCase3870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + DROP CONSTRAINT IF EXISTS chk_crew_assignment_case + `); + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + DROP COLUMN IF EXISTS crewing_case + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + ADD COLUMN IF NOT EXISTS crewing_case varchar(8) + `); + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + ADD CONSTRAINT chk_crew_assignment_case CHECK ( + crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2') + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts new file mode 100644 index 000000000..37607802d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-CrewLegYards.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Driver legs become yard-to-yard instead of three fixed corridor segments. + * + * The old `segment` enum could only express Indode–Dire Dawa, Dire Dawa–Nagad + * or the full corridor. Operations hand over at other yards too (Feto, Meiso, + * Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation + * places), so a leg is now any two yards on the schedule's route. + * + * `segment` is kept, nullable, so rows written before this still read back; it + * is never populated again. The §1.1 territorial boundary is unaffected — it + * now derives from yard position rather than the segment name, so a Djibouti + * driver is still confined to Dire Dawa and eastward. + * + * The driver-slot unique index moves with it: one Primary per leg, where a leg + * is the (from, to) pair rather than a segment name. + */ +export class CrewLegYards3880000000000 implements MigrationInterface { + name = 'CrewLegYards3880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id), + ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id) + `); + + // Backfill the three legacy segments onto real yards so historic rows keep + // a usable leg. Matched by code; a deployment missing one simply leaves + // those rows with a null leg, which the validator reports as incomplete. + await queryRunner.query(` + UPDATE freight.train_crew_assignments a + SET from_yard_id = f.id, to_yard_id = t.id + FROM freight.yards f, freight.yards t + WHERE a.segment = 'INDODE_DIRE_DAWA' + AND a.from_yard_id IS NULL + AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA' + `); + await queryRunner.query(` + UPDATE freight.train_crew_assignments a + SET from_yard_id = f.id, to_yard_id = t.id + FROM freight.yards f, freight.yards t + WHERE a.segment = 'DIRE_DAWA_NAGAD' + AND a.from_yard_id IS NULL + AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD' + `); + await queryRunner.query(` + UPDATE freight.train_crew_assignments a + SET from_yard_id = f.id, to_yard_id = t.id + FROM freight.yards f, freight.yards t + WHERE a.segment = 'FULL_CORRIDOR' + AND a.from_yard_id IS NULL + AND f.code = 'KALITY' AND t.code = 'NAGAD' + `); + + // One Primary per leg replaces one Primary per segment. + await queryRunner.query(` + DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg + ON freight.train_crew_assignments + (train_schedule_id, from_yard_id, to_yard_id, duty_role) + WHERE deleted_at IS NULL + AND from_yard_id IS NOT NULL + AND to_yard_id IS NOT NULL + AND duty_role IS NOT NULL + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg + ON freight.train_crew_assignments (from_yard_id, to_yard_id) + WHERE from_yard_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot + ON freight.train_crew_assignments (train_schedule_id, segment, duty_role) + WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL + `); + await queryRunner.query(` + ALTER TABLE freight.train_crew_assignments + DROP COLUMN IF EXISTS from_yard_id, + DROP COLUMN IF EXISTS to_yard_id + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts new file mode 100644 index 000000000..3677e6269 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3880000000000-ExchangeSettingsPerCurrency.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * `exchange_settings` was a single-row table holding the USD→ETB fallback + * only. Restructures it to one row per currency so DJF (and any future + * currency) gets its own fallback rate, source and sync timestamp instead of + * a parallel column per currency. + */ +export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface { + name = 'ExchangeSettingsPerCurrency3880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5); + `); + // The single pre-existing row was always the USD→ETB fallback. + await queryRunner.query(` + UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL; + `); + await queryRunner.query(` + ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency + ON freight.exchange_settings (currency) WHERE deleted_at IS NULL; + `); + // Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so + // pricing has a usable fallback before the first successful CBE fetch. + await queryRunner.query(` + INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at) + SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now() + WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF'); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts new file mode 100644 index 000000000..8f14a7123 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-EmptyContainerRateScope.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Empty container import is base rail freight for equipment carrying no cargo, + * so it is sold per lane exactly like laden container freight. + * + * CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an + * empty rate prices a leg (Djibouti -> Modjo), so both yards stay required. + * Drop-and-recreate is the established shape for this constraint — see + * 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance. + */ +export class EmptyContainerRateScope3890000000000 implements MigrationInterface { + name = 'EmptyContainerRateScope3890000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR + CASE + WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts new file mode 100644 index 000000000..34754e4f2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3900000000000-BookingCargoCondition.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Whether a booking moves cargo or bare equipment. + * + * EMPTY is container freight carrying nothing — the box itself is the shipment, + * priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a + * separate column rather than a third `freight_type`: an empty booking is still + * CONTAINER freight for wagon footprint, yard and warehouse allocation, train + * scheduling, marshalling and gate passes, and `freight_type` is read in ~880 + * places whose else-arm means "container". + * + * Every existing row is LADEN, which the default supplies — no backfill needed. + */ +export class BookingCargoCondition3900000000000 implements MigrationInterface { + name = 'BookingCargoCondition3900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN' + `); + + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition" + `); + // Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER + // freight. Enforced here so no API path can file the combination. + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK ( + cargo_condition IN ('LADEN', 'EMPTY') + AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER') + ) + `); + + // The booking queues filter empties out of (and into) the laden lists. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition + ON freight.bookings (cargo_condition) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts new file mode 100644 index 000000000..950fa64c0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3910000000000-EmptyContainerContractTemplate.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults'; + +/** + * Contract paper for empty container import. + * + * - contracts.cargo_condition mirrors bookings.cargo_condition, so a general + * contract can commit to moving bare equipment. + * - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer + * resolves for those contracts. It carries no customs variant: an empty box + * has no declaration to clear, the same reason intercity is unsuffixed. + */ +const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const; + +export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface { + name = 'EmptyContainerContractTemplate3910000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN' + `); + + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition" + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK ( + cargo_condition IN ('LADEN', 'EMPTY') + AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER') + ) + `); + + 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::varchar[]) AND is_system = true`, + [[...SEEDED_CODES]], + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts new file mode 100644 index 000000000..d51ae6ddd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3920000000000-ContractExtensionRequest.ts @@ -0,0 +1,99 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-requested validity extension of an EXPIRED contract. + * + * Flow: the customer asks from the portal (`extension_requested_at` is stamped, + * the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff + * add days on the backoffice detail page and the contract returns to the status + * it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on + * read) now stash that status in `status_before_expiry`, mirroring + * `status_before_suspension`; rows expired before this column existed fall + * back to the kind's resting status on extension. + * + * Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder` + * resolves every registry key against `iam.permissions` at boot and throws on + * a missing row, so the catalog row must exist wherever the registry ships. + * The grant is copied from whoever already holds `contracts:suspend` — the + * registry places both keys on the same desk (marketing), and the position + * seeder only re-syncs presets when SEED_EDR_ORG is set. + */ +export class ContractExtensionRequest3920000000000 implements MigrationInterface { + name = 'ContractExtensionRequest3920000000000'; + + private static readonly KEY = 'edr_freight_app:contracts:extend'; + private static readonly ID = 'a3000001-0001-4000-8000-00000000001d'; + private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL + `); + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL + `); + + await queryRunner.query( + `INSERT INTO iam.permissions (id, key, name, application_id) + SELECT $2::uuid, + $1::varchar, + '{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb, + a.id + FROM iam.application a + WHERE a.key = 'edr_freight_app' + AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID], + ); + + // Grant wherever suspend is already granted (positions and roles alike). + await queryRunner.query( + `INSERT INTO iam.position_permissions (position_id, permission_id) + SELECT pp.position_id, np.id + FROM iam.position_permissions pp + JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar + JOIN iam.permissions np ON np.key = $1::varchar + WHERE NOT EXISTS ( + SELECT 1 FROM iam.position_permissions x + WHERE x.position_id = pp.position_id AND x.permission_id = np.id + )`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY], + ); + await queryRunner.query( + `INSERT INTO iam.role_permissions (role_id, permission_id) + SELECT rp.role_id, np.id + FROM iam.role_permissions rp + JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar + JOIN iam.permissions np ON np.key = $1::varchar + WHERE NOT EXISTS ( + SELECT 1 FROM iam.role_permissions x + WHERE x.role_id = rp.role_id AND x.permission_id = np.id + )`, + [ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY], + ); + } + + /** Grants go first, or the delete trips the permission foreign keys. */ + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM iam.position_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [ContractExtensionRequest3920000000000.KEY], + ); + await queryRunner.query( + `DELETE FROM iam.role_permissions + WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`, + [ContractExtensionRequest3920000000000.KEY], + ); + await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [ + ContractExtensionRequest3920000000000.KEY, + ]); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`, + ); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index fa00fb521..6d416a7e3 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -126,7 +126,7 @@ export class FilterInvoiceDto { @ApiPropertyOptional({ enum: ["USD", "ETB"] }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) - @IsIn(["USD", "ETB"]) + @IsIn(["ETB", "USD", "DJF"]) currency?: "USD" | "ETB"; @ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." }) diff --git a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts index 012d5f8db..86cad4277 100644 --- a/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/additional-charge.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { OnEvent } from '@nestjs/event-emitter'; import { DataSource, EntityManager } from 'typeorm'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; @@ -291,20 +291,24 @@ export class AdditionalChargeService { } /** - * Amount converted to the other of ETB/USD, via the existing shared + * Amount converted to a second reference currency, via the existing shared * `ExchangeService` (CBE rate, falls back to the stored `exchange_settings` * rate) — same mechanism `booking-wagon-cancellation.service.ts` and - * warehouse fee pricing already use. Null on anything but ETB/USD, or if + * warehouse fee pricing already use. ETB converts to USD and vice versa + * (unchanged behaviour); any other supported currency (DJF) converts to + * USD, the system's pivot currency. Null on an unsupported currency, or if * the rate feed is down — this is a display convenience, not the payable * amount, so a failure here must never break the charge list. */ private async convertAmount( charge: AdditionalCharge, ): Promise<{ amount: number; currency: string } | null> { - if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null; - const target = charge.currency === 'ETB' ? 'USD' : 'ETB'; + const from = charge.currency?.toUpperCase(); + if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null; + const source = from as CurrencyCode; + const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD'; try { - const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target); + const amount = await this.exchangeService.convert(Number(charge.amount), source, target); return { amount: Math.round(amount * 100) / 100, currency: target }; } catch (err) { this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts index e3e97f301..1f0da457a 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -1,6 +1,6 @@ import { BadRequestException } from '@nestjs/common'; -import { FREIGHT_TYPES, FreightType } from './entities/booking.entity'; +import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity'; import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator'; /** Normalize and validate booking freight shape (used on create and after update merge). */ @@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void { } // + const condition = input.cargoCondition ?? 'LADEN'; + if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) { + throw new BadRequestException( + `cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`, + ); + } + const containers = input.containers ?? []; const hasContainers = containers.length > 0; const hasCargoType = Boolean(input.cargoTypeId); + // Empty means bare equipment: there is no commodity to name, and bulk has no + // equipment of its own to move, so EMPTY only ever rides CONTAINER freight. + if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') { + throw new BadRequestException( + 'An empty booking must be CONTAINER freight — bulk carries no equipment', + ); + } + if (input.freightType === 'BULK') { if (hasContainers) { throw new BadRequestException( diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index ba1aaa875..dcecb0c14 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let exchangeService: { getRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; @@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => { }; exchangeService = { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + // Delegates to `getRate` so a test that reassigns + // `exchangeService.getRate.mockResolvedValue(...)` gets a consistent + // rate table without also having to touch this mock. + getRateTable: jest.fn(async (target: string) => { + const rate = await exchangeService.getRate('USD', target); + return { ETB: rate, USD: rate, DJF: rate }; + }), }; service = new BookingPricingService( @@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking })), } as never, { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => { } as never, { findById: jest.fn() } as never, { findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn().mockResolvedValue({ @@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => { })), } as never, { findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never, - { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, { findById: jest.fn() } as never, { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, @@ -772,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => { expect(line.amount).toBe(3 * 1690); }); }); + +/** + * Empty container import is bare equipment moved as freight in its own right. + * It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT + * rate for the same lane and box — the two are separate tariffs, and + * UQ_rates_pattern only lets both exist because the rateType differs. + */ +describe('BookingPricingService — empty container import', () => { + const DJIBOUTI = 'yard-djibouti'; + const CT40 = 'ct-40ft'; + + const ladenImport40: Rate = { + id: 'rate-container-import-40', + rateType: 'CONTAINER_IMPORT', + currency: 'USD', + rateValue: 900, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: CT40, + originYardId: DJIBOUTI, + destinationYardId: MOJO, + } as Rate; + + const emptyImport40: Rate = { + id: 'rate-empty-container-import-40', + rateType: 'EMPTY_CONTAINER_IMPORT', + currency: 'USD', + rateValue: 250, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: CT40, + originYardId: DJIBOUTI, + destinationYardId: MOJO, + } as Rate; + + let service: BookingPricingService; + + const priceLines = (booking: Booking) => + ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> }, + ) => Promise<{ + lineItems: Array<{ code: string; amount: number; description: string }>; + blocked: string[]; + }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }], + }); + + const bookingWith = (cargoCondition: string) => + ({ + id: 'b-empty-1', + freightType: 'CONTAINER', + cargoCondition, + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + // Bare equipment declares no VGM — the service zeroes it at create. + cargoTotalWeightVgm: 0, + originYardId: DJIBOUTI, + destinationYardId: MOJO, + bookingContainers: [], + }) as unknown as Booking; + + beforeEach(() => { + const exchangeService = { + getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }), + }; + service = new BookingPricingService( + { calculateWagonCount: jest.fn().mockResolvedValue(4) } as never, + {} as never, + { findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never, + { findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never, + exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + {} as never, + { findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never, + ); + }); + + it('prices an empty booking off the empty tariff, not the laden one', async () => { + const result = await priceLines(bookingWith('EMPTY')); + + expect(result.lineItems).toHaveLength(1); + expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT'); + expect(result.lineItems[0].amount).toBe(250 * 4); + expect(result.lineItems[0].description).toContain('empty'); + }); + + it('leaves laden bookings on the laden tariff', async () => { + const result = await priceLines(bookingWith('LADEN')); + + expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT'); + expect(result.lineItems[0].amount).toBe(900 * 4); + }); + + it('treats a booking with no condition set as laden', async () => { + const booking = bookingWith('LADEN'); + delete (booking as unknown as Record).cargoCondition; + + const result = await priceLines(booking); + + expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT'); + }); + + it('hard-blocks an empty booking on a lane with no empty rate configured', async () => { + ( + service as unknown as { ratesService: { findLiveRates: jest.Mock } } + ).ratesService.findLiveRates.mockResolvedValue([ladenImport40]); + + const result = await priceLines(bookingWith('EMPTY')); + + // Never silently fall through to the laden rate — that would bill an empty + // repositioning move at 900/box instead of 250. + expect(result.lineItems).toHaveLength(0); + expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 29104bbce..4a745e16d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity'; import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -143,8 +143,9 @@ export class BookingPricingService { const ruleResult = await this.ruleEngineService.evaluate(evalInput); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; // H15: a booking created under a contract prices from that contract's FROZEN // rate snapshots (the agreed rates), not the live rate of the day. Loaded @@ -213,7 +214,7 @@ export class BookingPricingService { // route's container freight, never a frozen OVERWEIGHT_PER_TON value. const frozen = isDerived ? null - : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb); + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -248,8 +249,10 @@ export class BookingPricingService { // box or per wagon), bulk bookings the route's bulk fee (per ton or per // wagon). Frozen contract snapshots win over live rates; a customs booking // with nothing configured hard-blocks — clearance never ships for free. + // An empty box carries no declaration and no duty, so there is no clearance + // to sell even if a customs-bundled service type was somehow selected. const clearanceBlocked: string[] = []; - if (booking.customsClearingEnabled) { + if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') { const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates); for (const line of clearance.lineItems) { lineItems.push(line); @@ -570,12 +573,21 @@ export class BookingPricingService { }> { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const isBulk = booking.freightType === 'BULK'; + // Bare equipment prices off its own tariff. It has to be a distinct + // rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on + // rate_type without applies_to, so an empty 40ft rate on a lane would + // collide with the laden 40ft rate for that same lane. + const isEmpty = booking.cargoCondition === 'EMPTY'; - const rateType = - booking.tradeDirection === 'IMPORT' + const rateType = isEmpty + ? booking.tradeDirection === 'EXPORT' + ? 'EMPTY_CONTAINER_EXPORT' + : 'EMPTY_CONTAINER_IMPORT' + : booking.tradeDirection === 'IMPORT' ? isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT' @@ -608,7 +620,7 @@ export class BookingPricingService { frozenRates, container.containerTypeId, paymentCurrency, - usdToEtb, + fx, ); const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { @@ -649,7 +661,7 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); lines.push({ code: rateType, - description: `${label} rail freight`, + description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`, amount, unitAmount, unit: rateUnit, @@ -698,7 +710,7 @@ export class BookingPricingService { const unitUsd = Number(fallback.rateValue); // H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present. const frozen = isBulk - ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb) + ? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, fx) : null; let amount: number; let unitAmount: number; @@ -771,8 +783,9 @@ export class BookingPricingService { const liveRates = await this.liveRatesForBooking(booking); const paymentCurrency = booking.paymentCurrency; - const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const isEtbBooking = paymentCurrency !== 'USD'; + const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode); + const usdToEtb = fx['USD']; const containerCount = evalInput.containers.reduce( (sum, c) => sum + Number(c.quantity || 0), @@ -824,7 +837,7 @@ export class BookingPricingService { frozenRates, leg.rateType, paymentCurrency, - usdToEtb, + fx, ); let amount: number; let unitAmount: number; @@ -1021,13 +1034,18 @@ export class BookingPricingService { * drifted to.) Grandfathered ETB contracts convert the other way for the same * reason. * + * `fx` is a rate table converting FROM each source currency INTO the + * booking's currency (see `ExchangeService.getRateTable`) — a snapshot can + * be frozen in USD or (grandfathered) ETB, and the booking can be paid in + * any supported currency, so a scalar USD→ETB rate is no longer enough. + * * Returns null only when there is no snapshot or its price is unusable. */ private frozenRateByCode( frozenRates: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null { const snap = frozenRates?.get(code); if (!snap) return null; @@ -1035,15 +1053,11 @@ export class BookingPricingService { if (!(unitPrice >= 0)) return null; if (snap.currency === bookingCurrency) return snap; - // Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price. - if (!(usdToEtb > 0)) return null; - const converted = - snap.currency === 'USD' && bookingCurrency === 'ETB' - ? round2(unitPrice * usdToEtb) - : snap.currency === 'ETB' && bookingCurrency === 'USD' - ? unitPrice / usdToEtb - : null; - if (converted == null) return null; + // A rate of 0/NaN (an unpriced or unsupported source currency) would + // silently zero the price. + const rate = fx[snap.currency]; + if (!(rate > 0)) return null; + const converted = round2(unitPrice * rate); // A copy — the snapshot rows are shared across the pricing pass. return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, { @@ -1061,7 +1075,7 @@ export class BookingPricingService { frozenRates: Map | null, containerTypeId: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): Promise { if (!frozenRates) return null; let sizeFt: number | null = null; @@ -1071,7 +1085,7 @@ export class BookingPricingService { return null; } if (!sizeFt) return null; - return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb); + return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, fx); } /** @@ -1093,9 +1107,9 @@ export class BookingPricingService { const usedRates: Rate[] = []; const blocked: string[] = []; const currency = booking.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + const fx = await this.exchangeService.getRateTable(currency as CurrencyCode); + const usdToEtb = fx['USD']; + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb)); // An Ethiopian-side-only customs service prices off its own rate; the // contract froze its snapshots under the matching code prefix. Resolved by @@ -1132,7 +1146,7 @@ export class BookingPricingService { const hasPerSizeSnapshot = frozenRates?.has(`${customsType}_20FT`) || frozenRates?.has(`${customsType}_40FT`); - const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx); if (legacyFlat && !hasPerSizeSnapshot) { const amount = Number(legacyFlat.unitPrice); if (amount > 0) { @@ -1161,7 +1175,7 @@ export class BookingPricingService { // unknown type — falls through to the live per-type lookup below } const frozen = sizeFt - ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb) + ? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx) : null; const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); if (!frozen && !live) { @@ -1196,7 +1210,7 @@ export class BookingPricingService { // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. // Live lookup: the rate scoped to the booking's commodity wins; a // commodity-less rate (legacy) is the catch-all fallback. - const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb); + const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx); const live = (booking.cargoTypeId ? onLeg.find( 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 983564a51..a35e29663 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 @@ -8,7 +8,7 @@ import { NotFoundException, } from '@nestjs/common'; import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, EntityManager, In, IsNull } from 'typeorm'; @@ -114,6 +114,15 @@ interface PricedFee { * The cycle is repeatable by construction: the rebooked booking is a normal * PAID booking, so it can itself be partially cancelled again. */ + +/** Validates a stored currency string against the supported set, defaulting to USD. */ +function toCurrencyCode(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; +} + @Injectable() export class BookingWagonCancellationService { private readonly logger = new Logger(BookingWagonCancellationService.name); @@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService { */ private async priceFee(booking: Booking, cut: RequestedCut): Promise { const raw = await this.priceFeeInRateCurrency(booking, cut); - // Bill in the booking's own currency (rates are configured in USD; ETB - // bookings pay ETB) — same USD→ETB conversion booking pricing applies. - const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD'; - const from = raw.currency === 'ETB' ? 'ETB' : 'USD'; + // Bill in the booking's own currency (rates are configured in USD; a + // non-USD booking converts) — same conversion booking pricing applies. + const target = toCurrencyCode(booking.paymentCurrency); + const from = toCurrencyCode(raw.currency); if (from === target) return raw; const fx = await this.exchangeService.getRate(from, target); return { diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index ea901bb16..83daa0286 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1106,11 +1106,13 @@ ${footer} const containers = await Promise.all( containerLines.map(async (c) => { const ct = await this.containerTypesService.findById(c.containerTypeId); - const totalVgmTons = c.quantity * c.vgmPerUnitTons; + // Optional on the DTO — an empty booking states no VGM at all. + const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0); + const totalVgmTons = c.quantity * vgmPerUnitTons; return { containerTypeId: c.containerTypeId, quantity: c.quantity, - vgmPerUnitTons: c.vgmPerUnitTons, + vgmPerUnitTons, totalVgmTons, isReefer: ct.isReefer, wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt), @@ -1375,13 +1377,25 @@ ${footer} } } - const containers = dto.containers ?? []; + const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN'; + const isEmpty = cargoCondition === 'EMPTY'; assertFreightShape({ freightType: dto.freightType, + cargoCondition, cargoTypeId: dto.cargoTypeId, - containers, + containers: dto.containers ?? [], }); + // Bare equipment declares no VGM. Zero the lines HERE, before the rule + // engine sees them, so weight-limit and overweight evaluation, the wagon + // estimate, the persisted rows and every tonnage aggregate downstream all + // read the same figure — a stray VGM on an empty line would otherwise price + // an overweight surcharge on a box with nothing in it. + const containers = (dto.containers ?? []).map((c) => ({ + ...c, + vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0), + })); + const tradeDirection = await this.resolveTradeDirectionForBooking( dto.originYardId, dto.destinationYardId, @@ -1506,10 +1520,11 @@ ${footer} destinationYardId: dto.destinationYardId, tradeDirection, freightType: dto.freightType, + cargoCondition, cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null, cargoFreeText: dto.cargoFreeText, shippingLineId: dto.shippingLineId, - cargoTotalWeightVgm: dto.cargoTotalWeightVgm, + cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm, // Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK. bulkTotalWeightTons: dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null, @@ -1647,6 +1662,11 @@ ${footer} const warnings: string[] = []; const freightType = (dto.freightType ?? existing.freightType) as FreightType; + // A draft may be switched between laden and empty; an untouched draft keeps + // whatever it was created as. + const cargoCondition = + (dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN'; + const isEmpty = cargoCondition === 'EMPTY'; let containers = dto.containers ?? (existing.bookingContainers ?? []) @@ -1672,7 +1692,14 @@ ${footer} } } - assertFreightShape({ freightType, cargoTypeId, containers }); + // Same normalisation as create: zero the VGM of an empty booking before the + // rule engine, the wagon estimate or the persisted rows ever read it. + containers = containers.map((c) => ({ + ...c, + vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0), + })); + + assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers }); const originYardId = dto.originYardId ?? existing.originYardId; const destinationYardId = dto.destinationYardId ?? existing.destinationYardId; @@ -1719,6 +1746,9 @@ ${footer} const updates: Record = { ...dto, freightType, + cargoCondition, + // Bare equipment declares no VGM, whichever way the draft was edited. + cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount, cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, // Break-bulk actual tonnage; cleared when the booking leaves BULK. bulkTotalWeightTons: @@ -1825,10 +1855,12 @@ ${footer} await this.bookingsRepository.deleteContainers(id); await this.bookingsRepository.createContainers( id, + // Index-aligned with ruleResult, which evaluated these same lines. dto.containers.map((c, i) => ({ containerTypeId: c.containerTypeId, quantity: c.quantity, - vgmPerUnitTons: c.vgmPerUnitTons, + // Bare equipment declares no VGM — same normalisation the rule engine saw. + vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0), hazardousQuantity: c.hazardousQuantity, reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index a9aca53dd..a88c3f4c3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -18,7 +18,12 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { + BOOKING_STATUSES, + BOOKING_TYPES, + CARGO_CONDITIONS, + FREIGHT_TYPES, +} from '../entities/booking.entity'; import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; @@ -47,11 +52,20 @@ export class CreateBookingContainerDto { @Transform(({ value }) => Number(value)) quantity!: number; - @ApiProperty({ description: 'VGM per container in tons', minimum: 0 }) + /** + * Omitted on an empty booking — bare equipment has no verified gross mass to + * declare, and the service zeroes the line rather than trusting a stray value. + */ + @ApiPropertyOptional({ + description: 'VGM per container in tons. Omit for an EMPTY booking', + minimum: 0, + default: 0, + }) + @IsOptional() @IsNumber() @Min(0) - @Transform(({ value }) => Number(value)) - vgmPerUnitTons!: number; + @Transform(({ value }) => Number(value ?? 0)) + vgmPerUnitTons?: number; @ApiPropertyOptional({ description: 'How many of this line are hazardous (0..quantity)', @@ -312,6 +326,20 @@ export class CreateBookingDto { @IsIn([...FREIGHT_TYPES]) freightType!: string; + /** + * LADEN (default) or EMPTY. EMPTY is container freight carrying nothing — + * the box itself is the shipment, priced per size and lane off an + * EMPTY_CONTAINER_IMPORT rate. + */ + @ApiPropertyOptional({ + enum: CARGO_CONDITIONS, + default: 'LADEN', + description: 'EMPTY moves bare equipment; requires CONTAINER freight', + }) + @IsOptional() + @IsIn([...CARGO_CONDITIONS]) + cargoCondition?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Required for BULK; must be omitted for CONTAINER', @@ -330,10 +358,14 @@ export class CreateBookingDto { @IsUUID() shippingLineId?: string; - @ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 }) + @ApiProperty({ + description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking', + minimum: 0, + }) + @ValidateIf((o) => o.cargoCondition !== 'EMPTY') @IsNumber() @Min(0) - @Transform(({ value }) => Number(value)) + @Transform(({ value }) => Number(value ?? 0)) cargoTotalWeightVgm!: number; /** diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts index 1365158b1..c3417d62d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity'; export interface BookingFreightShapeInput { freightType?: string; + /** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */ + cargoCondition?: string | null; cargoTypeId?: string | null; containers?: Array<{ containerTypeId?: string }> | null; } @@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa return true; } + // Bulk carries no equipment of its own, so an empty booking is always + // container freight. Rejected here as well as in assertFreightShape so the + // 400 names the field instead of surfacing from the service layer. + if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') { + return false; + } + const containers = dto.containers ?? []; const hasContainers = containers.length > 0; const hasCargoType = @@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa defaultMessage(args: ValidationArguments): string { const dto = args.object as BookingFreightShapeInput; + if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') { + return 'An empty booking must be CONTAINER freight — bulk carries no equipment'; + } if (dto.freightType === 'BULK') { return 'BULK freight requires cargoTypeId and must not include container lines'; } 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 9cf728a0d..b457dd259 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 @@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number]; export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; export type FreightType = (typeof FREIGHT_TYPES)[number]; +/** + * Whether the booking moves cargo or bare equipment. EMPTY is container + * freight with nothing inside: the box IS the shipment, priced per size and + * lane off an EMPTY_CONTAINER_IMPORT rate. + * + * This is deliberately NOT a third `freightType`. An empty booking is still + * CONTAINER freight everywhere it matters physically — wagon footprint, yard + * and warehouse allocation, train scheduling, marshalling, gate passes — and + * `freightType` is read in ~880 places whose else-arm means "container". Only + * pricing, documents, customs and the contract template branch on condition. + */ +export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const; +export type CargoCondition = (typeof CARGO_CONDITIONS)[number]; + export const SCHEDULING_STATUSES = [ SchedulingStatus.NotScheduled, SchedulingStatus.Holding, @@ -388,6 +402,13 @@ export class Booking extends BaseEntity { @Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true }) freightType!: string; + /** + * LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY + * on CONTAINER freight — bulk has no equipment to move on its own. + */ + @Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' }) + cargoCondition!: string; + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) cargoTypeId?: string | null; 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 50a912a1d..9aa071fc9 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 @@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => { it('only ever resolves to a code that exists', () => { const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null]; const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null]; + const conditions = ['LADEN', 'EMPTY', null, undefined]; for (const d of directions) { for (const f of freights) { for (const c of [true, false]) { for (const e of [true, false, undefined]) { - expect(CONTRACT_TEMPLATE_CODES).toContain( - contractTemplateCodeFor(d, f, c, e), - ); + for (const cond of conditions) { + expect(CONTRACT_TEMPLATE_CODES).toContain( + contractTemplateCodeFor(d, f, c, e, cond), + ); + } } } } } }); + + // Empty equipment is a carriage agreement, not a cargo contract: no cargo + // liability, no VGM declaration, no commercial documents, no customs leg. + it('gives empty container import its own customs-free paper', () => { + for (const customs of [true, false]) { + for (const ethiopian of [true, false, undefined]) { + expect( + contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'), + ).toBe('IMPORT_EMPTY_CONTAINER'); + } + } + }); + + it('leaves laden contracts on the laden codes', () => { + expect( + contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'), + ).toBe('IMPORT_CONTAINER_NO_CUSTOMS'); + expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe( + 'IMPORT_CONTAINER_NO_CUSTOMS', + ); + }); + + // Empty rates and empty bookings are import-only, so a stray EMPTY on any + // other direction must fall through rather than resolve a template that + // describes a Djibouti-to-Ethiopia movement. + it('ignores the empty condition outside import', () => { + expect( + contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'), + ).toBe('EXPORT_CONTAINER_NO_CUSTOMS'); + expect( + contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'), + ).toBe('INTERCITY_CONTAINER'); + }); }); describe('CONTRACT_TEMPLATE_DEFAULTS', () => { - it('seeds exactly the fourteen declared codes, once each', () => { + it('seeds exactly the fifteen declared codes, once each', () => { const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort(); - expect(seeded).toHaveLength(14); + expect(seeded).toHaveLength(15); expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort()); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index 11cf412fa..d760333af 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record = { EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING", EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY", INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY", + // Carriage of the equipment itself — no cargo, no clearing, so it previews + // against the transport-only scope like every other non-customs code. + IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY", }; @Injectable() @@ -216,6 +219,7 @@ export class ContractTemplatesService { customsClearingEnabled?: boolean | null, cargoTypeId?: string | null, ethiopianCustomsOnly?: boolean | null, + cargoCondition?: string | null, ): Promise { const isBulk = (freightType ?? "").toUpperCase().includes("BULK"); if (isBulk) { @@ -235,6 +239,7 @@ export class ContractTemplatesService { freightType, customsClearingEnabled, ethiopianCustomsOnly, + cargoCondition, ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; 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 f94e5d52f..4cbf81316 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 @@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [ "EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS", "EXPORT_CONTAINER_NO_CUSTOMS", "INTERCITY_CONTAINER", + /** + * Empty container import — bare equipment railed north from Djibouti. No + * customs split: an empty box carries no declaration to clear, the same + * reason intercity has a single unsuffixed code. Import-only, matching the + * rate rule (southbound empties are served by the WITH_RETURN surcharge and + * empty_return_requests instead). + */ + "IMPORT_EMPTY_CONTAINER", ] as const; export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number]; @@ -74,7 +82,14 @@ export function contractTemplateCodeFor( freightType?: string | null, customsClearingEnabled?: boolean | null, ethiopianCustomsOnly?: boolean | null, + cargoCondition?: string | null, ): ContractTemplateCode { + // Empty equipment is its own paper: a straight carriage agreement with no + // cargo liability, no VGM declaration and no customs leg. Import-only, so + // anything else falls through to the laden codes below. + if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") { + return "IMPORT_EMPTY_CONTAINER"; + } const direction = tradeDirection === "IMPORT" ? "IMPORT" diff --git a/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts new file mode 100644 index 000000000..da3fdf04f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-extension.spec.ts @@ -0,0 +1,170 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * A lapsed contract comes back only on the customer's say-so: they ask once, + * staff add days, and the contract lands back where it was before it expired. + * Those three rules are the feature. + */ +describe('ContractTransitionService — extension request / extend', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + contractKind: 'GENERAL', + status: 'EXPIRED', + freightType: 'CONTAINER', + contractValidUntil: new Date('2026-01-31T21:00:00Z'), + statusBeforeExpiry: 'CONTRACT_ACTIVE', + extensionRequestedAt: null, + ...over, + }) as Contract; + + let current: Contract; + let repo: { update: jest.Mock; createReviewNote: jest.Mock }; + let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock }; + let service: ContractTransitionService; + + /** A staff user holding the extend key — authorization is tested elsewhere. */ + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:extend' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + }; + notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() }; + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('records the customer request and tells the contract desk', async () => { + await service.requestExtension('c-1', ' Two more shipments due ', 'user-1'); + + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Two more shipments due', + 'EXTENSION_REQUESTED', + 'user-1', + 'CUSTOMER', + ); + expect(repo.update).toHaveBeenCalledWith('c-1', { + extensionRequestedAt: expect.any(Date), + }); + expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith( + expect.objectContaining({ id: 'c-1' }), + 'Two more shipments due', + ); + }); + + it('refuses a request on a contract that has not expired', async () => { + current = contract({ status: 'CONTRACT_ACTIVE' }); + + await expect( + service.requestExtension('c-1', undefined, 'user-1'), + ).rejects.toThrow(/CONTRACT_ACTIVE/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('allows one pending request at a time', async () => { + current = contract({ extensionRequestedAt: new Date() }); + + await expect( + service.requestExtension('c-1', undefined, 'user-1'), + ).rejects.toThrow(/already awaiting/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses to extend before the customer has asked', async () => { + await expect( + service.extend('c-1', 30, undefined, 'staff-1', staff as never), + ).rejects.toThrow(/not requested/); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => { + current = contract({ + extensionRequestedAt: new Date(), + statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS', + }); + const before = Date.now(); + + await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never); + + const patch = repo.update.mock.calls[0][1] as { + status: string; + statusBeforeExpiry: null; + extensionRequestedAt: null; + contractValidUntil: Date; + }; + expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS'); + expect(patch.statusBeforeExpiry).toBeNull(); + expect(patch.extensionRequestedAt).toBeNull(); + // The old end (Jan 2026) is in the past, so the ten days count from now. + const tenDays = 10 * 86_400_000; + expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000); + expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/), + 'EXTENDED', + 'staff-1', + 'STAFF', + ); + expect(notifier.extended).toHaveBeenCalledWith( + expect.objectContaining({ id: 'c-1' }), + 10, + patch.contractValidUntil, + 'Approved by desk', + ); + }); + + it('extends from the current end date when it is still in the future', async () => { + const future = new Date(Date.now() + 5 * 86_400_000); + current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future }); + + await service.extend('c-1', 7, undefined, 'staff-1', staff as never); + + const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date }; + const expected = new Date(future); + expected.setDate(expected.getDate() + 7); + expect(patch.contractValidUntil.getTime()).toBe(expected.getTime()); + }); + + it('falls back to the resting status for rows expired before it was tracked', async () => { + current = contract({ + extensionRequestedAt: new Date(), + statusBeforeExpiry: null, + contractKind: 'ONE_TIME', + }); + + await service.extend('c-1', 1, undefined, 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith( + 'c-1', + expect.objectContaining({ status: 'FULLY_EXECUTED' }), + ); + }); + + it('refuses to extend without the extend permission', async () => { + current = contract({ extensionRequestedAt: new Date() }); + + await expect( + service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never), + ).rejects.toThrow(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 10b6afd37..8395d28c5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -188,6 +188,26 @@ export class ContractNotifierService { this.inApp(c, 'Contract cancelled', msg); } + /** Staff extended the validity of a lapsed contract — it is live again. */ + extended(c: Contract, days: number, validUntil: Date, note?: string | null): void { + const msg = + `Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` + + `and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` + + `You can book shipments under it again.${note ? ` Note: ${note}` : ''}`; + void this.notifyContact(c, msg, 'EXTENDED'); + this.inApp(c, 'Contract extended', msg); + } + + /** Customer asked for their expired contract to be extended — staff-side record. */ + extensionRequestedToStaff(c: Contract, note: string | null): void { + this.inAppStaff( + c, + 'Contract extension requested', + `The customer asked to extend expired contract ${this.ref(c)}.` + + `${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`, + ); + } + /** Customer cancelled their own contract — staff-side record. */ cancelledByCustomer(c: Contract, reason: string): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 04be6460f..1367e8c8a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common'; import { RatesService } from '../rule-engine/services/rates.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { round2 } from '../billing/invoice-settlement.util'; -import { ExchangeService } from '@edr/api-common'; +import { CurrencyCode, ExchangeService } from '@edr/api-common'; import { ContractsRepository } from './contracts.repository'; import { Contract } from './entities/contract.entity'; @@ -95,9 +95,9 @@ export class ContractPricingService { (r) => !r.shippingLineCompanyId, ); const currency = contract.paymentCurrency; - const isEtb = currency === 'ETB'; - const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; - const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd); + const usdToTarget = + currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode); + const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget)); const lineItems: ContractUnitRateLineItem[] = []; const baseType = this.baseRateType(contract); 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 ac400d9d3..bdacdb69d 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 @@ -430,6 +430,8 @@ export class ContractTransitionService { (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId, // Ethiopian-customs-only service types resolve to the Ethiopian variant. contract.serviceType?.includesEthiopianCustomsOnly, + // An empty-equipment contract resolves to the carriage-only paper. + contract.cargoCondition, ); if (!active) return null; return { @@ -1500,6 +1502,105 @@ export class ContractTransitionService { return updated; } + /** + * Customer asks EDR to extend the validity of their EXPIRED contract. Only + * stamps the request and tells the contract desk — nothing on the contract + * moves until staff {@link extend} it. One pending request at a time. + */ + async requestExtension( + contractId: string, + note: string | undefined, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['EXPIRED']); + if (contract.extensionRequestedAt) { + throw new ConflictException( + 'An extension request for this contract is already awaiting EDR.', + ); + } + + const reason = note?.trim() || null; + await this.contractsRepository.createReviewNote( + contractId, + reason ?? 'Customer requested a validity extension.', + 'EXTENSION_REQUESTED', + userId, + 'CUSTOMER', + ); + await this.contractsRepository.update(contractId, { + extensionRequestedAt: new Date(), + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.extensionRequestedToStaff(updated, reason); + return updated; + } + + /** + * Staff add validity days to an EXPIRED contract the customer asked to + * extend, and the contract returns to the status it held before it lapsed + * (stashed in statusBeforeExpiry by both expiry paths). Days count from + * today once the contract has lapsed — adding to a date already in the past + * could leave it expired — and from the current end date otherwise. + * + * Gated on the customer's request: the portal button is the only way to set + * extensionRequestedAt, so staff cannot silently revive a contract nobody + * asked about. + */ + async extend( + contractId: string, + days: number, + note: string | undefined, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.extend); + assertContractStatus(contract, ['EXPIRED']); + if (!contract.extensionRequestedAt) { + throw new ConflictException( + 'The customer has not requested an extension for this contract. A contract is only extended on customer request.', + ); + } + if (!Number.isInteger(days) || days < 1) { + throw new BadRequestException('An extension must add at least one day.'); + } + + const now = new Date(); + const currentEnd = contract.contractValidUntil + ? new Date(contract.contractValidUntil) + : null; + const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now; + const validUntil = new Date(base); + validUntil.setDate(validUntil.getDate() + days); + + // Rows that lapsed before statusBeforeExpiry existed have nothing to + // restore — fall back to the kind's post-signature resting status, the + // same default resume() uses. + const restored = + contract.statusBeforeExpiry ?? + (contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'); + + const trimmed = note?.trim() || null; + await this.contractsRepository.createReviewNote( + contractId, + `Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` + + (trimmed ? ` ${trimmed}` : ''), + 'EXTENDED', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: restored, + statusBeforeExpiry: null, + extensionRequestedAt: null, + contractValidUntil: validUntil, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.extended(updated, days, validUntil, trimmed); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 7cf7a8398..1df3eea0e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -78,6 +78,10 @@ import { import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; import { RenewContractDto } from './dto/renew-contract.dto'; +import { + ExtendContractDto, + RequestContractExtensionDto, +} from './dto/extend-contract.dto'; import { CompleteConsolidatedPairDto, CreateBookingUnderContractDto, @@ -528,6 +532,52 @@ export class ContractsController { ); } + @Post(':id/extension-request') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer asks EDR to extend the validity of their expired contract', + }) + async requestExtension( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestContractExtensionDto, + @CurrentUser() user: TCurrentUser, + ) { + // Same ownership rule as cancel/renew: staff with bookings.view/contracts.view + // pass through, everyone else must own the contract's company. + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return this.transitionService.requestExtension( + id, + dto.note, + resolveAuthUserId(user), + ); + } + + @Post(':id/extend') + @BookingStaff(FREIGHT_PERMS.contracts.extend) + @ApiOperation({ + summary: + 'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status', + }) + extend( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ExtendContractDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.extend( + id, + dto.days, + dto.note, + resolveAuthUserId(user), + user, + ); + } + @Post(':id/cancel') @PortalCustomer() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index 5b5e73021..9f025f350 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository { const result = await this.repository .createQueryBuilder() .update(Contract) - .set({ status: 'EXPIRED' }) + // SET reads the pre-update row, so status_before_expiry gets the status + // being replaced — the value ContractTransitionService.extend restores. + .set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' }) .where('deleted_at IS NULL') .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) .andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', { @@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository { const result = await this.repository .createQueryBuilder() .update(Contract) - .set({ status: 'EXPIRED' }) + .set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' }) .where('id = :id', { id }) .andWhere('deleted_at IS NULL') .andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES }) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 6c5390fee..83e8f8952 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -433,6 +433,7 @@ export class ContractsService { renewalOfId: dto.renewalOfId ?? null, tradeDirection: dto.tradeDirection, freightType: dto.freightType, + cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN', serviceTypeId: dto.serviceTypeId, // A contract is always QUOTED in USD — the billing currency is chosen per // booking (or on the shipment request when GL books for the customer), so @@ -952,6 +953,20 @@ export class ContractsService { } } + // Why the customer wants more time — shown on the staff detail page while + // the extension request is pending. + if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) { + try { + const note = await this.contractsRepository.findLatestReviewNote( + contract.id, + 'EXTENSION_REQUESTED', + ); + contract.latestExtensionRequestNote = note?.body ?? null; + } catch { + contract.latestExtensionRequestNote = null; + } + } + // Lets the portal disable "Cancel contract" instead of letting the customer // click it and read a 400. The API re-checks on cancel regardless. contract.activeBookingCount = diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts index 9f596bbef..c0d4e65ee 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -98,7 +98,7 @@ export class CreateBookingRequestDto { 'Billing currency for the shipment GL will book. Intercity is always ETB.', }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) paymentCurrency?: string; @ApiPropertyOptional() diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 88ab8beb7..1020dff13 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const; const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; // Canonical UPPERCASE — everything downstream (booking gating, pricing // surcharge, GL/portal booking forms) compares contract.equipmentReturn @@ -154,6 +155,15 @@ export class CreateContractDto { @IsIn([...FREIGHT_TYPES]) freightType!: string; + /** + * LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is + * container freight only. + */ + @ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' }) + @IsOptional() + @IsIn([...CARGO_CONDITIONS]) + cargoCondition?: string; + @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) @IsUUID() serviceTypeId!: string; diff --git a/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts new file mode 100644 index 000000000..7c82947c8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/extend-contract.dto.ts @@ -0,0 +1,31 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +/** Customer asks EDR to extend the validity of their EXPIRED contract. */ +export class RequestContractExtensionDto { + @ApiPropertyOptional({ description: 'Why the customer needs the contract extended' }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} + +/** Staff extend an EXPIRED contract that the customer asked to extend. */ +export class ExtendContractDto { + @ApiProperty({ + description: + 'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.', + minimum: 1, + maximum: 3650, + }) + @IsInt() + @Min(1) + @Max(3650) + days!: number; + + @ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' }) + @IsOptional() + @IsString() + @MaxLength(2000) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts index 5b64fe5f7..693c0edbe 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [ 'SUSPENSION_LIFTED', /** Customer cancelled their own contract; body is their reason. */ 'CANCELLATION', + /** Customer asked for an EXPIRED contract's validity to be extended. */ + 'EXTENSION_REQUESTED', + /** Staff extended the validity; body records the days added and the new end. */ + 'EXTENDED', ] as const; export type ContractReviewNoteType = (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index cdf6b4ecf..fd3b54b58 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -150,6 +150,14 @@ export class Contract extends BaseEntity { @Column({ name: 'freight_type', type: 'varchar', length: 20 }) freightType!: string; + /** + * LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract + * commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER + * template — a straight carriage agreement with no cargo or customs articles. + */ + @Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' }) + cargoCondition!: string; + @Column({ name: 'service_type_id', type: 'uuid' }) serviceTypeId!: string; @@ -231,6 +239,23 @@ export class Contract extends BaseEntity { @Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true }) statusBeforeSuspension?: string | null; + /** + * Status the contract held when it lapsed to EXPIRED (stamped by both the + * nightly sweep and the lazy flip on read), restored when staff extend the + * validity. Null on rows that expired before the column existed — extension + * then falls back to the kind's post-signature resting status. + */ + @Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true }) + statusBeforeExpiry?: string | null; + + /** + * When the customer asked for the validity of this EXPIRED contract to be + * extended. Set by the portal request, cleared when staff extend. Staff + * cannot extend a contract the customer has not asked about. + */ + @Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true }) + extensionRequestedAt?: Date | null; + @Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' }) clearanceStatus!: string; @@ -365,6 +390,13 @@ export class Contract extends BaseEntity { */ latestSuspensionNote?: string | null; + /** + * Body of the most recent EXTENSION_REQUESTED review note, attached by + * ContractsService.findById while an extension request is pending so staff + * see why the customer wants the contract extended. Not a column. + */ + latestExtensionRequestNote?: string | null; + /** * Count of this contract's non-terminal bookings, attached by * ContractsService.findById. The portal disables customer cancellation while diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts index 7bd109430..c6edd436f 100644 --- a/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/shipment-currency.spec.ts @@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot => const frozenByCode = ( snap: ContractRateSnapshot | null, bookingCurrency: string, - usdToEtb: number, + fx: Record, ): ContractRateSnapshot | null => ( BookingPricingService.prototype as unknown as { @@ -28,14 +28,14 @@ const frozenByCode = ( m: Map | null, code: string, bookingCurrency: string, - usdToEtb: number, + fx: Record, ) => ContractRateSnapshot | null; } ).frozenRateByCode( snap ? new Map([['CONTAINER_20FT', snap]]) : null, 'CONTAINER_20FT', bookingCurrency, - usdToEtb, + fx, ); describe('per-shipment billing currency', () => { @@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => { it('converts a USD snapshot for an ETB booking instead of dropping it', () => { // The old behaviour returned null here, which silently re-priced the // booking at live rates and lost the agreed contract price. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000); }); it('converts a grandfathered ETB snapshot back for a USD booking', () => { - expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400); + expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400); + }); + + it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => { + // 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845. + expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040); }); it('passes a matching-currency snapshot through untouched', () => { const snap = snapshot('USD', 400); - expect(frozenByCode(snap, 'USD', 1)).toBe(snap); + expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap); }); it('refuses to price off an unusable exchange rate', () => { // Converting with 0 would zero the whole line. - expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull(); - expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull(); + expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull(); + }); + + it('refuses to price off a currency the rate table has no entry for', () => { + expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull(); }); it('returns null when there is no snapshot', () => { - expect(frozenByCode(null, 'ETB', 150)).toBeNull(); + expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull(); }); }); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts index 98e87007c..4343732b7 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -1,13 +1,14 @@ -import { IsNumber, Max, Min } from "class-validator"; +import { IsNumber, Min } from "class-validator"; /** - * Operator-set USD→ETB fallback. Bounded well outside any plausible published - * rate but far short of a fat-fingered magnitude error — this value multiplies - * real invoice amounts whenever CBE is unreachable. + * Operator-set X→ETB fallback for one currency. The upper bound is enforced + * per currency in the controller (see `RATE_BOUNDS`) rather than here, since + * USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of + * magnitude — this value multiplies real invoice amounts whenever CBE is + * unreachable. */ export class UpdateExchangeSettingDto { @IsNumber({ maxDecimalPlaces: 6 }) - @Min(1) - @Max(10_000) + @Min(0.000001) fallbackRate!: number; } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts index 1e1f4ad66..e1fc99780 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm"; export type ExchangeFallbackSource = "AUTO" | "MANUAL"; /** - * Single-row table holding the USD→ETB fallback used when the CBE endpoint is - * unreachable. The live CBE rate always wins; this is only consulted on - * failure, and is overwritten by every successful fetch so it tracks the last - * known good rate. + * One row per foreign currency, holding the X→ETB fallback used when the CBE + * endpoint is unreachable for that currency. The live CBE rate always wins; + * this is only consulted on failure, and is overwritten by every successful + * fetch so it tracks the last known good rate. */ @Entity({ schema: "freight", name: "exchange_settings" }) export class ExchangeSetting extends BaseEntity { - /** USD→ETB rate served while the CBE endpoint is failing. */ + /** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */ + @Column({ name: "currency", type: "varchar", length: 5 }) + currency!: string; + + /** currency→ETB rate served while the CBE endpoint is failing for it. */ @Column({ name: "fallback_rate", type: "numeric", diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts index fb126f969..a52db2010 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service"; /** * The app's single `ExchangeModule` registration shape: CBE endpoint config - * from `app.cbeExchange`, with the DB-backed fallback wired in. + * from `app.cbeExchange`, with the DB-backed per-currency fallback wired in. * * `ExchangeModule` is registered per-feature-module (bookings, contracts, * warehouses), so this keeps the three call sites identical rather than @@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule { settings: ExchangeSettingsService, ): ExchangeOptions => ({ ...(config.get("app.cbeExchange") ?? {}), - loadFallbackRate: () => settings.loadFallbackRate(), - saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + loadFallbackRate: (code) => settings.loadFallbackRate(code), + saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate), }), }); } diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts new file mode 100644 index 000000000..e18bc830e --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-multi-currency.spec.ts @@ -0,0 +1,102 @@ +import { CbeExchangeProvider, ExchangeService } from '@edr/api-common'; + +/** + * The CBE feed quotes every currency it publishes against ETB in one fetch — + * this is a fixture of that shape (trimmed to USD + DJF, the two the app + * actually reads). Verified live against the real feed on 2026-09-04. + */ +const CBE_FIXTURE = [ + { + Date: '2026-09-04', + ExchangeRate: [ + { + transactionalSelling: 163.4365, + transactionalBuying: 160.2319, + currency: { CurrencyCode: 'USD' }, + }, + { + transactionalSelling: 0.9203, + transactionalBuying: 0.9022, + currency: { CurrencyCode: 'DJF' }, + }, + // CBE publishes 0 for a currency it isn't quoting cash-selling that + // day — must not be picked up as a usable rate. + { transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } }, + ], + }, +]; + +function mockFetchOnce(payload: unknown): jest.Mock { + const fn = jest.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + (global as unknown as { fetch: typeof fetch }).fetch = fn as never; + return fn; +} + +describe('CbeExchangeProvider — multi-currency', () => { + it('parses every quoted currency out of one fetch, not just USD', async () => { + const fetchMock = mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' }); + const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' }); + + expect(usdToEtb).toBeCloseTo(163.4365); + expect(djfToEtb).toBeCloseTo(0.9203); + // Both rates came from the SAME cached fetch — one HTTP call serves + // every currency, not one per currency. + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow( + /No CBE rate available for ZZZ/, + ); + }); + + it('only ever answers for X→ETB — everything else is derived upstream', async () => { + mockFetchOnce(CBE_FIXTURE); + const provider = new CbeExchangeProvider({}); + + await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull(); + await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull(); + }); +}); + +describe('ExchangeService — USD↔DJF pivot', () => { + it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('USD', 'DJF'); + + // 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand. + expect(rate).toBeCloseTo(163.4365 / 0.9203, 4); + expect(rate).toBeCloseTo(177.59, 1); + }); + + it('derives the inverse, DJF→USD, from the same pivot', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const rate = await service.getRate('DJF', 'USD'); + + expect(rate).toBeCloseTo(0.9203 / 163.4365, 6); + }); + + it('getRateTable resolves every supported currency into the target in one call', async () => { + mockFetchOnce(CBE_FIXTURE); + const service = new ExchangeService({}); + + const fx = await service.getRateTable('DJF'); + + expect(fx.DJF).toBe(1); + expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4); + expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4); + }); +}); diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts index 001fc90d2..0e0736561 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; -import { CurrentUser } from "@edr/api-common"; +import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import { BookingStaff } from "../../common/booking-guards"; @@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto"; import { ExchangeSettingsService } from "./exchange-settings.service"; +/** + * Sane manual-rate ceiling per currency — bounded well outside any plausible + * published rate but far short of a fat-fingered magnitude error. USD trades + * in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF + * itself is worth roughly 1/177th of a USD). + */ +const RATE_BOUNDS: Record = { + ETB: 1, + USD: 10_000, + DJF: 100, +}; + +const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB"); + +function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] { + const code = currency?.toUpperCase(); + const match = FOREIGN_CURRENCIES.find((c) => c === code); + if (!match) { + throw new BadRequestException( + `Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`, + ); + } + return match; +} + @ApiTags("exchange-settings") @ApiBearerAuth() @Controller("exchange-settings") @@ -17,37 +42,51 @@ export class ExchangeSettingsController { @Get() @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]) @ApiOperation({ - summary: "Current USD→ETB fallback rate and CBE feed health", + summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency", }) - async get() { - const setting = await this.service.get(); - const status = this.service.getFeedStatus(); + async list() { + const settings = await this.service.list(); + const byCurrency = new Map(settings.map((s) => [s.currency, s])); - return { - fallbackRate: setting.fallbackRate, - fallbackSource: setting.fallbackSource, - lastSyncedAt: setting.lastSyncedAt, - updatedById: setting.updatedById, - feed: status, - }; + return FOREIGN_CURRENCIES.map((code) => { + const setting = byCurrency.get(code); + return { + currency: code, + fallbackRate: setting?.fallbackRate ?? null, + fallbackSource: setting?.fallbackSource ?? null, + lastSyncedAt: setting?.lastSyncedAt ?? null, + updatedById: setting?.updatedById ?? null, + feed: this.service.getFeedStatus(code), + }; + }); } - @Patch() + @Patch(":currency") @BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin]) @ApiOperation({ summary: - "Set the USD→ETB fallback by hand (used only while CBE is unreachable)", + "Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)", }) async update( + @Param("currency") currency: string, @Body() dto: UpdateExchangeSettingDto, @CurrentUser() user: TCurrentUser, ) { + const code = assertSupportedCurrency(currency); + if (dto.fallbackRate > RATE_BOUNDS[code]) { + throw new BadRequestException( + `Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`, + ); + } + const updated = await this.service.setManualRate( + code, dto.fallbackRate, user?.id ?? null, ); return { + currency: updated.currency, fallbackRate: updated.fallbackRate, fallbackSource: updated.fallbackSource, lastSyncedAt: updated.lastSyncedAt, diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts index e0b670292..df36821bc 100644 --- a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -1,16 +1,22 @@ import { Injectable, Logger } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; +import { CurrencyCode } from "@edr/api-common"; import { Repository } from "typeorm"; import { ExchangeSetting } from "./entities/exchange-setting.entity"; /** - * Rate used before the row exists and before the first successful CBE fetch — - * the CBE USD transactional selling rate on 2026-08-04. + * Rate used before a currency's row exists and before its first successful + * CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is + * the CBE transactional selling rate on 2026-09-04 (CBE started being read + * for DJF then). */ -const SEED_FALLBACK_RATE = 162.4165; +const SEED_FALLBACK_RATES: Partial> = { + USD: 162.4165, + DJF: 0.9203, +}; -/** Health of the CBE feed, as surfaced to the backoffice. */ +/** Health of the CBE feed for one currency, as surfaced to the backoffice. */ export interface ExchangeFeedStatus { /** Rate most recently observed, whatever its source. */ rate: number | null; @@ -22,9 +28,17 @@ export interface ExchangeFeedStatus { lastError: string | null; } +const EMPTY_FEED_STATUS: ExchangeFeedStatus = { + rate: null, + source: null, + lastSuccessAt: null, + lastError: null, +}; + /** - * Owns the single `exchange_settings` row: the USD→ETB fallback used when the - * CBE endpoint is unreachable. + * Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) — + * each holding the currency→ETB fallback used when the CBE endpoint is + * unreachable for it. * * The live CBE rate is always preferred. This value is only read on failure, * and every successful fetch overwrites it, so it tracks the last known good @@ -35,107 +49,113 @@ export class ExchangeSettingsService { private readonly logger = new Logger(ExchangeSettingsService.name); /** - * Feed health, recorded from the exchange provider's callbacks rather than - * read off an injected `ExchangeService`. The provider is registered several - * times (bookings, contracts, warehouses), so no single instance sees every - * fetch — and injecting one here would be circular, since those - * registrations inject *this* service. + * Feed health per currency, recorded from the exchange provider's + * callbacks rather than read off an injected `ExchangeService`. The + * provider is registered several times (bookings, contracts, warehouses), + * so no single instance sees every fetch — and injecting one here would be + * circular, since those registrations inject *this* service. */ - private feed: ExchangeFeedStatus = { - rate: null, - source: null, - lastSuccessAt: null, - lastError: null, - }; + private feed = new Map(); constructor( @InjectRepository(ExchangeSetting) private readonly repository: Repository, ) {} - /** Health of the CBE feed as last observed by any provider instance. */ - getFeedStatus(): ExchangeFeedStatus { - return { ...this.feed }; + /** Health of the CBE feed for `code` as last observed by any provider instance. */ + getFeedStatus(code: CurrencyCode): ExchangeFeedStatus { + return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) }; } - /** The settings row, created at the seed rate on first access. */ - async get(): Promise { - const existing = await this.repository.findOne({ where: {} }); + /** The settings row for `code`, created at the seed rate on first access. */ + async get(code: CurrencyCode): Promise { + const existing = await this.repository.findOne({ where: { currency: code } }); if (existing) return existing; return this.repository.save( this.repository.create({ - fallbackRate: SEED_FALLBACK_RATE, + currency: code, + fallbackRate: SEED_FALLBACK_RATES[code] ?? 1, fallbackSource: "AUTO", lastSyncedAt: null, }), ); } + /** Every currency's settings row, for the backoffice settings list. */ + async list(): Promise { + return this.repository.find({ order: { currency: "ASC" } }); + } + /** - * Reads the stored fallback for the exchange provider. Returns `null` on any - * failure so the provider falls through to its own static default rather - * than propagating a database error into a pricing call. + * Reads the stored fallback for `code`, for the exchange provider. Returns + * `null` on any failure so the provider falls through to its own static + * default rather than propagating a database error into a pricing call. */ - async loadFallbackRate(): Promise { + async loadFallbackRate(code: CurrencyCode): Promise { // Only reached when the live fetch failed, so this call is itself the - // signal that the feed is down. + // signal that the feed is down for this currency. try { - const { fallbackRate } = await this.get(); + const { fallbackRate } = await this.get(code); const usable = Number.isFinite(fallbackRate) && fallbackRate > 0; - this.feed = { - ...this.feed, - rate: usable ? fallbackRate : this.feed.rate, + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { + ...previous, + rate: usable ? fallbackRate : previous.rate, source: "stored", - lastError: this.feed.lastError ?? "CBE endpoint unreachable", - }; + lastError: previous.lastError ?? "CBE endpoint unreachable", + }); return usable ? fallbackRate : null; } catch (err) { const message = (err as Error).message; - this.feed = { ...this.feed, source: "stored", lastError: message }; - this.logger.warn(`Could not read stored exchange fallback: ${message}`); + const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS; + this.feed.set(code, { ...previous, source: "stored", lastError: message }); + this.logger.warn( + `Could not read stored exchange fallback for ${code}: ${message}`, + ); return null; } } /** - * Records a freshly fetched live rate as the new fallback. Marked `AUTO`, - * overwriting a manual entry — a manual rate is a stopgap for while CBE is - * down, so a working CBE feed takes precedence again. + * Records a freshly fetched live rate as the new fallback for `code`. + * Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap + * for while CBE is down, so a working CBE feed takes precedence again. */ - async saveFallbackRate(rate: number): Promise { + async saveFallbackRate(code: CurrencyCode, rate: number): Promise { // Only called after a successful fetch, so the feed is confirmed healthy. - this.feed = { + this.feed.set(code, { rate, source: "live", lastSuccessAt: new Date().toISOString(), lastError: null, - }; + }); - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "AUTO", lastSyncedAt: new Date(), updatedById: null, }); - this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`); + this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`); } /** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */ async setManualRate( + code: CurrencyCode, rate: number, updatedById?: string | null, ): Promise { - const current = await this.get(); + const current = await this.get(code); await this.repository.update(current.id, { fallbackRate: rate, fallbackSource: "MANUAL", updatedById: updatedById ?? null, }); this.logger.warn( - `Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`, + `Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`, ); - return this.get(); + return this.get(code); } } diff --git a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts index 51e99614a..d3b24dd43 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/contracts.dataset.ts @@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = { { key: 'paymentCurrency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'serviceTypeId', label: 'Service type', type: 'text' }, // Routes are one-to-many on contract_routes, so these filter via EXISTS diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index d4d635f6f..4afe28771 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'minAmount', label: 'Min total', type: 'text' }, { key: 'maxAmount', label: 'Max total', type: 'text' }, diff --git a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts index 59739823c..e7a9963f2 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/payments.dataset.ts @@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = { { key: 'currency', label: 'Currency', type: 'select', options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ] }, { key: 'search', label: 'Search order or transaction ID', type: 'text' }, ], diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 1bd22c7c0..526dbafe6 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto { export class OverviewBillingKpisDto { @ApiProperty() revenueMtdEtb!: number; @ApiProperty() revenueMtdUsd!: number; + @ApiProperty() revenueMtdDjf!: number; @ApiProperty() pendingPayments!: number; @ApiProperty() successfulPaymentsMtd!: number; } @@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto { @ApiProperty({ example: '2026-06-01' }) date!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewRecentBookingDto { @@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto { @ApiProperty() bookingsCreated!: number; @ApiProperty() revenueEtb!: number; @ApiProperty() revenueUsd!: number; + @ApiProperty() revenueDjf!: number; @ApiProperty() tons!: number; } @@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto { @ApiProperty() label!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewTonsTrendPointDto { @@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto { @ApiProperty() freightType!: string; @ApiProperty() amountEtb!: number; @ApiProperty() amountUsd!: number; + @ApiProperty() amountDjf!: number; } export class OverviewHeatmapCellDto { diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 6908498bb..d3100df9e 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -265,6 +265,7 @@ export class OverviewRepository { async getBillingKpis(dirs?: string[]): Promise<{ revenueMtdEtb: number; revenueMtdUsd: number; + revenueMtdDjf: number; pendingPayments: number; successfulPaymentsMtd: number; }> { @@ -279,6 +280,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueMtdUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueMtdDjf", + ) .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") .where("payment.status = :status", { status: "success" }) .andWhere( @@ -298,6 +303,7 @@ export class OverviewRepository { return { revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0), revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0), + revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0), pendingPayments, successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0), }; @@ -370,7 +376,7 @@ export class OverviewRepository { days: number, dirs?: string[], offsetDays = 0, - ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -386,6 +392,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, @@ -394,12 +404,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") - .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ date: row.date, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -510,7 +521,7 @@ export class OverviewRepository { async getPaymentsByMethod( dirs?: string[], ): Promise< - { method: string; count: number; amountEtb: number; amountUsd: number }[] + { method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -525,6 +536,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`, + "amountDjf", + ) .where(scope.sql, scope.params) .groupBy("payment.method") .orderBy("count", "DESC") @@ -533,6 +548,7 @@ export class OverviewRepository { count: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -540,6 +556,7 @@ export class OverviewRepository { count: Number(row.count), amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -580,6 +597,7 @@ export class OverviewRepository { bookingsCreated: number; revenueEtb: number; revenueUsd: number; + revenueDjf: number; tons: number; }> { const bookingScope = directionScopeSql("booking.trade_direction", dirs); @@ -605,13 +623,17 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "revenueUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "revenueDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( windowSql("COALESCE(payment.paid_at, payment.created_at)"), { days, offsetDays }, ) .andWhere(paymentScope.sql, paymentScope.params) - .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + .getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(), this.cargoRepository .createQueryBuilder("cargo") .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") @@ -626,6 +648,7 @@ export class OverviewRepository { bookingsCreated, revenueEtb: Number(revenueRow?.revenueEtb ?? 0), revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + revenueDjf: Number(revenueRow?.revenueDjf ?? 0), tons: Number(tonsRow?.tons ?? 0), }; } @@ -634,7 +657,7 @@ export class OverviewRepository { async getRevenueByDirection( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -648,6 +671,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -656,12 +683,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.trade_direction IS NOT NULL") .groupBy("booking.trade_direction") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -669,7 +697,7 @@ export class OverviewRepository { async getRevenueByFreightType( days: number, dirs?: string[], - ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + ): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository .createQueryBuilder("payment") @@ -683,6 +711,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -691,12 +723,13 @@ export class OverviewRepository { .andWhere(scope.sql, scope.params) .andWhere("booking.freight_type IS NOT NULL") .groupBy("booking.freight_type") - .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + .getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>(); return rows.map((row) => ({ label: row.label, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } @@ -734,6 +767,7 @@ export class OverviewRepository { freightType: string; amountEtb: number; amountUsd: number; + amountDjf: number; }[] > { const scope = bookingRefScopeSql("payment.ref_id", dirs); @@ -750,6 +784,10 @@ export class OverviewRepository { `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, "amountUsd", ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`, + "amountDjf", + ) .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, @@ -765,6 +803,7 @@ export class OverviewRepository { freightType: string; amountEtb: string; amountUsd: string; + amountDjf: string; }>(); return rows.map((row) => ({ @@ -772,6 +811,7 @@ export class OverviewRepository { freightType: row.freightType, amountEtb: Number(row.amountEtb), amountUsd: Number(row.amountUsd), + amountDjf: Number(row.amountDjf), })); } diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts index 971de03cb..39d4757e7 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() usdEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" }) + @IsOptional() + @IsBoolean() + djfEnabled?: boolean; } diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts index a18f97279..862456241 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "usd_enabled", type: "boolean", default: true }) usdEnabled!: boolean; + /** Manual settlement allowed for DJF invoices. */ + @Column({ name: "djf_enabled", type: "boolean", default: true }) + djfEnabled!: boolean; + /** IAM user id of the last operator to change either toggle. */ @Column({ name: "updated_by_id", type: "uuid", nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts index efb43fa91..dc397cf79 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -4,16 +4,23 @@ import { Repository } from "typeorm"; import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; -/** The two currencies an invoice can be settled by hand in. */ -export type ManualPaymentCurrency = "ETB" | "USD"; +/** The currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD" | "DJF"; + +const FIELD_BY_CURRENCY: Record = { + ETB: "etbEnabled", + USD: "usdEnabled", + DJF: "djfEnabled", +}; /** * Owns the single `manual_payment_settings` row: whether Finance may settle * invoices by hand, per currency. * * Defaults mirror how the platform behaved before the toggles existed — USD - * has always been bank-transfer-only so it starts ON; ETB manual settlement is - * the new capability and starts OFF, so enabling it is a deliberate act. + * and DJF have always been bank-transfer-capable so they start ON; ETB manual + * settlement is the new capability and starts OFF, so enabling it is a + * deliberate act. */ @Injectable() export class ManualPaymentSettingsService { @@ -30,7 +37,7 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true }), + this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), ); } @@ -40,31 +47,34 @@ export class ManualPaymentSettingsService { const enabled: ManualPaymentCurrency[] = []; if (setting.etbEnabled) enabled.push("ETB"); if (setting.usdEnabled) enabled.push("USD"); + if (setting.djfEnabled) enabled.push("DJF"); return enabled; } /** Whether one currency may be settled by hand right now. */ async isEnabled(currency: string | null | undefined): Promise { const upper = currency?.toUpperCase(); - if (upper !== "ETB" && upper !== "USD") return false; + const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency]; + if (!field) return false; const setting = await this.get(); - return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + return setting[field]; } - /** Flip either toggle; an omitted field leaves that currency unchanged. */ + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, updatedById?: string | null, ): Promise { const current = await this.get(); await this.repository.update(current.id, { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts index 0cf3b886c..b5cdf582f 100644 --- a/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts +++ b/apps/edr-freight-api/src/modules/payment/entities/payment.entity.ts @@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity"; /** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */ type PaymentType = string type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill" -type Currency = "ETB" | "USD" +type Currency = "ETB" | "USD" | "DJF" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" @Entity({ schema: 'freight', name: 'payments' }) @@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity { @Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] }) method!: PaymentMethod - @Column({ type: "enum", enum: ["ETB", "USD"] }) + @Column({ type: "enum", enum: ["ETB", "USD", "DJF"] }) currency!: Currency @Column({ type: "numeric" }) diff --git a/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts new file mode 100644 index 000000000..313ff182c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/create-publication.dto.ts @@ -0,0 +1,33 @@ +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator"; + +/** + * Metadata fields for `POST /publications`, sent alongside the file as + * multipart/form-data — every field arrives as a string, so numeric/boolean + * fields need an explicit `@Transform` (global `enableImplicitConversion` is + * off, see main.ts). + */ +export class CreatePublicationDto { + @IsString() + @MaxLength(200) + title!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(60) + category?: string; + + @IsOptional() + @IsInt() + @Transform(({ value }) => Number(value ?? 0)) + sortOrder?: number; + + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === undefined || value === "true" || value === true) + published?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts new file mode 100644 index 000000000..677b08c32 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/dto/update-publication.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/mapped-types"; + +import { CreatePublicationDto } from "./create-publication.dto"; + +export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {} diff --git a/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts new file mode 100644 index 000000000..e627e638c --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/entities/publication.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * One document in the freight portal's public library (/publications) — a + * PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and + * curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs + * edited in place, this is a real table of many rows and each upload is a + * whole new file — there is no version-history log here, a re-upload just + * replaces the file columns (see `PublicationsService.replaceFile`). + */ +@Entity({ schema: "freight", name: "publications" }) +@Index(["published", "sortOrder"]) +export class Publication extends BaseEntity { + @Column({ name: "title", type: "varchar", length: 200 }) + title!: string; + + @Column({ name: "description", type: "text", nullable: true }) + description?: string | null; + + @Column({ name: "category", type: "varchar", length: 60, nullable: true }) + category?: string | null; + + /** MinIO object key. Never a signed URL — those expire; sign on read instead. */ + @Column({ name: "file_key", type: "varchar", length: 512 }) + fileKey!: string; + + /** Original filename, used for the download's Content-Disposition. */ + @Column({ name: "file_name", type: "varchar", length: 255 }) + fileName!: string; + + @Column({ name: "file_mime_type", type: "varchar", length: 120 }) + fileMimeType!: string; + + @Column({ name: "file_size_bytes", type: "bigint" }) + fileSizeBytes!: number; + + /** Manual ordering in the backoffice list and the public grid. */ + @Column({ name: "sort_order", type: "integer", default: 0 }) + sortOrder!: number; + + /** Unpublish without deleting — hides it from the public list only. */ + @Column({ name: "published", type: "boolean", default: true }) + published!: boolean; + + @Column({ name: "published_at", type: "timestamptz", nullable: true }) + publishedAt?: Date | null; + + @Column({ name: "uploaded_by_id", type: "uuid", nullable: true }) + uploadedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts new file mode 100644 index 000000000..8a95ffb39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/public-publications.controller.ts @@ -0,0 +1,51 @@ +import { Public } from "@edr/api-common"; +import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common"; +import { Response } from "express"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; + +import { PublicationsService } from "./publications.service"; + +/** + * The portal's /publications page — a public library of PDFs, Markdown + * write-ups and PowerPoint decks about the platform. No login required, same + * as /help, /faq and the legal pages: prospects reach it before any account + * exists. + */ +@ApiTags("publications") +@Public() +@Controller("publications") +export class PublicPublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get() + // Cheap to serve stale for a few minutes; every anonymous page view hits it. + @Header("Cache-Control", "public, max-age=300") + @ApiOperation({ summary: "List published publications for the public library" }) + list() { + return this.service.listPublic(); + } + + @Get(":id/file") + @ApiQuery({ + name: "download", + required: false, + description: "Set to 1/true to force a download instead of inline preview.", + }) + @ApiOperation({ summary: "Stream a published publication's file" }) + async getFile( + @Param("id", ParseUUIDPipe) id: string, + @Query("download") download: string | undefined, + @Res() res: Response, + ) { + const { stream, record } = await this.service.getPublishedFileStream(id); + const forceDownload = download === "1" || download === "true"; + + res.setHeader("Content-Type", record.fileMimeType); + res.setHeader( + "Content-Disposition", + `${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`, + ); + res.setHeader("Cache-Control", "public, max-age=300"); + stream.pipe(res); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.controller.ts b/apps/edr-freight-api/src/modules/publications/publications.controller.ts new file mode 100644 index 000000000..79f18e9e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.controller.ts @@ -0,0 +1,76 @@ +import { CurrentUser } from "@edr/api-common"; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from "@nestjs/common"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { documentUploadMulterOptions } from "../../common/document-upload.options"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { PublicationsService } from "./publications.service"; + +const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; +const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin]; + +@ApiTags("publications") +@ApiBearerAuth() +@Controller("publications") +export class PublicationsController { + constructor(private readonly service: PublicationsService) {} + + @Get("admin") + @BookingStaff(READ) + @ApiOperation({ summary: "List every publication, published or not" }) + list() { + return this.service.list(); + } + + @Post() + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload a new publication" }) + create( + @UploadedFile() file: Express.Multer.File, + @Body() dto: CreatePublicationDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.create(file, dto, user?.id ?? null); + } + + @Patch(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Update a publication's title, description, category, order or published state" }) + update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) { + return this.service.update(id, dto); + } + + @Post(":id/file") + @BookingStaff(WRITE) + @UseInterceptors(FileInterceptor("file", documentUploadMulterOptions)) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Replace a publication's file" }) + replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) { + return this.service.replaceFile(id, file); + } + + @Delete(":id") + @BookingStaff(WRITE) + @ApiOperation({ summary: "Remove a publication" }) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.service.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.module.ts b/apps/edr-freight-api/src/modules/publications/publications.module.ts new file mode 100644 index 000000000..46e612ebe --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.module.ts @@ -0,0 +1,17 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { MinioModule } from "../minio/minio.module"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsController } from "./publications.controller"; +import { PublicationsRepository } from "./publications.repository"; +import { PublicationsService } from "./publications.service"; +import { PublicPublicationsController } from "./public-publications.controller"; + +@Module({ + imports: [TypeOrmModule.forFeature([Publication]), MinioModule], + controllers: [PublicPublicationsController, PublicationsController], + providers: [PublicationsRepository, PublicationsService], + exports: [PublicationsService], +}) +export class PublicationsModule {} diff --git a/apps/edr-freight-api/src/modules/publications/publications.repository.ts b/apps/edr-freight-api/src/modules/publications/publications.repository.ts new file mode 100644 index 000000000..315f630bb --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.repository.ts @@ -0,0 +1,29 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Publication } from "./entities/publication.entity"; + +@Injectable() +export class PublicationsRepository extends BaseRepository { + constructor( + @InjectRepository(Publication) + repository: Repository, + ) { + super(repository); + } + + /** Public list: published rows only, in display order. */ + findPublished(): Promise { + return this.repository.find({ + where: { published: true }, + order: { sortOrder: "ASC", publishedAt: "DESC" }, + }); + } + + /** Admin list: every row, published or not. */ + override findAll(): Promise { + return this.repository.find({ order: { sortOrder: "ASC" } }); + } +} diff --git a/apps/edr-freight-api/src/modules/publications/publications.service.ts b/apps/edr-freight-api/src/modules/publications/publications.service.ts new file mode 100644 index 000000000..e7ff2b2a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/publications/publications.service.ts @@ -0,0 +1,151 @@ +import { + PublicationSummary, + PUBLICATION_ALLOWED_MIME_TYPES, + PUBLICATION_FILE_PREFIX, +} from "@edr/types"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { extname } from "path"; +import { Readable } from "stream"; +import { randomUUID } from "crypto"; + +import { MinioService } from "../minio/minio.service"; +import { CreatePublicationDto } from "./dto/create-publication.dto"; +import { UpdatePublicationDto } from "./dto/update-publication.dto"; +import { Publication } from "./entities/publication.entity"; +import { PublicationsRepository } from "./publications.repository"; + +@Injectable() +export class PublicationsService { + constructor( + private readonly repository: PublicationsRepository, + private readonly minio: MinioService, + ) {} + + private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File { + if (!file) throw new BadRequestException("No file uploaded"); + if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) { + throw new BadRequestException( + `Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`, + ); + } + } + + async create( + file: Express.Multer.File | undefined, + dto: CreatePublicationDto, + actorId: string | null, + ): Promise { + this.assertAllowedFile(file); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const published = dto.published ?? true; + return this.repository.create({ + title: dto.title, + description: dto.description ?? null, + category: dto.category ?? null, + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + sortOrder: dto.sortOrder ?? 0, + published, + publishedAt: published ? new Date() : null, + uploadedById: actorId, + }); + } + + async update(id: string, dto: UpdatePublicationDto): Promise { + const existing = await this.getByIdOrThrow(id); + + const patch: Partial = { + ...(dto.title !== undefined && { title: dto.title }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.category !== undefined && { category: dto.category }), + ...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }), + }; + + if (dto.published !== undefined && dto.published !== existing.published) { + patch.published = dto.published; + patch.publishedAt = dto.published ? new Date() : null; + } + + const updated = await this.repository.update(id, patch); + if (!updated) throw new NotFoundException(`Publication ${id} not found`); + return updated; + } + + /** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */ + async replaceFile(id: string, file?: Express.Multer.File): Promise { + this.assertAllowedFile(file); + const existing = await this.getByIdOrThrow(id); + + const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`; + await this.minio.uploadFile(key, file.buffer, file.mimetype); + + const updated = await this.repository.update(id, { + fileKey: key, + fileName: file.originalname, + fileMimeType: file.mimetype, + fileSizeBytes: file.size, + }); + + await this.minio.deleteFile(existing.fileKey); + return updated!; + } + + async remove(id: string): Promise { + await this.getByIdOrThrow(id); + await this.repository.softDelete(id); + } + + /** Admin list — every row, published or not. */ + list(): Promise { + return this.repository.findAll(); + } + + /** + * Public list — published rows only. No file URL here: a presigned MinIO + * URL isn't reachable from the browser (see `fileViewUrl` in the portal's + * `apiConfig.ts`); the portal builds each file's URL itself from `id` via + * `GET /publications/:id/file`. + */ + async listPublic(): Promise { + const rows = await this.repository.findPublished(); + return rows.map((row) => this.toSummary(row)); + } + + private toSummary(row: Publication): PublicationSummary { + return { + id: row.id, + title: row.title, + description: row.description ?? null, + category: row.category ?? null, + fileName: row.fileName, + fileMimeType: row.fileMimeType, + fileSizeBytes: Number(row.fileSizeBytes), + sortOrder: row.sortOrder, + publishedAt: row.publishedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; + } + + /** For the public/staff file route: streams a published row's bytes. */ + async getPublishedFileStream( + id: string, + ): Promise<{ stream: Readable; record: Publication }> { + const record = await this.repository.findById(id); + if (!record || !record.published) { + throw new NotFoundException(`Publication ${id} not found`); + } + return { stream: await this.minio.getFileStream(record.fileKey), record }; + } + + private async getByIdOrThrow(id: string): Promise { + const record = await this.repository.findById(id); + if (!record) throw new NotFoundException(`Publication ${id} not found`); + return record; + } +} diff --git a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts index 550475599..d50b20e9c 100644 --- a/apps/edr-freight-api/src/modules/reports/revenue-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/revenue-classification.ts @@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = { options: [ { value: 'ETB', label: 'ETB' }, { value: 'USD', label: 'USD' }, + { value: 'DJF', label: 'DJF' }, ], }; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts index 5902453a0..45c3b0abd 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.spec.ts @@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => { ); }); }); + +describe('deriveRateType — empty container freight', () => { + it('splits empty freight from laden freight by direction', () => { + expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe( + 'EMPTY_CONTAINER_IMPORT', + ); + expect( + deriveRateType({ + appliesTo: 'EMPTY_CONTAINER', + trigger: 'ALWAYS', + tradeDirection: 'EXPORT', + }), + ).toBe('EMPTY_CONTAINER_EXPORT'); + }); + + // UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate + // sharing CONTAINER_IMPORT would collide with the laden rate for the same + // lane and container type. The distinct rateType is what keeps both fileable. + it('never resolves to the laden container rate type', () => { + for (const tradeDirection of ['IMPORT', 'EXPORT']) { + expect( + deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }), + ).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT'); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts index 894080e81..2d7e3463a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-type.util.ts @@ -58,6 +58,8 @@ export function deriveRateType(input: { switch (appliesTo) { case 'CONTAINER': return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT'; + case 'EMPTY_CONTAINER': + return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT'; case 'BULK': return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT'; case 'INTERCITY': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index 39d6174de..2a56c1755 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => { expect(isBulkQuantityUnit("FLAT")).toBe(false); }); }); + +/** + * Empty equipment carries no cargo, so no weighed unit applies — only the box + * and the wagon it rides on. + */ +describe("allowedRateUnits — empty container freight", () => { + it("offers per-container and per-wagon only", () => { + expect( + allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }), + ).toEqual(["PER_CONTAINER", "PER_WAGON"]); + }); + + it("never offers a weighed unit, even for a per-item commodity scope", () => { + expect( + allowedRateUnits({ + appliesTo: "EMPTY_CONTAINER", + trigger: "ALWAYS", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).not.toContain("PER_ITEM"); + }); +}); 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 fd7754844..207359b70 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 @@ -98,6 +98,10 @@ function unitsForShape(input: { switch (appliesTo) { case 'CONTAINER': return ['PER_CONTAINER', 'PER_WAGON']; + case 'EMPTY_CONTAINER': + // Empty equipment carries no cargo to weigh, so the only bases that mean + // anything are the box itself and the wagon it rides on. + return ['PER_CONTAINER', 'PER_WAGON']; case 'BULK': return ['PER_TON', 'PER_WAGON']; case 'INTERCITY': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index cc57e4c65..dd6432cfa 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -8,6 +8,12 @@ import { Yard } from './yard.entity'; export const RATE_TYPES = [ 'CONTAINER_IMPORT', 'CONTAINER_EXPORT', + // Empty equipment moved as freight in its own right — no cargo, priced per + // box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys + // on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT + // would collide with the laden 40ft rate for the same lane. + 'EMPTY_CONTAINER_IMPORT', + 'EMPTY_CONTAINER_EXPORT', 'BULK_IMPORT', 'BULK_EXPORT', 'INTERCITY_BULK', @@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number]; * lookup and snapshots). * * - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS) + * - EMPTY_CONTAINER : base rail freight for empty equipment * - FIRST_MILE / LAST_MILE : pickup / delivery legs * - OTHER : trigger-based surcharges (hazard, reefer …) */ export const RATE_APPLIES_TO = [ 'BULK', 'CONTAINER', + 'EMPTY_CONTAINER', 'INTERCITY', 'FIRST_MILE', 'LAST_MILE', diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index a3361e526..87618bcdb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; /** Categories priced per rail leg — they carry an origin → destination yard pair. */ -const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; +const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [ + 'BULK', + 'CONTAINER', + 'EMPTY_CONTAINER', + 'INTERCITY', +]; /** * Surcharges sold per cargo kind: the admin says container or bulk, a * container fee then names its container type and a bulk fee its commodity. @@ -381,6 +386,30 @@ export class RatesService { return; } + if (appliesTo === 'EMPTY_CONTAINER') { + // Northbound repositioning only. Southbound empties are already sold by + // the WITH_RETURN surcharge and empty_return_requests; a second path to + // the same movement would let the business double-sell it. + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'An empty container rate is import-only for now.', + ); + } + // Size is the entire scope of an empty rate — there is no cargo to narrow + // by, so the box type must be named and a commodity must not be. + if (!containerTypeId) { + throw new BadRequestException( + 'An empty container rate must name the container type it covers.', + ); + } + if (cargoTypeId) { + throw new BadRequestException( + 'An empty container rate cannot be scoped to a bulk cargo type.', + ); + } + return; + } + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`, diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts new file mode 100644 index 000000000..8b589ef65 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.spec.ts @@ -0,0 +1,346 @@ +import { CrewDutyRole } from './entities/train-crew-assignment.entity'; +import { TrainCrewRole } from './entities/train-crew-member.entity'; +import { + AssignmentFacts, + CorridorContext, + CorridorYard, + CrewDemandInput, + legAllowsNationality, + overtimeHours, + specializedRequirements, + technicianRequirement, + validateCrewComposition, +} from './crew-composition.rules'; + +/** + * A slice of the real corridor, using the production display_order values: + * GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19. + */ +const YARD: Record = { + GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 }, + FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 }, + MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 }, + DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 }, + NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 }, +}; + +const CORRIDOR: CorridorContext = { + yards: new Map(Object.values(YARD).map((y) => [y.id, y])), + originOrder: YARD.GMP.displayOrder, + destinationOrder: YARD.NAGAD.displayOrder, + direDawaOrder: YARD.DIRE_DAWA.displayOrder, +}; + +const NO_DEMAND: CrewDemandInput = { + hasBadOrderWagon: false, + badOrderWagonLabels: [], + hasReeferCargo: false, + reeferSources: [], + hasHazmatCargo: false, + hazmatSources: [], + hasBreakBulkCargo: false, + breakBulkSources: [], + hasLivestockCargo: false, + livestockSources: [], +}; + +let seq = 0; +const driver = ( + nationality: 'ETHIOPIAN' | 'DJIBOUTIAN', + from: CorridorYard, + to: CorridorYard, + dutyRole: CrewDutyRole, +): AssignmentFacts => ({ + crewMemberId: `driver-${++seq}`, + role: TrainCrewRole.TRAIN_DRIVER, + dutyRole, + fromYardId: from.id, + toYardId: to.id, + nationality, + memberName: `Driver ${seq}`, +}); + +const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] => + Array.from({ length: count }, () => ({ + crewMemberId: `member-${++seq}`, + role, + nationality: 'ETHIOPIAN', + memberName: `Member ${seq}`, + })); + +const codes = (result: { issues: Array<{ code: string }> }) => + result.issues.map((i) => i.code); + +describe('crew composition rules (ITLMS Rolling Stock)', () => { + const validate = ( + assignments: AssignmentFacts[], + demand: CrewDemandInput = NO_DEMAND, + ) => validateCrewComposition(assignments, demand, CORRIDOR); + + /** One Ethiopian Primary over the whole route — the minimum viable crew. */ + const soloPrimary = () => + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY); + + describe('free-form crew sizing', () => { + it('accepts a single driver working the whole corridor', () => { + const result = validate([soloPrimary()]); + expect(result.issues).toEqual([]); + expect(result.complete).toBe(true); + }); + + it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => { + const drivers = [ + soloPrimary(), + ...Array.from({ length: count - 1 }, () => + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), + ), + ]; + expect(validate(drivers).complete).toBe(true); + }); + + it('accepts any number of federal police, including none', () => { + for (const count of [0, 1, 4, 9]) { + const result = validate([ + soloPrimary(), + ...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count), + ]); + expect(result.complete).toBe(true); + } + }); + + it('requires at least one driver', () => { + const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4)); + expect(codes(result)).toContain('DRIVER_COUNT'); + }); + }); + + describe('yard-to-yard legs', () => { + it('lets staff hand over at any intermediate yard', () => { + // Three legs the old fixed segments could not express: GMP–Feto, + // Feto–Meiso, Meiso–Nagad. + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY), + ]); + expect(result.issues).toEqual([]); + expect(result.complete).toBe(true); + }); + + it('rejects a leg with the same yard at both ends', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY), + ]); + expect(codes(result)).toContain('DRIVER_LEG_EMPTY'); + }); + + it('rejects a yard outside the schedule route', () => { + const outside: CorridorYard = { + id: 'y-sebeta', + label: 'Sebeta', + country: 'Ethiopia', + displayOrder: 1, // before the GMP origin + }; + const corridor: CorridorContext = { + ...CORRIDOR, + yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]), + }; + const result = validateCrewComposition( + [driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)], + NO_DEMAND, + corridor, + ); + expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE'); + }); + + it('requires a from-yard, to-yard and duty role on every driver', () => { + const result = validate([ + { + crewMemberId: 'd1', + role: TrainCrewRole.TRAIN_DRIVER, + nationality: 'ETHIOPIAN', + memberName: 'Unslotted Driver', + }, + ]); + expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE'); + }); + }); + + describe('§1.1 territorial boundary', () => { + const dd = YARD.DIRE_DAWA.displayOrder; + + it('lets a Djibouti driver work at or beyond Dire Dawa', () => { + expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true); + }); + + it('bars a Djibouti driver from any leg west of Dire Dawa', () => { + expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false); + expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false); + }); + + it('leaves Ethiopian drivers unrestricted', () => { + expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true); + expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true); + }); + + it('flags a Djibouti driver placed on a western leg', () => { + const result = validate([ + driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY), + ]); + expect(codes(result)).toContain('TERRITORIAL_BOUNDARY'); + }); + + it('accepts the documented split: Ethiopians west, Djiboutians east', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT), + driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), + driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT), + ]); + expect(result.issues).toEqual([]); + expect(result.runType).toBe('LONG_RUN'); + }); + }); + + describe('one Primary per leg', () => { + it('rejects two Primaries on the same leg', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), + ]); + expect(codes(result)).toContain('DUPLICATE_PRIMARY'); + }); + + it('allows a Primary on each of two different legs', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), + ]); + expect(result.complete).toBe(true); + }); + + it('allows many Assistants alongside one Primary', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY), + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF), + ]); + expect(result.complete).toBe(true); + }); + + it('requires a Primary on every covered leg', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT), + ]); + expect(codes(result)).toContain('PRIMARY_MISSING'); + }); + }); + + describe('§1.1 run type', () => { + it('is a long run when the legs span the whole route', () => { + const result = validate([ + driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY), + driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), + ]); + expect(result.runType).toBe('LONG_RUN'); + }); + + it('is a short run when the legs cover only part of the route', () => { + const result = validate([ + driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY), + ]); + expect(result.runType).toBe('SHORT_RUN'); + }); + }); + + describe('§1.2 technical maintenance crew', () => { + it('requires no technician when no bad-order wagon is attached', () => { + expect(technicianRequirement(NO_DEMAND).min).toBe(0); + }); + + it('forces one technician when a bad-order wagon is attached', () => { + const demand = { + ...NO_DEMAND, + hasBadOrderWagon: true, + badOrderWagonLabels: ['WG-1042'], + }; + expect(technicianRequirement(demand).min).toBe(1); + + const result = validate([soloPrimary()], demand); + expect(codes(result)).toContain('TECHNICIAN_REQUIRED'); + // The wagon that forced it is named, so the demand is explicable. + expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message) + .toContain('WG-1042'); + }); + }); + + describe('§1.2 specialized cargo crew', () => { + it('asks for nothing when no specialized cargo is aboard', () => { + expect(specializedRequirements(NO_DEMAND)).toEqual([]); + }); + + it('requires a reefer technician only when reefer cargo is aboard', () => { + const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] }; + const rules = specializedRequirements(demand); + expect(rules).toHaveLength(1); + expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN); + expect(rules[0].min).toBe(1); + }); + + it('blocks a hazmat run with no escort assigned', () => { + const result = validate([soloPrimary()], { + ...NO_DEMAND, + hasHazmatCargo: true, + hazmatSources: ['BK-2024-0891'], + }); + expect(codes(result)).toContain('SPECIALIZED_REQUIRED'); + expect(result.complete).toBe(false); + }); + + it('passes once the escort is assigned, at any count', () => { + for (const escorts of [1, 2, 5]) { + const result = validate( + [soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)], + { ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] }, + ); + expect(result.complete).toBe(true); + } + }); + }); + + describe('duplicate seats', () => { + it('flags a member assigned twice on one run', () => { + const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0]; + const result = validate([soloPrimary(), twice, twice]); + expect(codes(result)).toContain('DUPLICATE_MEMBER'); + }); + }); + + describe('§3.2 overtime hours', () => { + it('reproduces the documented worked example', () => { + // PDF: 500h worked against a 240h standard => 260h variance, + // split 156h at the 1.5x tier and 104h at the 1.75x tier. + expect(overtimeHours(500)).toEqual({ + variance: 260, + tier1Hours: 156, + tier2Hours: 104, + }); + }); + + it('reports no overtime below the monthly standard', () => { + expect(overtimeHours(200)).toEqual({ + variance: 0, + tier1Hours: 0, + tier2Hours: 0, + }); + }); + + it('splits the variance 60/40 as a flat convention', () => { + const { tier1Hours, tier2Hours, variance } = overtimeHours(340); + expect(variance).toBe(100); + expect(tier1Hours).toBe(60); + expect(tier2Hours).toBe(40); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts new file mode 100644 index 000000000..9c2a04838 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/crew-composition.rules.ts @@ -0,0 +1,437 @@ +import { TrainCrewRole } from './entities/train-crew-member.entity'; +import { + CrewDutyRole, + CrewSegment, +} from './entities/train-crew-assignment.entity'; + +/** + * ITLMS Rolling Stock §1.2 / §2 composition rules. + * + * One module, used by BOTH the assignment API and the dispatch guard, so the + * wizard and the departure gate can never disagree about whether a crew is + * complete. Pure functions over plain data — no repository access — so the + * caller decides what to load and this stays unit-testable. + */ + +/** + * Security-detail size (§1.2 names 4 federal police). + * + * Operations asked for free-form crewing, so the document's numbers are treated + * as the usual shape rather than a hard limit — any count is accepted and the + * typical value is surfaced as a hint in the UI. + */ +export const FEDERAL_POLICE_TYPICAL = 4; + +/** Government monthly working-hour baseline (§3.1). */ +export const MONTHLY_STANDARD_HOURS = 240; + +/** + * §3.2 tier split. The document fixes the day/night division as a flat 60/40 of + * the variance regardless of when the hours fell, and that is implemented as + * written rather than derived from real clock hours. + */ +export const OT_TIER_1_SHARE = 0.6; +export const OT_TIER_2_SHARE = 0.4; +export const OT_TIER_1_FACTOR = 1.5; +export const OT_TIER_2_FACTOR = 1.75; + +/** + * Driving-crew size (§1.2 "3 or 4 Drivers"). + * + * Operations asked for a free-form crew rather than the two fixed pairing cases + * of §2, so the document's 3-or-4 is treated as the usual shape, not a limit: + * any count within these bounds is accepted and each driver carries their own + * segment and duty role. MIN stays at 1 so a partially built crew still saves. + */ +export const DRIVER_COUNT_MIN = 1; + +/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */ +export const DRIVER_COUNT_TYPICAL = [3, 4]; + +/** + * A corridor yard as the rules see it. + * + * `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD + * 22), which is what makes "is this leg inside the schedule's span" and "does + * this leg cross into Djibouti" answerable without hard-coding station names. + */ +export interface CorridorYard { + id: string; + label: string; + country: string; + displayOrder: number; +} + +/** Dire Dawa is the handover point §1.1 draws the territorial line at. */ +export const DIRE_DAWA_CODE = 'DIRE_DAWA'; + +/** + * The corridor a schedule runs on, as the rules need to see it: every yard by + * id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by + * the caller so these functions stay pure and unit-testable. + */ +export interface CorridorContext { + yards?: Map; + originOrder?: number; + destinationOrder?: number; + direDawaOrder?: number; +} + +/** + * §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa – Nagad + * corridor segment exclusively. + * + * Expressed against yards rather than a fixed segment name: a leg is open to a + * Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point + * east of it works without naming the pair in code. The restriction is + * asymmetric on purpose — the document confines Djiboutian drivers but never + * bars Ethiopians from that stretch. + */ +export const legAllowsNationality = ( + from: CorridorYard | undefined, + to: CorridorYard | undefined, + nationality: string, + direDawaOrder: number, +): boolean => { + if (nationality !== 'DJIBOUTIAN') return true; + if (!from || !to) return true; // Incomplete leg — a separate rule reports it. + // Both ends must sit at or beyond Dire Dawa, whichever way the train runs. + return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder; +}; + +/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */ +export interface SpecializedRequirement { + role: TrainCrewRole; + /** Hard floor — 0 unless the cargo or consist forces someone aboard. */ + min: number; + /** The count §1.2 suggests. A hint for the UI; nothing enforces it. */ + typical: number; + /** Why this is required — surfaced verbatim so the demand is explicable. */ + reason: string; +} + +/** What the consist and its cargo demand, as detected from the schedule. */ +export interface CrewDemandInput { + /** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */ + hasBadOrderWagon: boolean; + badOrderWagonLabels: string[]; + hasReeferCargo: boolean; + reeferSources: string[]; + hasHazmatCargo: boolean; + hazmatSources: string[]; + hasBreakBulkCargo: boolean; + breakBulkSources: string[]; + hasLivestockCargo: boolean; + livestockSources: string[]; +} + +const listSources = (sources: string[]): string => + sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : ''; + +/** + * Turn detected cargo/consist facts into the crew the run must carry. + * Only triggered rows appear, so staff are never asked about cargo not aboard. + */ +export const specializedRequirements = ( + demand: CrewDemandInput, +): SpecializedRequirement[] => { + const required: SpecializedRequirement[] = []; + if (demand.hasReeferCargo) { + required.push({ + role: TrainCrewRole.REEFER_TECHNICIAN, + min: 1, + typical: 2, + reason: `Reefer cargo on board${listSources(demand.reeferSources)}`, + }); + } + if (demand.hasHazmatCargo) { + required.push({ + role: TrainCrewRole.HAZMAT_ESCORT, + min: 1, + typical: 2, + reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`, + }); + } + if (demand.hasBreakBulkCargo) { + required.push({ + role: TrainCrewRole.LASHING_INSPECTOR, + min: 1, + typical: 2, + reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`, + }); + } + if (demand.hasLivestockCargo) { + required.push({ + role: TrainCrewRole.LIVESTOCK_HANDLER, + min: 1, + typical: 3, + reason: `Livestock shipment on board${listSources(demand.livestockSources)}`, + }); + } + return required; +}; + +/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */ +export const technicianRequirement = ( + demand: CrewDemandInput, +): SpecializedRequirement => ({ + role: TrainCrewRole.TECHNICIAN, + min: demand.hasBadOrderWagon ? 1 : 0, + typical: 3, + reason: demand.hasBadOrderWagon + ? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}` + : 'Optional technical maintenance crew', +}); + +/** One assignment, reduced to what the rules actually read. */ +export interface AssignmentFacts { + crewMemberId: string; + role: TrainCrewRole; + dutyRole?: CrewDutyRole | null; + /** The leg this driver works, as two corridor yards. */ + fromYardId?: string | null; + toYardId?: string | null; + nationality: string; + memberName: string; +} + +export interface CrewValidationIssue { + code: string; + message: string; +} + +export interface CrewValidationResult { + /** True when every mandatory rule passes — the dispatch gate reads this. */ + complete: boolean; + issues: CrewValidationIssue[]; + /** Derived, never entered: one segment covered = short run, both = long run (§1.1). */ + runType: 'SHORT_RUN' | 'LONG_RUN' | null; +} + +/** + * Validate a schedule's crew against §1.1 and §1.2. + * + * Returns issues rather than throwing: the wizard renders them as a live + * checklist while a partial crew is still being built, and only the dispatch + * guard treats a non-empty list as fatal. + */ +export const validateCrewComposition = ( + assignments: AssignmentFacts[], + demand: CrewDemandInput, + corridor: CorridorContext = {}, +): CrewValidationResult => { + const yards = corridor.yards ?? new Map(); + const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY; + const issues: CrewValidationIssue[] = []; + + const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER); + + if (drivers.length < DRIVER_COUNT_MIN) { + issues.push({ + code: 'DRIVER_COUNT', + message: 'At least one driver must be assigned', + }); + } + + // Every driver needs a leg and a duty role — without them the run has no + // record of who drove which part of the corridor. + for (const driver of drivers) { + if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) { + issues.push({ + code: 'DRIVER_SLOT_INCOMPLETE', + message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`, + }); + continue; + } + if (driver.fromYardId === driver.toYardId) { + issues.push({ + code: 'DRIVER_LEG_EMPTY', + message: `${driver.memberName} has the same yard at both ends of their leg`, + }); + } + // A leg outside the schedule's own span would put a driver on track this + // train never runs. + if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) { + const low = Math.min(corridor.originOrder, corridor.destinationOrder); + const high = Math.max(corridor.originOrder, corridor.destinationOrder); + const from = yards.get(driver.fromYardId); + const to = yards.get(driver.toYardId); + for (const yard of [from, to]) { + if (yard && (yard.displayOrder < low || yard.displayOrder > high)) { + issues.push({ + code: 'LEG_OUTSIDE_ROUTE', + message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`, + }); + } + } + } + } + + // A leg cannot have two Primaries — someone must be in charge of each stretch + // and only one person can be. Assistants and relief drivers are unconstrained. + const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`; + const legLabel = (d: AssignmentFacts) => { + const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined; + const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined; + return from && to ? `${from} – ${to}` : 'this leg'; + }; + + const primariesByLeg = new Map(); + for (const driver of drivers) { + if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) { + const key = legKey(driver); + const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) }; + entry.names.push(driver.memberName); + primariesByLeg.set(key, entry); + } + } + for (const [, entry] of primariesByLeg) { + if (entry.names.length > 1) { + issues.push({ + code: 'DUPLICATE_PRIMARY', + message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`, + }); + } + } + + // Each covered leg needs a Primary — an Assistant alone cannot run it. + const coveredLegs = new Map(); + for (const driver of drivers) { + if (driver.fromYardId && driver.toYardId) { + coveredLegs.set(legKey(driver), legLabel(driver)); + } + } + for (const [key, label] of coveredLegs) { + if (!primariesByLeg.has(key)) { + issues.push({ + code: 'PRIMARY_MISSING', + message: `${label} has no Primary Driver assigned`, + }); + } + } + + // §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa. + for (const driver of drivers) { + const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined; + const to = driver.toYardId ? yards.get(driver.toYardId) : undefined; + if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) { + issues.push({ + code: 'TERRITORIAL_BOUNDARY', + message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`, + }); + } + } + + // §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are + // no longer enforced: operations crew each run to its own need, so any number + // of any role is accepted. What still holds is what makes a run coherent — + // a driver with a segment and duty role, one Primary per segment, and the + // specialized crew the cargo actually demands. + + // §1.2 technical maintenance crew: a bad-order wagon still forces at least + // one technician — that rule is about safety, not crew sizing, so it stays. + const technicianRule = technicianRequirement(demand); + const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length; + if (technicians < technicianRule.min) { + issues.push({ + code: 'TECHNICIAN_REQUIRED', + message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`, + }); + } + + // §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort + // rides along) but the upper bound is gone — how many is operations' call. + for (const rule of specializedRequirements(demand)) { + const count = assignments.filter((a) => a.role === rule.role).length; + if (count < rule.min) { + issues.push({ + code: 'SPECIALIZED_REQUIRED', + message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`, + }); + } + } + + // Nobody may hold two seats on the same run. + const seen = new Set(); + for (const a of assignments) { + if (seen.has(a.crewMemberId)) { + issues.push({ + code: 'DUPLICATE_MEMBER', + message: `${a.memberName} is assigned more than once on this run`, + }); + } + seen.add(a.crewMemberId); + } + + return { + complete: issues.length === 0, + issues, + runType: deriveRunType(drivers, corridor, yards), + }; +}; + +/** + * §1.1 run type. A crew whose legs together span the schedule's whole route is + * a long run; anything shorter is a short run. + */ +const deriveRunType = ( + drivers: AssignmentFacts[], + corridor: CorridorContext, + yards: Map, +): 'SHORT_RUN' | 'LONG_RUN' | null => { + const orders = drivers + .flatMap((d) => [d.fromYardId, d.toYardId]) + .map((id) => (id ? yards.get(id)?.displayOrder : undefined)) + .filter((o): o is number => o !== undefined); + if (!orders.length) return null; + if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) { + return 'SHORT_RUN'; + } + const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder); + const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder); + const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh; + return covered ? 'LONG_RUN' : 'SHORT_RUN'; +}; + +export const labelSegment = (segment: CrewSegment): string => + ({ + [CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP – Dire Dawa', + [CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa – Nagad', + [CrewSegment.FULL_CORRIDOR]: 'Full corridor', + })[segment]; + +export const labelDutyRole = (dutyRole: CrewDutyRole): string => + ({ + [CrewDutyRole.PRIMARY]: 'Primary Driver', + [CrewDutyRole.ASSISTANT]: 'Assistant Driver', + [CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver', + })[dutyRole]; + +export const labelRole = (role: TrainCrewRole): string => + ({ + [TrainCrewRole.TRAIN_DRIVER]: 'train driver', + [TrainCrewRole.FEDERAL_POLICE]: 'federal police', + [TrainCrewRole.TECHNICIAN]: 'technician', + [TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician', + [TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort', + [TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector', + [TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler', + })[role]; + +/** + * §3.2 overtime hours for one driver's month. + * + * Hours only, by design: no salary is stored anywhere in the platform, so the + * output stops at the two tier totals and finance applies the rates. + */ +export const overtimeHours = ( + workedHours: number, + standardHours: number = MONTHLY_STANDARD_HOURS, +): { variance: number; tier1Hours: number; tier2Hours: number } => { + const variance = Math.max(0, workedHours - standardHours); + return { + variance, + tier1Hours: variance * OT_TIER_1_SHARE, + tier2Hours: variance * OT_TIER_2_SHARE, + }; +}; diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts new file mode 100644 index 000000000..6389baba7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/create-train-crew-member.dto.ts @@ -0,0 +1,32 @@ +import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { + TrainCrewNationality, + TrainCrewRole, + TrainCrewStatus, +} from '../entities/train-crew-member.entity'; + +export class CreateTrainCrewMemberDto { + @IsString() + @MinLength(1) + @MaxLength(100) + firstName!: string; + + @IsString() + @MinLength(1) + @MaxLength(100) + lastName!: string; + + @IsEnum(TrainCrewRole) + role!: TrainCrewRole; + + @IsEnum(TrainCrewNationality) + nationality!: TrainCrewNationality; + + @IsOptional() + @IsEnum(TrainCrewStatus) + status?: TrainCrewStatus; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts new file mode 100644 index 000000000..02579283a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/query-train-crew-member.dto.ts @@ -0,0 +1,63 @@ +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + TrainCrewNationality, + TrainCrewRole, + TrainCrewStatus, +} from '../entities/train-crew-member.entity'; + +/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */ +export const TRAIN_CREW_SORT_FIELDS = [ + 'firstName', + 'lastName', + 'role', + 'nationality', + 'status', + 'createdAt', + 'updatedAt', +] as const; + +export class QueryTrainCrewMemberDto { + /** Matched against first and last name. */ + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(TrainCrewRole) + role?: TrainCrewRole; + + @IsOptional() + @IsEnum(TrainCrewNationality) + nationality?: TrainCrewNationality; + + @IsOptional() + @IsEnum(TrainCrewStatus) + status?: TrainCrewStatus; + + @IsOptional() + @Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value)) + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(200) + limit?: number; + + @IsOptional() + @IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[]) + sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number]; + + @IsOptional() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts new file mode 100644 index 000000000..4938f6260 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/save-crew-assignments.dto.ts @@ -0,0 +1,45 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; + +import { CrewDutyRole } from '../entities/train-crew-assignment.entity'; +import { TrainCrewRole } from '../entities/train-crew-member.entity'; + +export class CrewAssignmentRowDto { + @IsUUID() + crewMemberId!: string; + + @IsEnum(TrainCrewRole) + role!: TrainCrewRole; + + /** Required for drivers, rejected as incomplete without it. */ + @IsOptional() + @IsEnum(CrewDutyRole) + dutyRole?: CrewDutyRole; + + /** The leg this driver works — any two yards on the schedule's route. */ + @IsOptional() + @IsUUID() + fromYardId?: string; + + @IsOptional() + @IsUUID() + toYardId?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class SaveCrewAssignmentsDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CrewAssignmentRowDto) + assignments!: CrewAssignmentRowDto[]; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts b/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts new file mode 100644 index 000000000..18215e2b5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/dto/update-train-crew-member.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto'; + +export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {} diff --git a/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts new file mode 100644 index 000000000..3591a45a8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-assignment.entity.ts @@ -0,0 +1,114 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity'; + +/** + * Legacy fixed corridor segments. + * + * Kept only so historic rows written before segments became yard-to-yard still + * read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick + * any two yards on the corridor, so a leg is no longer limited to the three + * spans the original design hard-coded. + */ +export enum CrewSegment { + INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA', + DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD', + FULL_CORRIDOR = 'FULL_CORRIDOR', +} + +/** Driver duty role for one run (§2). Null for non-driving crew. */ +export enum CrewDutyRole { + PRIMARY = 'PRIMARY', + ASSISTANT = 'ASSISTANT', + BENCH_RELIEF = 'BENCH_RELIEF', +} + +export enum CrewAssignmentStatus { + PLANNED = 'PLANNED', + CONFIRMED = 'CONFIRMED', + COMPLETED = 'COMPLETED', + REMOVED = 'REMOVED', +} + +/** + * One roster member assigned to one train schedule. + * + * `role` is snapshotted from the roster at assignment time: a member who later + * changes role must not silently rewrite the crew of a run that already + * departed. `dutyRole` and `segment` live here rather than on the roster + * because they are properties of THIS run — a driver who is Primary on one + * trip is Assistant on the next. Crew sizes are free-form: operations size each + * run to its own need rather than to a fixed pairing case. + * + * Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the + * platform reports OT hours only; no salary is stored anywhere, and the payroll + * conversion stays with finance. + */ +@Entity({ schema: 'freight', name: 'train_crew_assignments' }) +@Index(['trainScheduleId']) +@Index(['crewMemberId']) +@Index(['status']) +export class TrainCrewAssignment extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'crew_member_id', type: 'uuid' }) + crewMemberId!: string; + + @ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'crew_member_id' }) + crewMember?: TrainCrewMember; + + @Column({ name: 'role', type: 'varchar', length: 32 }) + role!: TrainCrewRole; + + @Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true }) + dutyRole?: CrewDutyRole | null; + + /** Legacy fixed segment — null on every assignment written since yard legs. */ + @Column({ name: 'segment', type: 'varchar', length: 24, nullable: true }) + segment?: CrewSegment | null; + + /** + * The leg this driver works, as two yards on the corridor. + * + * Free-form on purpose: operations pick any yard as a handover point, so a + * crew change at Meiso or Feto is expressible without a code change. The + * schedule's own origin and destination bound what staff may choose. + */ + @Column({ name: 'from_yard_id', type: 'uuid', nullable: true }) + fromYardId?: string | null; + + @Column({ name: 'to_yard_id', type: 'uuid', nullable: true }) + toYardId?: string | null; + + /** + * Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours + * as a typical duration, not a rule, so nothing here enforces a length — the + * stamps are recorded and reported. + */ + @Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true }) + layoverStartAt?: Date | null; + + @Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true }) + layoverEndAt?: Date | null; + + /** Worked span for this run — accumulated monthly for the §3 OT calculation. */ + @Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true }) + dutyStartAt?: Date | null; + + @Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true }) + dutyEndAt?: Date | null; + + @Column({ + name: 'status', + type: 'varchar', + length: 16, + default: CrewAssignmentStatus.PLANNED, + }) + status!: CrewAssignmentStatus; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts new file mode 100644 index 000000000..588678279 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/entities/train-crew-member.entity.ts @@ -0,0 +1,69 @@ +import { Column, Entity, Index } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; + +/** + * On-board role a crew member is rostered for. Mirrors the crew composition + * rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security + * detail, technical maintenance, and the four specialized cargo roles. + */ +export enum TrainCrewRole { + TRAIN_DRIVER = 'TRAIN_DRIVER', + FEDERAL_POLICE = 'FEDERAL_POLICE', + TECHNICIAN = 'TECHNICIAN', + REEFER_TECHNICIAN = 'REEFER_TECHNICIAN', + HAZMAT_ESCORT = 'HAZMAT_ESCORT', + LASHING_INSPECTOR = 'LASHING_INSPECTOR', + LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER', +} + +/** + * Employing country. Drives the territorial boundary in §1.1 — Djibouti train + * drivers operate only on the Dire Dawa – Nagad segment — and the crewing + * cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers). + */ +export enum TrainCrewNationality { + ETHIOPIAN = 'ETHIOPIAN', + DJIBOUTIAN = 'DJIBOUTIAN', +} + +export enum TrainCrewStatus { + ACTIVE = 'ACTIVE', + INACTIVE = 'INACTIVE', + SUSPENDED = 'SUSPENDED', + ON_LEAVE = 'ON_LEAVE', +} + +/** + * Roster of people assignable to a train. Distinct from `freight.drivers`, + * which is the road/last-mile truck driver register (licences, vehicle types, + * trip counts) — a train driver shares none of those fields. + */ +@Entity({ schema: 'freight', name: 'train_crew_members' }) +@Index(['role']) +@Index(['nationality']) +@Index(['status']) +@Index(['isActive']) +export class TrainCrewMember extends BaseEntity { + @Column({ name: 'first_name', type: 'varchar', length: 100 }) + firstName!: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100 }) + lastName!: string; + + @Column({ name: 'role', type: 'varchar', length: 32 }) + role!: TrainCrewRole; + + @Column({ name: 'nationality', type: 'varchar', length: 16 }) + nationality!: TrainCrewNationality; + + @Column({ + name: 'status', + type: 'varchar', + length: 16, + default: TrainCrewStatus.ACTIVE, + }) + status!: TrainCrewStatus; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts new file mode 100644 index 000000000..97e012a5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto'; +import { TrainCrewAssignmentService } from './train-crew-assignment.service'; + +@ApiTags('train-crew-assignments') +@ApiBearerAuth() +@Controller('train-schedules/:scheduleId/crew') +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([FREIGHT_PERMS.trainCrew.view, FREIGHT_PERMS.trainCrew.assign]) +export class TrainCrewAssignmentController { + constructor(private readonly service: TrainCrewAssignmentService) {} + + @Get() + @ApiOperation({ + summary: "A schedule's crew, the cargo-driven requirements, and rule validation", + }) + getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.service.getScheduleCrew(scheduleId); + } + + @Get('eligible-drivers') + @ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' }) + eligibleDrivers( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Query('fromYardId') fromYardId?: string, + @Query('toYardId') toYardId?: string, + ) { + return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId); + } + + @Get('corridor-yards') + @ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" }) + corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { + return this.service.corridorYards(scheduleId); + } + + @Put() + @BookingStaff(FREIGHT_PERMS.trainCrew.assign) + @ApiOperation({ + summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)", + }) + save( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: SaveCrewAssignmentsDto, + ) { + return this.service.saveAssignments(scheduleId, dto); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts new file mode 100644 index 000000000..df1c1321f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew-assignment.service.ts @@ -0,0 +1,389 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, Repository } from 'typeorm'; + +import { + CrewAssignmentStatus, + TrainCrewAssignment, +} from './entities/train-crew-assignment.entity'; +import { + TrainCrewMember, + TrainCrewRole, + TrainCrewStatus, +} from './entities/train-crew-member.entity'; +import { + AssignmentFacts, + CorridorContext, + CorridorYard, + CrewDemandInput, + CrewValidationResult, + DIRE_DAWA_CODE, + labelRole, + legAllowsNationality, + specializedRequirements, + technicianRequirement, + validateCrewComposition, +} from './crew-composition.rules'; +import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto'; + +/** Wagon statuses that mean "defective / bad order" for §1.2. */ +const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE']; + +/** + * Cargo-type name fragments that mark a livestock shipment. Matched on the + * cargo type's name because no boolean flag for livestock exists yet — unlike + * reefer and hazardous, which bookings carry explicitly. + */ +const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry']; + +@Injectable() +export class TrainCrewAssignmentService { + constructor( + @InjectRepository(TrainCrewAssignment) + private readonly assignmentRepo: Repository, + @InjectRepository(TrainCrewMember) + private readonly memberRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** Every assignment on a schedule, with the roster member joined. */ + async listForSchedule(scheduleId: string): Promise { + return this.assignmentRepo.find({ + where: { + trainScheduleId: scheduleId, + status: In([ + CrewAssignmentStatus.PLANNED, + CrewAssignmentStatus.CONFIRMED, + CrewAssignmentStatus.COMPLETED, + ]), + }, + relations: { crewMember: true }, + order: { createdAt: 'ASC' }, + }); + } + + /** + * What this schedule's consist and cargo demand (§1.2). + * + * Read straight from the train set and its allocations rather than asked of + * the user: the wagons and bookings already say whether a bad-order wagon is + * attached and whether reefer, hazardous, break-bulk or livestock cargo is + * aboard, so the requirement is derived and every row can name its trigger. + */ + async detectDemand(scheduleId: string): Promise { + const badOrder: Array<{ label: string }> = await this.dataSource.query( + ` + SELECT COALESCE(w.wagon_number, tsw.id::text) AS label + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id + LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id + WHERE ts.id = $1 + AND w.status = ANY($2) + `, + [scheduleId, BAD_ORDER_WAGON_STATUSES], + ); + + const cargo: Array<{ + reference: string | null; + is_reefer: boolean; + is_hazardous: boolean; + load_type: string | null; + cargo_type_name: string | null; + }> = await this.dataSource.query( + ` + SELECT DISTINCT + b.reference, + b.is_reefer, + b.is_hazardous, + wba.load_type, + ct.cargo_type_name + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id + JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id + JOIN freight.bookings b ON b.id = wba.booking_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + WHERE ts.id = $1 + `, + [scheduleId], + ); + + const label = (row: { reference: string | null }) => row.reference ?? 'a booking'; + const isLivestock = (name: string | null) => + Boolean(name) && + LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint)); + + const reefer = cargo.filter((c) => c.is_reefer); + const hazmat = cargo.filter((c) => c.is_hazardous); + // Break-bulk rides as a bulk allocation rather than a container. + const breakBulk = cargo.filter((c) => c.load_type === 'BULK'); + const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name)); + + return { + hasBadOrderWagon: badOrder.length > 0, + badOrderWagonLabels: badOrder.map((w) => w.label), + hasReeferCargo: reefer.length > 0, + reeferSources: reefer.map(label), + hasHazmatCargo: hazmat.length > 0, + hazmatSources: hazmat.map(label), + hasBreakBulkCargo: breakBulk.length > 0, + breakBulkSources: breakBulk.map(label), + hasLivestockCargo: livestock.length > 0, + livestockSources: livestock.map(label), + }; + } + + /** + * The corridor this schedule runs on: every active yard by id, plus where the + * schedule starts, ends, and where Dire Dawa sits. `display_order` is the + * yard's place along the line, which is what lets the rules answer "is this + * leg inside the route" and "does it cross the territorial boundary" without + * hard-coding station names. + */ + async loadCorridor(scheduleId: string): Promise { + const rows: Array<{ + id: string; + code: string; + label: string; + country: string; + display_order: number; + }> = await this.dataSource.query( + `SELECT id, code, label, country, display_order + FROM freight.yards + WHERE is_active = true + ORDER BY display_order ASC`, + ); + + const yards = new Map( + rows.map((r) => [ + r.id, + { + id: r.id, + label: r.label, + country: r.country, + displayOrder: Number(r.display_order), + }, + ]), + ); + + const [schedule]: Array<{ + origin_station_id: string | null; + destination_station_id: string | null; + }> = await this.dataSource.query( + `SELECT origin_station_id, destination_station_id + FROM freight.train_schedules WHERE id = $1`, + [scheduleId], + ); + + const orderOf = (id: string | null | undefined) => + id ? yards.get(id)?.displayOrder : undefined; + + return { + yards, + originOrder: orderOf(schedule?.origin_station_id), + destinationOrder: orderOf(schedule?.destination_station_id), + direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE) + ? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order) + : undefined, + }; + } + + /** Yards a driver leg may use — every yard between origin and destination. */ + async corridorYards(scheduleId: string): Promise { + const corridor = await this.loadCorridor(scheduleId); + const all = [...(corridor.yards?.values() ?? [])].sort( + (a, b) => a.displayOrder - b.displayOrder, + ); + if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) { + return all; + } + const low = Math.min(corridor.originOrder, corridor.destinationOrder); + const high = Math.max(corridor.originOrder, corridor.destinationOrder); + return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high); + } + + /** + * Full picture for one schedule: who is assigned, what the cargo demands, and + * which composition rules currently fail. The wizard renders this directly. + */ + async getScheduleCrew(scheduleId: string) { + const [assignments, demand, corridor] = await Promise.all([ + this.listForSchedule(scheduleId), + this.detectDemand(scheduleId), + this.loadCorridor(scheduleId), + ]); + + const validation = validateCrewComposition( + assignments.map(toFacts), + demand, + corridor, + ); + + return { + scheduleId, + assignments, + corridorYards: [...(corridor.yards?.values() ?? [])] + .filter((y) => { + if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) { + return true; + } + const low = Math.min(corridor.originOrder, corridor.destinationOrder); + const high = Math.max(corridor.originOrder, corridor.destinationOrder); + return y.displayOrder >= low && y.displayOrder <= high; + }) + .sort((a, b) => a.displayOrder - b.displayOrder), + demand, + requirements: { + technician: technicianRequirement(demand), + specialized: specializedRequirements(demand), + }, + validation, + }; + } + + /** + * Replace a schedule's crew in one transaction. + * + * A whole-set replace rather than per-row edits: the wizard submits the + * finished crew, and composition rules are only meaningful over the complete + * set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster + * over days, and §1.2 places the hard gate at departure, not at save time. + * Only structural errors (unknown member, wrong role, territorial breach) + * reject here; the rest surface as issues and block dispatch. + */ + async saveAssignments( + scheduleId: string, + dto: SaveCrewAssignmentsDto, + ): Promise { + const rows = dto.assignments ?? []; + const memberIds = rows.map((r) => r.crewMemberId); + + const corridor = await this.loadCorridor(scheduleId); + const members = memberIds.length + ? await this.memberRepo.find({ where: { id: In(memberIds) } }) + : []; + const byId = new Map(members.map((m) => [m.id, m])); + + for (const row of rows) { + const member = byId.get(row.crewMemberId); + if (!member) { + throw new NotFoundException(`Crew member ${row.crewMemberId} not found`); + } + if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) { + throw new BadRequestException( + `${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`, + ); + } + if (row.role !== member.role) { + throw new BadRequestException( + `${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`, + ); + } + if (member.role === TrainCrewRole.TRAIN_DRIVER) { + if (!row.fromYardId || !row.toYardId || !row.dutyRole) { + throw new BadRequestException( + `Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`, + ); + } + // §1.1 territorial boundary is structural — never persist a breach. + const from = corridor.yards?.get(row.fromYardId); + const to = corridor.yards?.get(row.toYardId); + if ( + !legAllowsNationality( + from, + to, + member.nationality, + corridor.direDawaOrder ?? Number.POSITIVE_INFINITY, + ) + ) { + throw new BadRequestException( + `${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`, + ); + } + } + } + + await this.dataSource.transaction(async (manager) => { + const repo = manager.getRepository(TrainCrewAssignment); + await repo.delete({ trainScheduleId: scheduleId }); + if (rows.length) { + await repo.insert( + rows.map((row) => ({ + trainScheduleId: scheduleId, + crewMemberId: row.crewMemberId, + role: row.role, + dutyRole: row.dutyRole ?? null, + fromYardId: row.fromYardId ?? null, + toYardId: row.toYardId ?? null, + status: CrewAssignmentStatus.PLANNED, + notes: row.notes ?? null, + })), + ); + } + }); + + const demand = await this.detectDemand(scheduleId); + const saved = await this.listForSchedule(scheduleId); + return validateCrewComposition(saved.map(toFacts), demand, corridor); + } + + /** + * Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule + * listed, so staff see the whole gap at once rather than one error per retry. + */ + async assertCrewReadyForDispatch(scheduleId: string): Promise { + const { validation } = await this.getScheduleCrew(scheduleId); + if (!validation.complete) { + throw new BadRequestException( + `Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`, + ); + } + } + + /** Roster drivers eligible for a leg between two yards (§1.1). */ + async eligibleDrivers( + scheduleId: string, + fromYardId?: string, + toYardId?: string, + ): Promise { + const drivers = await this.memberRepo.find({ + where: { + role: TrainCrewRole.TRAIN_DRIVER, + status: TrainCrewStatus.ACTIVE, + isActive: true, + }, + order: { firstName: 'ASC' }, + }); + if (!fromYardId || !toYardId) return drivers; + + const corridor = await this.loadCorridor(scheduleId); + const from = corridor.yards?.get(fromYardId); + const to = corridor.yards?.get(toYardId); + return drivers.filter((d) => + legAllowsNationality( + from, + to, + d.nationality, + corridor.direDawaOrder ?? Number.POSITIVE_INFINITY, + ), + ); + } +} + +/** Reduce a persisted assignment to the facts the rules read. */ +const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({ + crewMemberId: a.crewMemberId, + role: a.role, + dutyRole: a.dutyRole, + fromYardId: a.fromYardId, + toYardId: a.toYardId, + nationality: a.crewMember?.nationality ?? '', + memberName: a.crewMember + ? `${a.crewMember.firstName} ${a.crewMember.lastName}` + : 'A crew member', +}); diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts new file mode 100644 index 000000000..b5ba3dc27 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto'; +import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto'; +import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto'; +import { TrainCrewService } from './train-crew.service'; + +@ApiTags('train-crew') +@ApiBearerAuth() +@Controller('train-crew') +// Class gate lists every key its routes use: Nest runs class AND method +// guards, so a key missing here would deny before the route's own key runs. +@BookingStaff([ + FREIGHT_PERMS.trainCrew.view, + FREIGHT_PERMS.trainCrew.create, + FREIGHT_PERMS.trainCrew.update, + FREIGHT_PERMS.trainCrew.delete, +]) +export class TrainCrewController { + constructor(private readonly trainCrewService: TrainCrewService) {} + + @Post() + @BookingStaff(FREIGHT_PERMS.trainCrew.create) + @ApiOperation({ summary: 'Create a train crew member' }) + create(@Body() dto: CreateTrainCrewMemberDto) { + return this.trainCrewService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List train crew members with filters' }) + findAll(@Query() query: QueryTrainCrewMemberDto) { + return this.trainCrewService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a train crew member by id' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.trainCrewService.findById(id); + } + + @Patch(':id') + @BookingStaff(FREIGHT_PERMS.trainCrew.update) + @ApiOperation({ summary: 'Update a train crew member' }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateTrainCrewMemberDto, + ) { + return this.trainCrewService.update(id, dto); + } + + @Delete(':id') + @BookingStaff(FREIGHT_PERMS.trainCrew.delete) + @ApiOperation({ summary: 'Delete a train crew member' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.trainCrewService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts new file mode 100644 index 000000000..8e9d01da9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainCrewAssignment } from './entities/train-crew-assignment.entity'; +import { TrainCrewMember } from './entities/train-crew-member.entity'; +import { TrainCrewAssignmentController } from './train-crew-assignment.controller'; +import { TrainCrewAssignmentService } from './train-crew-assignment.service'; +import { TrainCrewController } from './train-crew.controller'; +import { TrainCrewService } from './train-crew.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])], + providers: [TrainCrewService, TrainCrewAssignmentService], + controllers: [TrainCrewController, TrainCrewAssignmentController], + exports: [TrainCrewService, TrainCrewAssignmentService], +}) +export class TrainCrewModule {} diff --git a/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts new file mode 100644 index 000000000..ae8ffbafe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-crew/train-crew.service.ts @@ -0,0 +1,111 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ILike, Repository } from 'typeorm'; + +import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto'; +import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto'; +import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto'; +import { TrainCrewMember } from './entities/train-crew-member.entity'; + +const DEFAULT_LIMIT = 25; + +@Injectable() +export class TrainCrewService { + constructor( + @InjectRepository(TrainCrewMember) + private readonly crewRepo: Repository, + ) {} + + async create(dto: CreateTrainCrewMemberDto): Promise { + await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role); + const member = this.crewRepo.create(dto); + return this.crewRepo.save(member); + } + + async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{ + data: TrainCrewMember[]; + total: number; + page: number; + limit: number; + }> { + const page = query.page ?? 1; + const limit = query.limit ?? DEFAULT_LIMIT; + + const qb = this.crewRepo.createQueryBuilder('c'); + + if (query.search) { + qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', { + search: `%${query.search}%`, + }); + } + if (query.role) qb.andWhere('c.role = :role', { role: query.role }); + if (query.nationality) { + qb.andWhere('c.nationality = :nationality', { nationality: query.nationality }); + } + if (query.status) qb.andWhere('c.status = :status', { status: query.status }); + if (query.isActive !== undefined) { + qb.andWhere('c.isActive = :isActive', { isActive: query.isActive }); + } + + // sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here. + const [data, total] = await qb + .orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(id: string): Promise { + const member = await this.crewRepo.findOne({ where: { id } }); + if (!member) { + throw new NotFoundException(`Train crew member ${id} not found`); + } + return member; + } + + async update(id: string, dto: UpdateTrainCrewMemberDto): Promise { + const member = await this.findById(id); + + const firstName = dto.firstName ?? member.firstName; + const lastName = dto.lastName ?? member.lastName; + const role = dto.role ?? member.role; + const identityChanged = + firstName !== member.firstName || + lastName !== member.lastName || + role !== member.role; + if (identityChanged) { + await this.assertNoDuplicate(firstName, lastName, role, id); + } + + Object.assign(member, dto); + return this.crewRepo.save(member); + } + + async remove(id: string): Promise { + await this.findById(id); + await this.crewRepo.softDelete(id); + } + + /** + * The roster carries no employee number yet, so name + role is the only + * identity available to catch an accidental re-entry of the same person. + * Case-insensitive; `exceptId` skips the row being updated. + */ + private async assertNoDuplicate( + firstName: string, + lastName: string, + role: string, + exceptId?: string, + ): Promise { + const existing = await this.crewRepo.findOne({ + where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never }, + }); + if (existing && existing.id !== exceptId) { + throw new ConflictException( + `Train crew member ${firstName} ${lastName} (${role}) already exists`, + ); + } + } +} 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 1c7cd11ca..1416d1d16 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 @@ -871,7 +871,7 @@ export class TrainSchedulingController { @TrainSchedulingView() @ApiOperation({ summary: - "Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)", + "Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)", }) async scheduleWagonListExport( @Param("id", ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 50debc6c0..1037364f2 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm'; @Entity({ schema: 'freight', name: 'train_scheduling_global_rules' }) export class TrainSchedulingGlobalRules extends BaseEntity { + /** + * LEGACY — `max_train_length_meters`, `max_train_weight_tons` and + * `max_20ft_container_weight_tons` are no longer read by planning: train + * weight/length come from locomotive configuration and per-box ceilings from + * the rule engine's weight limit rules (`max_capacity_tons`). Kept only so + * existing rows keep loading. + */ @Column({ name: 'max_train_length_meters', type: 'numeric', diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index f61593442..03235a983 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; @@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => { expect(written.windowPhase).toBeUndefined(); }); }); + + describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => { + const ceilings = (bookings: unknown[]) => + ( + service as never as { + containerCapacityCeilingsByLine: (b: unknown[]) => Promise>; + } + ).containerCapacityCeilingsByLine(bookings); + + it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => { + const find = jest.fn().mockResolvedValue([ + { containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' }, + { containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' }, + { containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null }, + ]); + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WeightLimitRule) return { find }; + throw new Error('unexpected repository'); + }); + + const result = await ceilings([ + { + tradeDirection: 'EXPORT', + bookingContainers: [ + { id: 'line-a', containerTypeId: 'ct-20' }, + { id: 'line-b', containerTypeId: 'ct-40' }, + ], + }, + { tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] }, + ]); + + expect(result).toEqual({ 'line-a': 26, 'line-c': 28 }); + expect(find).toHaveBeenCalledTimes(1); + }); + + it('queries nothing when the bookings carry no container lines', async () => { + dataSource.getRepository.mockImplementation(() => { + throw new Error('should not be called'); + }); + await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({}); + }); + }); }); 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 03d75da18..bfbeafed6 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 @@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository'; import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository'; import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository'; +import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service'; import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository'; import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository'; import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository'; @@ -74,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; import { Wagon } from '../../wagons/entities/wagon.entity'; import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service'; -import { TabularExportService } from '../../exports/tabular-export.service'; +import { + buildWagonListWorkbook, + groupWagonListLines, + WagonListLine, +} from '../utils/wagon-list-workbook.util'; /** One line of the schedule wagon-list export (raw SQL projection). */ -interface ScheduleWagonListRow { - sequenceNo: number | null; - wagonNumber: string | null; - wagonType: string | null; - containerNumber: string | null; - containerSizeFt: number | null; - loadType: string | null; - status: string | null; - bulkCargoDescription: string | null; - /** numeric columns arrive as strings from pg. */ - vgmTons: string | null; - originLabel: string | null; - destinationLabel: string | null; - bookingReference: string | null; - customerName: string | null; -} import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from '../dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; @@ -111,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule. import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto'; import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity'; import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto'; import { ImportDjiboutiOperation, @@ -159,6 +149,7 @@ import { validateMixedTrainLimitsPerEdge, MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, + type ContainerPlacementRules, type WagonPlanSlot, } from '../utils/wagon-plan.util'; import { @@ -189,6 +180,8 @@ import { trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, + MAX_FALLBACK_LENGTH, + MAX_FALLBACK_WEIGHT, WagonTypeDimensions, } from '../train-capacity.util'; import { @@ -378,13 +371,13 @@ export interface UnassignedBookingsResponse { bookings: CompositionUnassignedBookingRow[]; } -const DEFAULT_TRAIN_LIMITS: Required = { - maxWeightTons: 3500, - maxLengthMeters: 760, - maxWagonsPerTrain: Math.floor(760 / 14), - max20ftContainerWeightTons: 30, - max20ftPairWeightDiffTons: 10, -}; +/** + * Train weight/length come from locomotive configuration (the assigned set, or + * the strongest in-service locomotive when none is assigned yet); per-box + * container ceilings come from the rule engine's weight limit rules. Only the + * 20ft pair-imbalance tolerance is a static default. + */ +const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10; /** Raw row shape for the booking-window queries (company- and contract-scoped). */ interface BookingWindowRow { @@ -446,9 +439,10 @@ export class TrainSchedulingService { // Per-wagon history ledger (global module). @Optional keeps the positional // spec constructors working; production always has it. @Optional() private readonly wagonHistory?: WagonHistoryService, + // Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure"). // Trailing + @Optional so the positional constructors in the existing specs - // keep working; production always resolves it from ExportsModule. - @Optional() private readonly tabularExport?: TabularExportService, + // keep working; production always resolves it. + @Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService, ) {} /** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */ @@ -809,9 +803,9 @@ export class TrainSchedulingService { } /** - * Train length/weight and 20ft weight caps are engine-internal (wagon - * planning still reads them off the row); they are no longer exposed or - * editable through the global-rules endpoints. + * Train length/weight and the 20ft weight cap columns are legacy: planning + * now takes weight/length from locomotive configuration and per-box ceilings + * from weight limit rules. They are neither read nor exposed here. */ private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) { if (!row) return row; @@ -3027,6 +3021,11 @@ export class TrainSchedulingService { 'End the loading window at the origin station before dispatching', ); } + // On-board crew must be complete before the train leaves — ITLMS Rolling + // Stock §1.2 enforces composition "prior to departure", so an incomplete + // crew saves freely on the assignment page but cannot depart. Optional + // dependency: the positional spec constructors omit it. + await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId); // 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'); @@ -3770,15 +3769,15 @@ export class TrainSchedulingService { } /** - * The schedule detail page's wagon-list Excel export. - * - * One row per container (a wagon carrying two boxes yields two rows, repeating - * the wagon number) so each container's own VGM is present and totals footable. - * Bulk wagons, having no containers, yield a single row carrying the bulk - * description and the allocated tonnage as the VGM figure. + * The schedule detail page's wagon-list Excel export, laid out like the + * wagon sheet the yard circulates by hand: containers grouped by customer, + * one line per container (a two-box wagon repeats its wagon number under one + * "No."), a blank line between customers, and the wagon count / company / + * transitor merged down each group. See buildWagonListWorkbook. * * Only wagon slots that actually carry an allocation are listed — empty slots - * on the consist are omitted. + * on the consist are omitted. A bulk wagon yields one line carrying the cargo + * description in place of a container number. */ async scheduleWagonListWorkbook( scheduleId: string, @@ -3787,56 +3786,37 @@ export class TrainSchedulingService { if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); } - if (!this.tabularExport) { - throw new BadRequestException('Tabular export service is unavailable'); - } - // Row grain is the container item; the LEFT JOIN keeps bulk (and any - // container-less) allocation as one row. `booking_container_units` is joined - // on BOTH container number and its booking_container line — container - // numbers repeat across bookings, so number alone would multiply rows. - const rows: ScheduleWagonListRow[] = await this.dataSource.query( + // Row grain is the container item; the LEFT JOIN keeps a bulk (or any + // container-less) allocation as one row. The transitor is the customs + // clearing agent the customer named on the booking. + const lines: WagonListLine[] = await this.dataSource.query( `SELECT tsw.sequence_no AS "sequenceNo", w.wagon_number AS "wagonNumber", - COALESCE(wt.name, wt.code) AS "wagonType", ci.container_number AS "containerNumber", cit.size_ft AS "containerSizeFt", a.load_type AS "loadType", - a.status AS "status", bl.cargo_description AS "bulkCargoDescription", - COALESCE( - ci.gross_weight_tons, - bcu.vgm_tons, - bc.vgm_per_unit_tons, - a.allocated_weight_tons - ) AS "vgmTons", COALESCE(by_.label, so.label) AS "originLabel", COALESCE(ay.label, sd.label) AS "destinationLabel", - b.reference AS "bookingReference", COALESCE( slc.name, CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END, c.name - ) AS "customerName" + ) AS "customerName", + NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor" FROM freight.train_schedules s JOIN freight.train_set_wagons tsw ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL JOIN freight.wagon_booking_allocations a ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id - LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id LEFT JOIN freight.bookings b ON b.id = a.booking_id LEFT JOIN freight.companies c ON c.id = b.company_id LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id - LEFT JOIN freight.booking_container bc - ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL - LEFT JOIN freight.booking_container_units bcu - ON bcu.container_number = ci.container_number - AND bcu.booking_container_id = bc.id - AND bcu.deleted_at IS NULL LEFT JOIN freight.wagon_allocation_bulk_loads bl ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL LEFT JOIN freight.yards so ON so.id = s.origin_station_id @@ -3848,47 +3828,13 @@ export class TrainSchedulingService { [scheduleId], ); - // "number" is the printed line number of the sheet, not the wagon sequence — - // a two-container wagon occupies two lines, and the reader counts lines. - const sheetRows = rows.map((row, index) => ({ - number: index + 1, - wagonNumber: row.wagonNumber ?? '—', - containerNumber: - row.containerNumber ?? - (row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'), - vgmTons: row.vgmTons === null ? null : Number(row.vgmTons), - originLabel: row.originLabel ?? '—', - destinationLabel: row.destinationLabel ?? '—', - customerName: row.customerName ?? '—', - })); - - const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0); - const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; - - const buffer = await this.tabularExport.toXlsx({ - title: `Wagons ${reference}`.slice(0, 31), - description: `Wagon list for train ${reference}`, - label: 'train-schedule:wagon-list', - kpis: [ - { label: 'Lines', value: sheetRows.length }, - { - label: 'Wagons', - value: new Set(rows.map((r) => r.sequenceNo)).size, - }, - { label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' }, - ], - columns: [ - { key: 'number', label: 'No.', type: 'number' }, - { key: 'wagonNumber', label: 'Wagon', type: 'string' }, - { key: 'containerNumber', label: 'Container number', type: 'string' }, - { key: 'vgmTons', label: 'VGM', type: 'tons' }, - { key: 'originLabel', label: 'Origin', type: 'string' }, - { key: 'destinationLabel', label: 'Destination', type: 'string' }, - { key: 'customerName', label: 'Customer', type: 'string' }, - ], - rows: sheetRows, + const { groups, totalWagons } = groupWagonListLines(lines); + const buffer = await buildWagonListWorkbook({ + trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id, + groups, + totalWagons, }); - + const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id; return { filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`, buffer, @@ -6708,11 +6654,6 @@ export class TrainSchedulingService { )), ); - const placementRules = { - max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons, - max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, - }; - // With forceAssign, capacity-shaped rules (train limits, total weight, // locomotive capability) become warnings — staff owns the override. Physical // impossibilities (no wagon of the required type at the yard, wrong route, @@ -6745,6 +6686,11 @@ export class TrainSchedulingService { ); if (requireContainerPlacements && resolvedMode !== 'BULK') { const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); + const placementRules: ContainerPlacementRules = { + maxContainerWeightTonsByLineId: + await this.containerCapacityCeilingsByLine(containerBookings), + max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, + }; violations.push( ...validateContainerPlacements( containerBookings, @@ -6892,6 +6838,70 @@ export class TrainSchedulingService { } } + /** + * Hard per-box ceiling for every container line of the given bookings, from + * the rule engine's weight limit rule (`max_capacity_tons`) matching the + * line's container type and the booking's trade direction (a `BOTH` rule + * applies to either direction; an exact-direction rule wins over it). Lines + * whose rule has no capacity set get no entry — capacity is optional. + */ + private async containerCapacityCeilingsByLine( + bookings: Booking[], + ): Promise> { + const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = []; + for (const booking of bookings) { + const direction = String(booking.tradeDirection ?? '').toUpperCase(); + for (const line of booking.bookingContainers ?? []) { + if (!line.containerTypeId) continue; + lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction }); + } + } + if (!lines.length) return {}; + + const typeIds = [...new Set(lines.map((l) => l.containerTypeId))]; + const rules = await this.dataSource + .getRepository(WeightLimitRule) + .find({ where: { containerTypeId: In(typeIds) } }); + + const ceilings: Record = {}; + for (const { lineId, containerTypeId, tradeDirection } of lines) { + const candidates = rules.filter( + (r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null, + ); + const rule = + candidates.find((r) => r.tradeDirection === tradeDirection) ?? + candidates.find((r) => r.tradeDirection === 'BOTH'); + const cap = Number(rule?.maxCapacityTons); + if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap; + } + return ceilings; + } + + /** + * Limits for a train that has no locomotive assigned yet: the strongest + * in-service locomotive on each axis, so planning assumes the most capable + * power that could be coupled. Null when no locomotive is configured at all. + */ + private async strongestFleetLocomotiveLimits(): Promise { + const fleet = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0); + const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0); + if (!pulls.length && !lengths.length) return null; + const strongest = (axis: number[], pick: (l: Locomotive) => number) => + fleet.find((l) => pick(l) === Math.max(...axis)); + return { + maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity, + maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity, + overageToleranceTons: + Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0, + overageToleranceMeters: + Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) || + 0, + }; + } + private async resolveTrainLimitConfig( dto?: { maxTrainWeightTons?: number; @@ -6902,24 +6912,14 @@ export class TrainSchedulingService { builtWagonCount?: number, ): Promise> { const row = await this.loadGlobalRulesRow(); - const configured = this.configService?.get<{ - maxTrainWeightTons?: number; - maxTrainLengthMeters?: number; - maxWagonsPerTrain?: number; - }>('app.trainScheduling'); - - const ruleWeightCap = - dto?.maxTrainWeightTons ?? - (row?.maxTrainWeightTons != null - ? Number(row.maxTrainWeightTons) - : configured?.maxTrainWeightTons); - const ruleLengthCap = - dto?.maxTrainLengthMeters ?? - (row?.maxTrainLengthMeters != null - ? Number(row.maxTrainLengthMeters) - : configured?.maxTrainLengthMeters); - + const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>( + 'app.trainScheduling', + ); const wagonTypes = await this.loadSchedulingWagonTypeDimensions(); + const max20ftPairWeightDiffTons = this.positiveNumber( + undefined, + Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS, + ); if (locomotive) { // With a locomotive assigned its own limits are the single source of @@ -6954,52 +6954,40 @@ export class TrainSchedulingService { : builtWagonCount && builtWagonCount > 0 ? builtWagonCount : derived.maxWagonSlots, - max20ftContainerWeightTons: this.positiveNumber( - undefined, - Number(row?.max20ftContainerWeightTons) || - DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, - ), - max20ftPairWeightDiffTons: this.positiveNumber( - undefined, - Number(row?.max20ftPairWeightDiffTons) || - DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, - ), + max20ftPairWeightDiffTons, }; } - const maxWeightTons = this.positiveNumber( - dto?.maxTrainWeightTons, - ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons, - ); - const maxLengthMeters = this.positiveNumber( - dto?.maxTrainLengthMeters, - ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters, - ); - const derivedWithoutLoco = deriveTrainCapacityFromLocomotive( - { maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters }, + // No locomotive on the set yet: plan against the strongest in-service + // locomotive's configuration. An explicit dto override still narrows it. + const fleet = await this.strongestFleetLocomotiveLimits(); + if (!fleet) { + this.logger.warn( + 'No in-service locomotive is configured — train weight/length limits fall back to ' + + `${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`, + ); + } + const derived = deriveTrainCapacityFromLocomotive( + fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH }, wagonTypes, + { + maxTrainWeightTons: dto?.maxTrainWeightTons, + maxTrainLengthMeters: dto?.maxTrainLengthMeters, + }, ); return { - maxWeightTons, - maxLengthMeters, + maxWeightTons: derived.maxWeightTons, + maxLengthMeters: derived.maxLengthMeters, maxWagonsPerTrain: Math.floor( this.positiveNumber( dto?.maxWagonsPerTrain, row?.maxWagonsPerTrain != null ? Number(row.maxWagonsPerTrain) - : configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots, + : configured?.maxWagonsPerTrain ?? derived.maxWagonSlots, ), ), - max20ftContainerWeightTons: this.positiveNumber( - undefined, - Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons, - ), - max20ftPairWeightDiffTons: this.positiveNumber( - undefined, - Number(row?.max20ftPairWeightDiffTons) || - DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons, - ), + max20ftPairWeightDiffTons, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index fdbc504b5..0ce67e959 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module'; import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module'; import { BookingsModule } from '../bookings/bookings.module'; import { Container } from '../container-management/entities/container.entity'; -import { ExportsModule } from '../exports/exports.module'; import { LocomotivesModule } from '../locomotives/locomotives.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FacilityHandlingService } from './facility-handling.service'; @@ -17,6 +16,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive. import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainCrewModule } from '../train-crew/train-crew.module'; import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesModule } from '../wagon-types/wagon-types.module'; @@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module'; UserTradeAccessModule, NotificationsModule, NotificationInboxModule, - ExportsModule, LocomotivesModule, WagonTypesModule, TrainSetsModule, @@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module'; forwardRef(() => WarehousesModule), RuleEngineModule, forwardRef(() => ContractsModule), + TrainCrewModule, ], controllers: [TrainSchedulingController], providers: [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts new file mode 100644 index 000000000..58bf11a3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.spec.ts @@ -0,0 +1,169 @@ +import ExcelJS from 'exceljs'; + +import { + buildWagonListWorkbook, + groupWagonListLines, + WAGON_LIST_HEADERS, + WagonListLine, + wagonListSheetName, +} from './wagon-list-workbook.util'; + +const line = (overrides: Partial): WagonListLine => ({ + sequenceNo: 1, + wagonNumber: 'ER0001', + containerNumber: 'CONT0000001', + containerSizeFt: 40, + loadType: 'CONTAINER', + bulkCargoDescription: null, + originLabel: 'DCT', + destinationLabel: 'GMP', + customerName: 'ABC transit', + transitor: null, + ...overrides, +}); + +// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes, +// then a second customer's single wagon, and a bulk wagon for a third. +const fixture: WagonListLine[] = [ + line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }), + line({ + sequenceNo: 2, + wagonNumber: 'ER0693', + containerNumber: 'CXDU1833620', + containerSizeFt: 20, + transitor: 'Semuzu Transit', + }), + line({ + sequenceNo: 2, + wagonNumber: 'ER0693', + containerNumber: 'TTNU1328287', + containerSizeFt: 20, + transitor: 'Semuzu Transit', + }), + line({ + sequenceNo: 3, + wagonNumber: 'ER0444', + containerNumber: 'ESLU0720200', + containerSizeFt: 20, + customerName: 'SYNTRANS LOGISTICS PLC', + }), + line({ + sequenceNo: 4, + wagonNumber: 'ER0716', + containerNumber: null, + containerSizeFt: null, + loadType: 'BULK', + bulkCargoDescription: 'Wheat', + customerName: 'Baili food processing', + }), +]; + +describe('groupWagonListLines', () => { + it('groups by customer in first-appearance order and counts wagons, not containers', () => { + const { groups, totalWagons } = groupWagonListLines(fixture); + + expect(groups.map((g) => g.companyName)).toEqual([ + 'ABC transit', + 'SYNTRANS LOGISTICS PLC', + 'Baili food processing', + ]); + expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]); + expect(totalWagons).toBe(4); + }); + + it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => { + const { groups } = groupWagonListLines(fixture); + + expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]); + expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]); + expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]); + }); + + it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => { + const { groups } = groupWagonListLines(fixture); + + expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']); + expect(groups[0].transitor).toBe('Semuzu Transit'); + expect(groups[2].lines[0]).toMatchObject({ + containerNumber: 'Wheat', + containerType: 'Bulk', + }); + expect(groups[2].transitor).toBe(''); + }); + + it('files lines with no customer under a placeholder group', () => { + const { groups } = groupWagonListLines([line({ customerName: null })]); + expect(groups[0].companyName).toBe('—'); + }); +}); + +describe('wagonListSheetName', () => { + it('strips characters Excel forbids and caps at 31 characters', () => { + expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502'); + expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31); + expect(wagonListSheetName('///')).toBe('Wagons'); + }); +}); + +describe('buildWagonListWorkbook', () => { + let sheet: ExcelJS.Worksheet; + + beforeAll(async () => { + const grouped = groupWagonListLines(fixture); + const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped }); + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer); + sheet = workbook.worksheets[0]; + }); + + const cell = (address: string) => sheet.getCell(address).value; + const merged = (address: string) => sheet.getCell(address).isMerged; + + it('opens with the banner (train + total wagons) merged across every column, then the headers', () => { + expect(sheet.name).toBe('V138U 8502'); + expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/); + expect(merged('I1')).toBe(true); + expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]); + expect(sheet.getCell('A2').font?.bold).toBe(true); + }); + + it('lays each customer out as a contiguous block separated by a blank row', () => { + // Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili. + expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']); + expect(sheet.getRow(6).values).toEqual([]); + expect(cell('B7')).toBe('ER0444'); + expect(sheet.getRow(8).values).toEqual([]); + expect(cell('B9')).toBe('ER0716'); + expect(cell('C9')).toBe('Wheat'); + expect(cell('G9')).toBe('Bulk'); + }); + + it('prints "No." once per wagon, merged down a two-container wagon', () => { + expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]); + expect(merged('A4')).toBe(true); + expect(merged('A5')).toBe(true); + expect(merged('A3')).toBe(false); + expect(cell('A7')).toBe(3); + expect(cell('A9')).toBe(4); + }); + + it('merges wagon count, company and transitor down the whole customer block', () => { + expect(cell('D3')).toBe(2); + expect(cell('H3')).toBe('ABC transit'); + expect(cell('I3')).toBe('Semuzu Transit'); + for (const col of ['D', 'H', 'I']) { + expect(merged(`${col}3`)).toBe(true); + expect(merged(`${col}5`)).toBe(true); + } + expect(sheet.getCell('H3').font?.bold).toBe(true); + // A single-line block has nothing to merge. + expect(merged('H7')).toBe(false); + expect(cell('D7')).toBe(1); + expect(cell('I7')).toBeNull(); + }); + + it('carries the route and container size on every line', () => { + expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']); + expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts new file mode 100644 index 000000000..adfd04075 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-list-workbook.util.ts @@ -0,0 +1,219 @@ +import ExcelJS from 'exceljs'; + +/** + * One loaded container (or one bulk load) on a wagon of the schedule — the + * input grain of the wagon-list workbook. A wagon carrying two boxes arrives + * as two lines sharing `sequenceNo`. + */ +export interface WagonListLine { + sequenceNo: number | null; + wagonNumber: string | null; + containerNumber: string | null; + /** 20 / 40 / 45 …; null for bulk or unknown. */ + containerSizeFt: number | null; + loadType: string | null; + bulkCargoDescription: string | null; + originLabel: string | null; + destinationLabel: string | null; + customerName: string | null; + /** The customs clearing / transit agent named on the booking. */ + transitor: string | null; +} + +export interface WagonListGroupLine { + /** Sheet-wide wagon counter — printed once per wagon, not once per container. */ + wagonOrdinal: number; + sequenceNo: number | null; + wagonNumber: string; + containerNumber: string; + containerType: string; + origin: string; + destination: string; +} + +/** All lines of one customer, contiguous on the sheet. */ +export interface WagonListGroup { + companyName: string; + transitor: string; + /** Distinct wagons in the group — the "Number of Wagons" cell. */ + wagonCount: number; + lines: WagonListGroupLine[]; +} + +export interface WagonListWorkbookInput { + /** Train number (falls back to the schedule reference) — the banner text. */ + trainLabel: string; + groups: WagonListGroup[]; + totalWagons: number; +} + +const BLANK = '—'; + +/** + * Groups the container-grain lines by customer, in order of first appearance, + * keeping consist order inside each group. Wagon ordinals run across the whole + * sheet so the reader can count wagons down the "No." column. + */ +export function groupWagonListLines(lines: WagonListLine[]): { + groups: WagonListGroup[]; + totalWagons: number; +} { + const groups = new Map< + string, + WagonListGroup & { transitors: Set; wagons: Set } + >(); + const ordinalByGroupWagon = new Map(); + let nextOrdinal = 1; + + for (const line of lines) { + const companyName = line.customerName?.trim() || BLANK; + let group = groups.get(companyName); + if (!group) { + group = { + companyName, + transitor: '', + wagonCount: 0, + lines: [], + transitors: new Set(), + wagons: new Set(), + }; + groups.set(companyName, group); + } + + const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`; + const ordinalKey = `${companyName} ${wagonKey}`; + let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey); + if (wagonOrdinal === undefined) { + wagonOrdinal = nextOrdinal++; + ordinalByGroupWagon.set(ordinalKey, wagonOrdinal); + group.wagons.add(wagonKey); + } + const transitor = line.transitor?.trim(); + if (transitor) group.transitors.add(transitor); + + const isBulk = line.loadType === 'BULK' && !line.containerNumber; + group.lines.push({ + wagonOrdinal, + sequenceNo: line.sequenceNo, + wagonNumber: line.wagonNumber ?? BLANK, + containerNumber: + line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK), + containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK, + origin: line.originLabel ?? BLANK, + destination: line.destinationLabel ?? BLANK, + }); + } + + const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({ + ...group, + transitor: [...transitors].join(', '), + wagonCount: wagons.size, + })); + return { + groups: result, + totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0), + }; +} + +const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9]; +export const WAGON_LIST_HEADERS = [ + 'No.', + 'Wagon', + 'Container No.', + 'Number of Wagons', + 'Origin', + 'Destination', + 'Type of Container', + 'Company Name', + 'Transitor', +]; +const LAST_COLUMN = WAGON_LIST_HEADERS.length; +/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */ +const BANNER_FILL: ExcelJS.Fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFACB9CA' }, +}; +const CENTERED: Partial = { horizontal: 'center', vertical: 'middle' }; + +/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */ +export function wagonListSheetName(trainLabel: string): string { + const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim(); + return (cleaned || 'Wagons').slice(0, 31); +} + +/** + * The operations wagon-list sheet, laid out like the hand-made one the yard + * circulates: a banner row (train number + total wagons), one header row, then + * the containers grouped by customer with a blank row between customers. + * Inside a group the wagon number repeats per container while "No." is merged + * down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged + * down the whole group. + */ +export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), { + views: [{ zoomScale: 85 }], + }); + COLUMN_WIDTHS.forEach((width, i) => { + sheet.getColumn(i + 1).width = width; + }); + + const banner = sheet.addRow([ + `${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`, + ]); + sheet.mergeCells(1, 1, 1, LAST_COLUMN); + banner.height = 28; + const bannerCell = banner.getCell(1); + bannerCell.font = { name: 'Calibri', size: 12, bold: true }; + bannerCell.alignment = CENTERED; + bannerCell.fill = BANNER_FILL; + + const header = sheet.addRow(WAGON_LIST_HEADERS); + header.eachCell((cell) => { + cell.font = { name: 'Calibri', size: 11, bold: true }; + cell.alignment = CENTERED; + }); + + input.groups.forEach((group, groupIndex) => { + if (groupIndex > 0) sheet.addRow([]); + const firstRow = sheet.rowCount + 1; + + let wagonStartRow = firstRow; + group.lines.forEach((line, lineIndex) => { + const isFirstLine = lineIndex === 0; + const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal; + const row = sheet.addRow([ + newWagon ? line.wagonOrdinal : null, + line.wagonNumber, + line.containerNumber, + isFirstLine ? group.wagonCount : null, + line.origin, + line.destination, + line.containerType, + isFirstLine ? group.companyName : null, + isFirstLine ? group.transitor || null : null, + ]); + for (let col = 1; col <= LAST_COLUMN; col++) { + const cell = row.getCell(col); + cell.font = { name: 'Calibri', size: 11, bold: col === 8 }; + if (col === 8) cell.alignment = { ...CENTERED, wrapText: true }; + else if (col !== 2 && col !== 3) cell.alignment = CENTERED; + } + row.getCell(1).numFmt = '#,##0'; + + if (newWagon && !isFirstLine) { + if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1); + wagonStartRow = row.number; + } + }); + + const lastRow = sheet.rowCount; + if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1); + if (lastRow > firstRow) { + for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col); + } + }); + + return Buffer.from(await workbook.xlsx.writeBuffer()); +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index a9741b476..f8b831bb0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -171,7 +171,7 @@ describe('wagon-plan.util', () => { expect(validateContainerPlacements([booking], plan, placements)).toEqual([]); }); - it('rejects 20ft container over max individual weight', () => { + it('rejects a container over its line weight-limit-rule capacity', () => { const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); const units = expandBookingContainerUnits([booking]); const placements = units.map((unit, index) => ({ @@ -182,11 +182,29 @@ describe('wagon-plan.util', () => { })); const violations = validate20ftContainerRules(units, placements, { - max20ftContainerWeightTons: 30, + maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 }, max20ftPairWeightDiffTons: 10, }); - expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true); + expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2); + }); + + it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => { + const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]); + const units = expandBookingContainerUnits([booking]); + const placements = units.map((unit, index) => ({ + bookingContainerId: unit.bookingContainerId, + unitIndex: unit.unitIndex, + sequenceNo: 1, + containerNumber: `CNTR-${index + 1}`, + })); + + const violations = validate20ftContainerRules(units, placements, { + maxContainerWeightTonsByLineId: {}, + max20ftPairWeightDiffTons: 10, + }); + + expect(violations).toEqual([]); }); it('rejects 20ft pair when weight difference exceeds limit', () => { @@ -204,7 +222,6 @@ describe('wagon-plan.util', () => { })); const violations = validate20ftContainerRules(units, placements, { - max20ftContainerWeightTons: 30, max20ftPairWeightDiffTons: 10, }); 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 b4e87d398..b31b5c7e2 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 @@ -20,12 +20,17 @@ export type TrainLimitConfig = { maxWeightTons?: number; maxLengthMeters?: number; maxWagonsPerTrain?: number; - max20ftContainerWeightTons?: number; max20ftPairWeightDiffTons?: number; }; export type ContainerPlacementRules = { - max20ftContainerWeightTons?: number; + /** + * Hard per-box weight ceiling keyed by booking container LINE id, resolved + * from the rule engine's weight limit rule (`max_capacity_tons`) for the + * line's container type and the booking's trade direction. A line with no + * entry has no ceiling — the rule's capacity is optional. + */ + maxContainerWeightTonsByLineId?: Record; max20ftPairWeightDiffTons?: number; }; @@ -820,15 +825,21 @@ export function perEdgeConsistUsage( ); } +/** + * Per-box weight rules for a container plan: + * - every unit is checked against its line's weight-limit-rule capacity + * ceiling (`maxContainerWeightTonsByLineId`, any size); + * - 20ft pairs sharing a wagon are checked for weight imbalance. + */ export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], rules?: ContainerPlacementRules, ): string[] { const violations: string[] = []; - const maxEach = rules?.max20ftContainerWeightTons; + const capacityByLine = rules?.maxContainerWeightTonsByLineId; const maxDiff = rules?.max20ftPairWeightDiffTons; - if (maxEach == null && maxDiff == null) return violations; + if (capacityByLine == null && maxDiff == null) return violations; const placementByUnit = new Map( placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]), @@ -837,15 +848,16 @@ export function validate20ftContainerRules( const weightsBySlot = new Map(); for (const unit of units) { - const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); - if (sizeFt >= 40) continue; - + const maxEach = capacityByLine?.[unit.bookingContainerId]; if (maxEach != null && unit.grossWeightTons > maxEach) { violations.push( - `${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`, + `${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`, ); } + const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20); + if (sizeFt >= 40) continue; + const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`); if (!placement?.sequenceNo) continue; diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts index 6d7084a96..6c22d37b5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/invoice.dto.ts @@ -14,7 +14,7 @@ export class GenerateInvoiceDto { @ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' }) @IsOptional() - @IsIn(['ETB', 'USD']) + @IsIn(['ETB', 'USD', 'DJF']) billingCurrency?: 'ETB' | 'USD'; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index c59cd0617..80a81b13a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; -import { ExchangeService } from '@edr/api-common'; +import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; @@ -430,8 +430,11 @@ export class WarehouseFeeService { }; } - private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { - return currency === 'ETB' ? 'ETB' : 'USD'; + private normalizeCurrency(currency?: string | null): CurrencyCode { + const code = currency?.toUpperCase(); + return (CURRENCY_CODES as readonly string[]).includes(code ?? '') + ? (code as CurrencyCode) + : 'USD'; } private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise { 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 5ddc99cca..2a3bd17aa 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 @@ -912,6 +912,129 @@ Settle assessed duties and taxes within the period notified by the Service Provi ), ]; +/* ────────────────────────── IMPORT / EMPTY CONTAINER ─────────────────────── */ + +/** + * Empty container import — bare equipment railed north from Djibouti for + * repositioning inland. Not a variant of the laden import pack: there is no + * cargo to describe, no VGM to declare, no commercial documents to lodge and no + * customs leg to sell, so the paper is a straight equipment-carriage agreement. + * Priced per box by size (20ft / 40ft) and lane, off an EMPTY_CONTAINER_IMPORT + * rate. + */ +const IMPORT_EMPTY_CONTAINER_BASE: ContractTemplateBase = { + name: "Empty Container Import Contract", + description: + "Railway transport of empty containers from Djibouti (DMP/Nagad) to the agreed Ethiopian terminal for repositioning. Priced per container by size; no cargo, no customs clearing.", + documentTitle: "Empty Container Transportation Service by Railway", + whereasClauses: [ + "The Client has requested and agreed to the transportation of empty containers from the Djibouti railway terminals (DMP or Nagad) to the agreed Ethiopian destination terminal using the Addis Ababa\u2013Djibouti railway line.", + "The containers covered by this Agreement carry no cargo, and the Service Provider is engaged for the carriage of the equipment itself.", + "The Service Provider has agreed to transport the empty containers as per the terms of this contract.", + ], + articles: [ + a( + "objective", + "Objective and Scope of the Services", + `To provide railway transportation services for empty 20ft and/or 40ft containers from the agreed Djibouti loading terminal (DMP or Nagad Railway Station) to the agreed Ethiopian destination terminal. +The scope of the services comprises: +- Terminal handling and loading of the empty containers onto flat wagons at the Djibouti loading terminal. +- Railway transport between the agreed origin and destination terminals. +- Unloading of the empty containers at the destination terminal. +The containers covered by this Agreement carry no cargo. Any container found to be laden at loading falls outside this Agreement and shall be handled and priced as a laden shipment.`, + ), + a( + "client-obligations", + "Obligations of the Client", + `Give written/email/electronic shipment instructions to the Service Provider stating the number of empty containers by size (20ft and/or 40ft), the loading terminal and the destination terminal. +Provide the container release order or equivalent instruction from the container owner or its agent, together with the container numbers, before loading. +Warrant that every container tendered is empty, free of residue, and holds no cargo, dunnage or personal effects. +Ensure the containers are presented at the loading terminal, in a condition fit for rail carriage, one day before the planned loading date. +One flat wagon carries either one 40ft container or two 20ft containers. +Book wagons at least five (5) days in advance. +Assign representatives at both ends to oversee container handover. +Collect the empty containers from the destination terminal within three (3) calendar days from the day following the arrival notice. +If the Client fails to collect the containers within the specified period, the Client shall be liable to pay the applicable demurrage, storage and double handling charges of the destination terminal. +Settle all charges due under this Agreement in accordance with the agreed payment terms.`, + ), + a( + "provider-obligations", + "Obligations of the Service Provider", + `Provide the agreed number of flat wagons on the agreed loading date, subject to wagon availability and the allocation priority applicable to the booking. +Handle and load the empty containers at the Djibouti loading terminal and unload them at the destination terminal. +Transport the empty containers to the agreed destination terminal and issue an arrival notice to the Client. +Record the condition of each container at handover, and hand over the containers at destination in the condition in which they were received, fair wear and tear from carriage excepted. +Issue the consignment note and the interchange documentation for each shipment. +Notify the Client without delay of any incident affecting the containers in the Service Provider's custody.`, + ), + a( + "liability", + "Liability for the Equipment", + `The Service Provider's liability under this Agreement is limited to loss of, or physical damage to, the containers while in its custody between loading at the origin terminal and handover at the destination terminal. +Because the containers carry no cargo, no cargo liability, cargo insurance obligation or cargo declaration arises under this Agreement. +The Service Provider shall not be liable for pre-existing damage recorded at loading, nor for damage arising from a defect in the container itself. +The Client shall indemnify the Service Provider against any claim arising from a container tendered as empty that is later found to contain cargo, residue or prohibited goods.`, + ), + a( + "force-majeure", + "Force Majeure", + `Neither party shall be liable for failure to perform its obligations under this Agreement where such failure results from an event beyond its reasonable control, including natural disaster, war, civil unrest, government action, or closure of the railway line or terminals. +The affected party shall notify the other in writing within five (5) calendar days of the occurrence and shall resume performance as soon as the event ceases.`, + ), + a( + "pricing", + "Contract Price and Terms of Payment", + `The price is charged per empty container carried, at the agreed rate for each container size (20ft and 40ft) on the agreed origin\u2013destination lane, as set out in the rate schedule to this Agreement. +The price covers terminal handling, loading, railway carriage and unloading as described in the Scope of the Services. It excludes any charge levied by the destination terminal after the free period, and any first-mile or last-mile road leg unless separately agreed. +Payment shall be made in accordance with the payment terms stated in this Agreement; where the price is quoted in USD and settled in Birr, conversion applies the Commercial Bank of Ethiopia's daily selling exchange rate on the date of payment. +The Service Provider may revise the rates on prior written notice to the Client.`, + ), + a( + "contract-documents", + "Contract Documents", + `The following form an integral part of this Agreement: +- This Agreement and its rate schedule. +- The container release order or equivalent instruction from the container owner or its agent. +- The shipment instruction given by the Client for each consignment. +- The consignment note and interchange documents issued for each shipment.`, + ), + a( + "consignment-notes", + "Consignment Notes", + `A consignment note shall be issued for each shipment, stating the container numbers, sizes, the origin and destination terminals and the recorded condition of each container. +The consignment note is evidence of the containers received for carriage and of their condition at handover.`, + ), + a( + "amendment", + "Amendment", + `Any amendment to this Agreement shall be valid only if made in writing and signed by the authorised representatives of both parties.`, + ), + a( + "termination", + "Termination of Contract", + `Either party may terminate this Agreement by giving thirty (30) calendar days' prior written notice to the other party. +Either party may terminate this Agreement with immediate effect where the other party commits a material breach and fails to remedy it within fifteen (15) calendar days of written notice. +Termination does not affect any obligation accrued before the effective date of termination, including payment for shipments already performed or in transit.`, + ), + a( + "effectiveness", + "Contract Effectiveness", + `This Agreement becomes effective on the date it is signed by the authorised representatives of both parties.`, + ), + a( + "duration", + "Contract Period", + `This Agreement shall remain in force for the period stated in the Agreement, unless terminated earlier in accordance with the Termination article.`, + ), + a( + "disputes", + "Settlement of Disputes", + `The parties shall attempt to settle any dispute arising out of or in connection with this Agreement amicably. +Failing amicable settlement, the dispute shall be resolved in accordance with the laws of the Federal Democratic Republic of Ethiopia before the competent courts of Ethiopia.`, + ), + ], +}; + /** Build the stored `_CUSTOMS` / `_ETHIOPIAN_CUSTOMS` / `_NO_CUSTOMS` trio for one base pack. */ function splitByCustoms( base: ContractTemplateBase, @@ -944,10 +1067,11 @@ function splitByCustoms( } /** - * Fourteen templates: import and export each split by customs clearing option + * Fifteen 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. + * border, so there is no customs leg to contract for. Empty container import + * is unsplit for the same reason: bare equipment carries no declaration. */ export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ ...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"), @@ -956,4 +1080,5 @@ export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [ ...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"), ...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"), { ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" }, + { ...IMPORT_EMPTY_CONTAINER_BASE, code: "IMPORT_EMPTY_CONTAINER" }, ]; 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 0a3d26d71..2eb64913d 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -472,6 +472,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:contracts:cancel", "Cancel a contract (terminal)", ), + // Add validity days to an EXPIRED contract the customer asked to extend and + // put it back where it was. Sits on the same desk as suspend/cancel. + perm( + "a3000001-0001-4000-8000-00000000001d", + "edr_freight_app:contracts:extend", + "Extend an expired contract", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -1506,6 +1513,38 @@ export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E'''. Train crew roster — the people assignable to a train (drivers, federal +// police, technicians, specialized cargo crew) per ITLMS Rolling Stock 1.2. +// Distinct from the `drivers` keys above, which gate the road/last-mile truck +// driver register. +export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f5a00001-0001-4000-8000-000000000001", + "edr_freight_app:train_crew:view", + "View train crew members", + ), + perm( + "f5a00001-0001-4000-8000-000000000002", + "edr_freight_app:train_crew:create", + "Create train crew member", + ), + perm( + "f5a00001-0001-4000-8000-000000000003", + "edr_freight_app:train_crew:update", + "Update train crew member", + ), + perm( + "f5a00001-0001-4000-8000-000000000004", + "edr_freight_app:train_crew:delete", + "Delete train crew member", + ), + perm( + "f5a00001-0001-4000-8000-000000000005", + "edr_freight_app:train_crew:assign", + "Assign train crew to a schedule", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1954,6 +1993,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, ...EMPTY_RETURN_REQUEST_PERMISSIONS, + ...TRAIN_CREW_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2091,6 +2131,7 @@ export const FREIGHT_PERMS = { clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", suspend: "edr_freight_app:contracts:suspend", cancel: "edr_freight_app:contracts:cancel", + extend: "edr_freight_app:contracts:extend", editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", @@ -2331,6 +2372,13 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:drivers:update", delete: "edr_freight_app:drivers:delete", }, + trainCrew: { + view: "edr_freight_app:train_crew:view", + create: "edr_freight_app:train_crew:create", + update: "edr_freight_app:train_crew:update", + delete: "edr_freight_app:train_crew:delete", + assign: "edr_freight_app:train_crew:assign", + }, tracking: { view: "edr_freight_app:tracking:view", manage: "edr_freight_app:tracking:manage", @@ -2498,6 +2546,11 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:support_content:view", manage: "edr_freight_app:settings:support_content:manage", }, + // Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice. + publications: { + view: "edr_freight_app:settings:publications:view", + manage: "edr_freight_app:settings:publications:manage", + }, }, support: { agentView: "edr_freight_app:support:agent_view", @@ -2913,6 +2966,8 @@ export const ROLE_PERMISSION_PRESETS = { // Terminal kill switch, granted alongside suspend on the same desk that // already rejects contracts and cancels bookings. FREIGHT_PERMS.contracts.cancel, + // Validity extension of an expired contract, on customer request. + FREIGHT_PERMS.contracts.extend, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, // Marketing follows up with the customer when a reviewer sends profile diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 652a331c7..c57af9a8a 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage" import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; +import PublicationsPage from "./pages/publications/PublicationsPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import WagonPerformancePage from "./pages/wagon-performance/WagonPerformancePage"; @@ -67,6 +68,7 @@ import WagonPerformanceDetailPage from "./pages/wagon-performance/WagonPerforman import WagonTransfersPage from "./pages/wagons/WagonTransfersPage"; import VehicleDetailPage from "./pages/fleet/VehicleDetailPage"; import DriverDetailPage from "./pages/fleet/DriverDetailPage"; +import TrainCrewPage from "./pages/train-crew/TrainCrewPage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; @@ -86,6 +88,7 @@ import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import ScheduleCrewPage from "./pages/trainScheduling/ScheduleCrewPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import OperationsStandardsPage from "./pages/settings/OperationsStandardsPage"; @@ -899,6 +902,14 @@ const App = () => { } /> + + + + } + /> { } /> + + + + } + /> { } /> + + + + } + /> = { DRAFT: { label: "Draft", color: "gray" }, diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 65c9719ed..678913f78 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import { Button, Center, Group, Loader, SimpleGrid, Stack, Table, Text } from "@mantine/core"; import { Coins, Truck } from "lucide-react"; +import { currencyDecimals } from "@edr/ui-common"; import { api } from "@/services/api"; import { FeePreviewModal } from "@/components/warehouses/FeePreviewModal"; @@ -12,8 +13,8 @@ import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => `${Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2, + minimumFractionDigits: currencyDecimals(currency), + maximumFractionDigits: currencyDecimals(currency), })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx index 318ce3f9b..a03c29bec 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/RebookWagonCancellationModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import toast from "react-hot-toast"; -import { OperationDatePicker } from "@edr/ui-common"; +import { OperationDatePicker, currencyDecimals } from "@edr/ui-common"; import { api } from "@/auth/http"; import { api as rpc } from "@/services/api"; @@ -223,7 +223,7 @@ export function RebookWagonCancellationModal({ {cancellation.booking?.reference ?? cancellation.bookingId} ·{" "} {cancellation.wagonsCancelled} wagon(s) · credit{" "} - {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)} + {formatMoney(cancellation.creditAmount, cancellation.feeCurrency, currencyDecimals(cancellation.feeCurrency))} Shipment day diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx index 4bca7cced..4f50c5921 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/wagon-cancellation/WagonCancellationCreditCard.tsx @@ -8,6 +8,7 @@ import { api } from "@/auth/http"; import { useAuth } from "@/auth/useAuth"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { formatDate, formatMoney } from "@/lib/format"; +import { currencyDecimals } from "@edr/ui-common"; import { RebookWagonCancellationModal } from "./RebookWagonCancellationModal"; import { canRebookWagonCancellations, @@ -73,7 +74,7 @@ export function WagonCancellationCreditCard({ {Number(r.wagonsCancelled)} wagon(s) · credit{" "} - {formatMoney(Number(r.creditAmount), r.feeCurrency, 2)} + {formatMoney(Number(r.creditAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))} {chip.label} @@ -83,7 +84,7 @@ export function WagonCancellationCreditCard({ Cancelled {formatDate(r.createdAt)} {r.fault ? ` · ${r.fault === "EDR" ? "EDR fault (no fee)" : "customer fault"}` : ""} {Number(r.feeAmount) > 0 - ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, 2)}${ + ? ` · fee ${formatMoney(Number(r.feeAmount), r.feeCurrency, currencyDecimals(r.feeCurrency))}${ r.feePaidAt ? " paid" : " unpaid" }` : ""} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx index 047d1361f..fb47ace60 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceChargesTab.tsx @@ -39,7 +39,7 @@ import { import { formatDateTime } from "@/lib/format"; import { extractErrorMessage } from "@/utils/errorExtractor"; -const CURRENCIES = ["ETB", "USD"]; +const CURRENCIES = ["ETB", "USD", "DJF"]; const STATUS_META: Record< Freight.ClearanceChargeStatus, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index be70ffe4d..d1508d475 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -1,9 +1,19 @@ import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; +import { + Button, + Group, + Modal, + NumberInput, + Stack, + Text, + Textarea, +} from "@mantine/core"; import { Ban, + CalendarClock, + CalendarPlus, Check, Eye, // FilePen, // ponytail: back with the "Edit contract articles" button @@ -84,6 +94,9 @@ export function ContractActionsToolbar({ const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); // Cancel is its own key — it is terminal, so it is NOT implied by suspend. const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel); + // Revive an EXPIRED contract by adding validity days — only after the + // customer asked for it from the portal (the API enforces the same). + const mayExtend = hasPermission(user, FREIGHT_PERMS.contracts.extend); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); @@ -98,6 +111,9 @@ export function ContractActionsToolbar({ const [resumeNote, setResumeNote] = useState(""); const [cancelOpen, setCancelOpen] = useState(false); const [cancelReason, setCancelReason] = useState(""); + const [extendOpen, setExtendOpen] = useState(false); + const [extendDays, setExtendDays] = useState(30); + const [extendNote, setExtendNote] = useState(""); // Shared by the suspended branch and the normal toolbar — both can cancel. const cancelModal = ( @@ -183,7 +199,132 @@ export function ContractActionsToolbar({ [validitySetting], ); - if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) { + // Lapsed: nothing to do until the customer asks for more time from the + // portal. Once they have, staff add days and the contract returns to the + // status it held before it expired. + if (status === "EXPIRED") { + const requestedAt = contract.extensionRequestedAt + ? new Date(contract.extensionRequestedAt) + : null; + const currentEnd = contract.contractValidUntil + ? new Date(contract.contractValidUntil) + : null; + // Mirrors ContractTransitionService.extend: days count from today once the + // contract has lapsed, from the current end date otherwise. + const base = + currentEnd && currentEnd.getTime() > Date.now() ? currentEnd : new Date(); + const newEnd = new Date(base); + newEnd.setDate(newEnd.getDate() + Math.max(0, Math.floor(extendDays || 0))); + const restoredStatus = + contract.statusBeforeExpiry ?? + (contract.contractKind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED"); + const daysValid = Number.isInteger(extendDays) && extendDays >= 1; + + return ( + + + + This contract's validity ended + {currentEnd ? ` on ${currentEnd.toLocaleDateString()}` : ""}. New + bookings are blocked until it is extended. + + {requestedAt ? ( + <> + + Extension requested by the customer on{" "} + {requestedAt.toLocaleDateString()}. + + {contract.latestExtensionRequestNote && ( + + Reason: {contract.latestExtensionRequestNote} + + )} + {mayExtend ? ( + + ) : ( + + You do not have permission to extend a contract. + + )} + + ) : ( + + The customer has not requested an extension. A contract can only + be extended once they ask for it from the portal. + + )} + + + setExtendOpen(false)} + title="Extend this contract?" + centered + > + + + Contract {contract.reference} gets the days below added to + its validity, returns to {restoredStatus}, and the customer + is notified. Bookings under it are possible again immediately. + + setExtendDays(typeof v === "number" ? v : Number(v) || 0)} + /> + + New validity end:{" "} + {daysValid ? newEnd.toLocaleDateString() : "—"} + +