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-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index 56d23ce79..97a211037 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 @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { RatesService } from '../modules/rule-engine/services/rates.service'; -import { Rate } from '../modules/rule-engine/entities/rate.entity'; +import { Rate, isContainerHazardRate } from '../modules/rule-engine/entities/rate.entity'; import { ContractDirection, ContractFreight, @@ -109,12 +109,21 @@ export class ContractRateScheduleBuilder { // Fuel is sold per lane + commodity — only lanes matching the contract's // direction belong on its schedule, labeled with their leg. if (rate.trigger === 'FUEL') { - if (this.fuelDirectionMatches(rate, direction)) { + if (this.laneDirectionMatches(rate, direction)) { surcharges.push(this.fuelRow(rate)); } continue; } + // The container hazard surcharge is sold per lane (+ box size) — only a + // container contract on a matching direction shows it, with its leg. + if (isContainerHazardRate(rate.trigger, rate.rateUnit)) { + if (freight === 'CON' && this.laneDirectionMatches(rate, direction)) { + surcharges.push(this.lanedSurchargeRow(rate)); + } + continue; + } + // Everything left is a trigger-based charge (surcharge / demurrage / customs). surcharges.push(this.surchargeRow(rate)); } @@ -203,12 +212,28 @@ export class ContractRateScheduleBuilder { }; } - private fuelDirectionMatches(rate: Rate, direction: ContractDirection): boolean { + /** Lane-sold surcharges (fuel, container hazard) match on the contract's direction. */ + private laneDirectionMatches(rate: Rate, direction: ContractDirection): boolean { const want = direction === 'IMP' ? 'IMPORT' : direction === 'EXP' ? 'EXPORT' : 'DOMESTIC'; return rate.tradeDirection === want; } + /** A lane-sold surcharge row — the leg rides along in the charge label. */ + private lanedSurchargeRow(rate: Rate): RateScheduleRow { + const origin = rate.originYard?.label ?? rate.originYard?.code ?? '—'; + const destination = + rate.destinationYard?.label ?? rate.destinationYard?.code ?? '—'; + const label = TRIGGER_ROUTE_LABELS[rate.trigger] ?? this.titleCase(rate.trigger); + return { + route: `${label} (${origin} → ${destination})`, + cargo: this.cargoLabel(rate), + currency: rate.currency, + amount: this.formatAmount(rate.rateValue), + unit: this.unitLabel(rate.rateUnit), + }; + } + /** * Fuel row — the lane matters, so it rides along in the charge label. * Per-liter collapses to one flat total (base liters × rate value); the 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-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/3890000000000-AddDjfPaymentsEnabled.ts b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts new file mode 100644 index 000000000..c8eb09978 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3890000000000-AddDjfPaymentsEnabled.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the currency-level DJF switch to `manual_payment_settings`. + * + * Distinct from `djf_enabled`, which governs only the MANUAL rail: this one + * says whether DJF may be used as a payment currency at all — offered on the + * booking forms and accepted for online payment. Defaults to `true`, the + * behaviour before the switch existed. + */ +export class AddDjfPaymentsEnabled3890000000000 implements MigrationInterface { + name = 'AddDjfPaymentsEnabled3890000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.manual_payment_settings + ADD COLUMN IF NOT EXISTS djf_payments_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_payments_enabled; + `); + } +} 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/migrations/3930000000000-CompanyTransitAgentLink.ts b/apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts new file mode 100644 index 000000000..104382e10 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3930000000000-CompanyTransitAgentLink.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Links a freight-forwarder company to the transit agent it is registered as. + * + * An Ethiopian transit agent and a freight forwarder are the same business seen + * from two sides: the roster GL uses to assign an officer, and the customer + * that signs contracts on other companies' behalf. Until now nothing tied the + * two rows together, so a forwarder could onboard under any name and staff had + * no way to tell which roster entry it was. + * + * Two changes: + * - `transit_agents.country` — the roster was Djibouti-only, so every existing + * row defaults to `DJ`. Only `ET` agents are offered to a forwarder onboarding. + * - `companies.transit_agent_id` — nullable: importers and exporters have no + * agent, and pre-existing forwarders stay unlinked until they are edited. + */ +export class CompanyTransitAgentLink3930000000000 implements MigrationInterface { + name = 'CompanyTransitAgentLink3930000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.transit_agents + ADD COLUMN IF NOT EXISTS country varchar(2) NOT NULL DEFAULT 'DJ' + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_transit_agents_country" + ON freight.transit_agents (country) + `); + + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS transit_agent_id uuid + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_companies_transit_agent_id" + ON freight.companies (transit_agent_id) + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_companies_transit_agent' + ) THEN + ALTER TABLE freight.companies + ADD CONSTRAINT "FK_companies_transit_agent" + FOREIGN KEY (transit_agent_id) + REFERENCES freight.transit_agents (id) + ON DELETE SET NULL; + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP CONSTRAINT IF EXISTS "FK_companies_transit_agent" + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_companies_transit_agent_id" + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS transit_agent_id + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_transit_agents_country" + `); + await queryRunner.query(` + ALTER TABLE freight.transit_agents + DROP COLUMN IF EXISTS country + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts b/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts new file mode 100644 index 000000000..371bdf527 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3940000000000-DropTransitAgentValidity.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drops the transit-agent validity window. + * + * `valid_from` / `valid_to` gated which officers GL could assign, on top of the + * `is_active` switch. In practice the dates were never maintained — an agent + * whose window lapsed was simply suspended — and with Ethiopian agents now + * doubling as the roster a freight forwarder registers itself against, a + * date range that silently hides a live business is worse than no range. + * `is_active` is the single switch from here on. + * + * `down()` re-adds the columns as nullable: the dates themselves are gone. + */ +export class DropTransitAgentValidity3940000000000 implements MigrationInterface { + name = 'DropTransitAgentValidity3940000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.transit_agents + DROP COLUMN IF EXISTS valid_from, + DROP COLUMN IF EXISTS valid_to + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.transit_agents + ADD COLUMN IF NOT EXISTS valid_from date, + ADD COLUMN IF NOT EXISTS valid_to date + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts b/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts new file mode 100644 index 000000000..53d265d9f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3950000000000-TransitAgentProfileSequence.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Reference sequence for the new `transit_agent` operational profile. + * + * `company_profiles.type` is a plain varchar, so the role itself needs no DDL; + * what it needs is its own reference series (`TA-A00001`, …), minted on + * approval exactly like IM/EX/FF. Mirrors the baseline's sequences. + */ +export class TransitAgentProfileSequence3950000000000 implements MigrationInterface { + name = "TransitAgentProfileSequence3950000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ta + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ta`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts b/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts new file mode 100644 index 000000000..ddfa43615 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3960000000000-ContainerHazardRateScope.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The container hazardous-cargo surcharge (HAZARDOUS billed PER_CONTAINER) is + * now sold per trade direction + origin → destination lane, optionally per + * container type (20ft / 40ft) — the same shape as the empty-return service. + * The per-ton (bulk) hazard rate keeps its global, unscoped shape. + * + * - CK_rates_yard_scope gains the per-container hazard rate in its + * yard-carrying branch. Drop-and-recreate is the established shape for this + * constraint — see 3890000000000-EmptyContainerRateScope. + * - Existing lane-less per-container hazard rows cannot satisfy the new + * branch and no longer match the way the engine prices container hazard + * (per lane + size), so they are SUPERSEDED — kept for the audit trail, out + * of the unique pattern index and out of pricing. The rates team re-enters + * the surcharge per lane; until then a hazardous container booking on that + * lane hard-blocks rather than shipping the service for free. + */ +export class ContainerHazardRateScope3960000000000 implements MigrationInterface { + name = "ContainerHazardRateScope3960000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED' + WHERE trigger = 'HAZARDOUS' + AND rate_unit = 'PER_CONTAINER' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL) + AND deleted_at IS NULL + AND status <> 'SUPERSEDED' + `); + + 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') + OR (trigger = 'HAZARDOUS' AND rate_unit = 'PER_CONTAINER') + 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 { + // Lane-scoped per-container hazard rows have no place under the old + // constraint (surcharges carried no yards) — retire them the same way. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED' + WHERE trigger = 'HAZARDOUS' + AND rate_unit = 'PER_CONTAINER' + AND (origin_yard_id IS NOT NULL OR destination_yard_id IS NOT NULL) + AND deleted_at IS NULL + AND status <> 'SUPERSEDED' + `); + + 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 + ) + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 9a34516aa..647d9717a 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -82,7 +82,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -167,7 +167,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} as never, { get: () => undefined } as never, - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -304,7 +304,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -362,7 +362,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -410,7 +410,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -526,7 +526,7 @@ describe("BillingService.recordPayment", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -646,7 +646,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -722,7 +722,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -816,7 +816,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -902,7 +902,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); @@ -978,7 +978,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config - { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"], isCurrencyOffered: async () => true } as never, // manualPaymentSettings { directSend: jest.fn() } as never, // notifications { notify: jest.fn() } as never, // inbox ); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index ce8c95f40..42a5baf34 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -806,7 +806,7 @@ export class BillingService { // settle by hand in a currency whose channel is switched off. if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { throw new BadRequestException( - `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, + `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Payments first.`, ); } if (!file) { @@ -2297,6 +2297,15 @@ export class BillingService { ); } + // DJF switched off as a payment currency: the online rails (Waafi / CAC + // Bank) stop taking it. The invoice itself is untouched — Finance can + // still settle it by hand while the DJF manual channel is on. + if (!(await this.manualPaymentSettings.isCurrencyOffered(invoice.currency))) { + throw new BadRequestException( + `${invoice.currency} payments are switched off. Enable them in Configuration → Payments first.`, + ); + } + // A booking's PREPAID invoice is only payable inside its pay window — // `dueAt` mirrors booking.paymentDeadline (issuePayable at reserve time). // Blocking INITIATION here is what makes the deadline real: a payment diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index ad0e62ef3..7c210ceb9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -298,6 +298,42 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Operation request needs changes', msg); } + /** + * Operations moved the shipment day (and possibly the train) themselves + * instead of asking the customer to. The booking stays under review, so the + * customer only needs to know the new day — nothing to resubmit. + */ + operationRescheduled(b: Booking, previousDay: string | null, note?: string): void { + const newDay = b.scheduledDate + ? b.scheduledDate.toLocaleDateString('en-GB', { timeZone: 'Africa/Addis_Ababa' }) + : 'a new day'; + const msg = + `Operations moved the shipment day of booking ${b.reference} ` + + `${previousDay ? `from ${previousDay} ` : ''}to ${newDay}.` + + (note ? ` Note from Operations: ${note}` : '') + + ' The request stays under review — no action is needed on your side.'; + if (b.customsClearingEnabled) { + this.logger.log(`OPERATION RESCHEDULED (to GL) — ${this.ref(b)}`); + void this.inbox.notify({ + recipients: + b.createdByRole === 'GL_ET' && b.createdByUserId + ? { userIds: [b.createdByUserId] } + : CLEARANCE_DESK, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: `Booking ${b.reference} shipment day changed`, + body: msg, + link: b.contractId + ? `/dashboard/contracts/clearance/${b.contractId}` + : `/dashboard/bookings/${b.id}/clearance`, + data: { bookingId: b.id, reference: b.reference, note: note ?? null }, + }); + return; + } + void this.notifyContact(b, msg, 'OPERATION RESCHEDULED'); + this.inApp(b, 'Shipment day changed by Operations', msg); + } + /** Operation accepted → invoice ready; await payment / booking window. */ operationAccepted(b: Booking): void { // No invoice and no pay window for a shipping line — the charge sits on @@ -620,4 +656,67 @@ export class BookingLifecycleNotifierService { }, ); } + + /** + * The customer picked a registered transit agent (a freight forwarder on the + * platform) to clear this booking. Tell the forwarder company — every one of + * its portal users in the bell, plus SMS and email to its contact — so the + * job shows up in its Assigned Bookings tab and it can start preparing + * documents. The company is found through its transit-agent link; an agent + * nobody has registered against gets no message, since there is nobody to + * send it to. Never throws: a failed notice must not undo a completed booking. + */ + async transitAgentAssigned( + b: Booking, + agent: { id: string; name: string }, + ): Promise { + try { + const [forwarder]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.companies + WHERE transit_agent_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [agent.id], + ); + if (!forwarder) { + this.logger.warn( + `Transit agent ${agent.name} has no forwarder company — assignment notice for ${this.ref(b)} not sent`, + ); + return; + } + const title = 'New booking assigned to you'; + const body = `Booking ${b.reference} has been assigned to ${agent.name} for customs clearance. Open Assigned Bookings in the portal to see it.`; + void this.inbox.notify({ + recipients: { companyId: forwarder.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title, + body, + link: '/forwarder/assigned-bookings', + data: { bookingId: b.id, reference: b.reference, transitAgentId: agent.id }, + }); + const { phone, email } = await resolveCompanyNotifyContact( + this.dataSource, + forwarder.id, + ); + const message = `EDR Freight: ${body}`; + if (phone) { + try { + await this.notifications.directSend('sms', phone, message); + } catch (err) { + this.logger.warn(`Forwarder SMS failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + if (email) { + try { + await this.notifications.directSend('email', email, message); + } catch (err) { + this.logger.warn(`Forwarder email failed for ${this.ref(b)}: ${(err as Error).message}`); + } + } + } catch (err) { + this.logger.warn( + `transitAgentAssigned failed for ${this.ref(b)}: ${(err as Error).message}`, + ); + } + } } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index b55c8d4b0..8cfec366d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -217,3 +217,170 @@ describe('BookingTransitionService — requestOperation export space gate', () = ); }); }); + +/** + * Staff reschedule of an operation request: instead of returning the booking + * to the customer, Operations sets the new shipment day (and the export train) + * themselves. Same day-pool / export gates as the customer request; the booking + * lands (back) at OPERATION_REQUEST_PENDING and the customer is told. + */ +describe('BookingTransitionService — staff reschedule of an operation request', () => { + function makeService(over: { + status?: string; + tradeDirection?: 'EXPORT' | 'IMPORT'; + hasDeparture?: boolean; + } = {}) { + const booking = { + id: 'b-1', + reference: 'BKG-1', + status: over.status ?? 'OPERATION_REQUEST_PENDING', + tradeDirection: over.tradeDirection ?? 'IMPORT', + originYardId: 'o-1', + destinationYardId: 'd-1', + totalAmount: 1000, + contractId: null, + scheduledDate: new Date('2026-07-01T00:00:00.000Z'), + requestedTrainScheduleId: null, + serviceType: { code: 'RAIL_CONTAINER' }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + checkDayCompatibilityForBooking: jest.fn().mockResolvedValue({ + hasDeparture: over.hasDeparture ?? true, + hasCompatible: true, + }), + }; + const bookingBatchService = { + pickExportSchedule: jest.fn().mockResolvedValue('sched-1'), + }; + const notifier = { operationRescheduled: jest.fn() }; + const clearanceEvents = { record: jest.fn() }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + { isPhasedCustomsBooking: () => false } as never, + {} as never, // workflowService + {} as never, // invoiceService + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + notifier as never, + clearanceEvents as never, + { emit: jest.fn() } as never, // events + ); + return { service, bookingsRepository, bookingBatchService, notifier, clearanceEvents }; + } + + it('refuses a booking that has not requested operation', async () => { + const { service, bookingsRepository } = makeService({ status: 'CLEARANCE_READY' }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toBeInstanceOf(ConflictException); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('refuses a day with no departure on the route and changes nothing', async () => { + const { service, bookingsRepository } = makeService({ hasDeparture: false }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toBeInstanceOf(BadRequestException); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + }); + + it('moves an import request to the new day, keeps it pending, logs it and tells the customer', async () => { + const { service, bookingsRepository, notifier, clearanceEvents } = makeService(); + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + 'sched-9', // ignored for import — the batch engine assigns the train + 'staff-1', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', { + status: 'OPERATION_REQUEST_PENDING', + scheduledDate: new Date('2026-07-20T00:00:00.000Z'), + requestedTrainScheduleId: null, + }); + expect(bookingsRepository.createReviewNote).not.toHaveBeenCalled(); + expect(clearanceEvents.record).toHaveBeenCalledWith( + expect.objectContaining({ + bookingId: 'b-1', + action: 'OPERATION_RESCHEDULED', + actorType: 'STAFF', + actorId: 'staff-1', + metadata: expect.objectContaining({ + previousScheduledDate: '2026-07-01', + scheduledDate: '2026-07-20', + }), + }), + ); + expect(notifier.operationRescheduled).toHaveBeenCalledWith( + expect.objectContaining({ id: 'b-1' }), + '2026-07-01', + undefined, + ); + }); + + it('resolves a change request staff had raised: back to pending, note kept as a staff note', async () => { + const { service, bookingsRepository, notifier } = makeService({ + status: 'OPERATION_CHANGES_REQUESTED', + }); + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + null, + 'staff-1', + { note: ' Moved to the Monday train ' }, + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_REQUEST_PENDING' }), + ); + expect(bookingsRepository.createReviewNote).toHaveBeenCalledWith( + 'b-1', + 'Moved to the Monday train', + 'STAFF_NOTE', + 'staff-1', + ); + expect(notifier.operationRescheduled).toHaveBeenCalledWith( + expect.anything(), + '2026-07-01', + 'Moved to the Monday train', + ); + }); + + it('export rail: requires the train and persists the pick after the space gate', async () => { + const { service, bookingsRepository, bookingBatchService } = makeService({ + tradeDirection: 'EXPORT', + }); + await expect( + service.rescheduleOperationRequest('b-1', '2026-07-20T00:00:00.000Z', null, 'staff-1'), + ).rejects.toThrow(/select a train/i); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + + await service.rescheduleOperationRequest( + 'b-1', + '2026-07-20T00:00:00.000Z', + 'sched-9', + 'staff-1', + ); + expect(bookingBatchService.pickExportSchedule).toHaveBeenCalledWith( + expect.objectContaining({ requestedTrainScheduleId: 'sched-9' }), + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ + status: 'OPERATION_REQUEST_PENDING', + requestedTrainScheduleId: 'sched-9', + }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7324da975..69da49299 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1311,6 +1311,45 @@ export class BookingTransitionService { ); } + const { date, requestedId } = await this.resolveOperationDay( + booking, + scheduledDate, + requestedTrainScheduleId, + opts?.bypassDayPool, + ); + + await this.bookingsRepository.update(bookingId, { + status: "OPERATION_REQUEST_PENDING", + scheduledDate: date, + requestedTrainScheduleId: requestedId, + } as never); + await this.clearanceEvents.record({ + bookingId, + action: 'OPERATION_REQUESTED', + label: `Requested operation for shipment day ${scheduledDate}`, + actorType: 'CUSTOMER', + actorId: opts?.userId ?? null, + metadata: { scheduledDate }, + }); + const fresh = await this.bookingsService.findById(bookingId); + this.notifier.operationRequestedToStaff(fresh); + return fresh; + } + + /** + * Validate a shipment day (and, for export rail, the picked train) for a + * booking the way the customer's operation request does, and resolve what + * gets persisted: the binding `scheduledDate` and the `requestedTrainScheduleId` + * (export rail / shipping-line only — import and domestic trains are assigned + * by the batch engine, so their pick is dropped). Shared by the customer + * request and the staff reschedule so both enforce the same gates. + */ + private async resolveOperationDay( + booking: Booking, + scheduledDate: string, + requestedTrainScheduleId?: string | null, + bypassDayPool?: boolean, + ): Promise<{ date: Date; requestedId: string | null }> { const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException("A valid schedule date is required"); @@ -1322,7 +1361,7 @@ export class BookingTransitionService { // gate; quantity never blocks — oversized bookings get a partial split // offer). The batch engine assigns the specific train within that // (route, day) pool later. - if (!opts?.bypassDayPool) { + if (!bypassDayPool) { const { hasDeparture, hasCompatible } = await this.bookingsService.checkDayCompatibilityForBooking( booking, @@ -1359,7 +1398,7 @@ export class BookingTransitionService { // persisted here the same way an export pick is. Customer import/domestic // bookings still never carry one (the batch engine assigns their train). const requestedId = - isExportTrain || opts?.bypassDayPool + isExportTrain || bypassDayPool ? (requestedTrainScheduleId ?? null) : null; // Export rail rides the exact train the customer picked — never an @@ -1403,21 +1442,76 @@ export class BookingTransitionService { } } + return { date, requestedId }; + } + + /** + * Operations changes the shipment day and/or train of a booking the customer + * has already requested operation on — instead of bouncing it back to the + * customer with a change request, staff set the new day (and, for export + * rail, the train) themselves. The same day-pool / export-space gates as the + * customer's own request apply, so staff cannot park a booking on a day with + * no departure or a train with no room. + * + * Allowed at OPERATION_REQUEST_PENDING (staff review) and at + * OPERATION_CHANGES_REQUESTED (staff resolve their own change request); either + * way the booking lands back at OPERATION_REQUEST_PENDING for the normal + * accept. The customer is told the new day, with the staff note when given. + */ + async rescheduleOperationRequest( + bookingId: string, + scheduledDate: string, + requestedTrainScheduleId: string | null | undefined, + actorId: string, + options: { note?: string } = {}, + ): Promise { + const booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + ]); + + const { date, requestedId } = await this.resolveOperationDay( + booking, + scheduledDate, + requestedTrainScheduleId, + ); + const previousDay = booking.scheduledDate ? eatDay(booking.scheduledDate) : null; + const previousTrainId = booking.requestedTrainScheduleId ?? null; + const note = options.note?.trim() || undefined; + await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", scheduledDate: date, requestedTrainScheduleId: requestedId, } as never); + if (note) { + await this.bookingsRepository.createReviewNote( + bookingId, + note, + "STAFF_NOTE", + actorId, + ); + } await this.clearanceEvents.record({ bookingId, - action: 'OPERATION_REQUESTED', - label: `Requested operation for shipment day ${scheduledDate}`, - actorType: 'CUSTOMER', - actorId: opts?.userId ?? null, - metadata: { scheduledDate }, + action: "OPERATION_RESCHEDULED", + label: + `Operations moved the shipment day ` + + `${previousDay ? `from ${previousDay} ` : ""}to ${eatDay(date)}` + + (requestedId && requestedId !== previousTrainId ? " and changed the train" : ""), + actorType: "STAFF", + actorId, + metadata: { + previousScheduledDate: previousDay, + scheduledDate: eatDay(date), + previousTrainScheduleId: previousTrainId, + trainScheduleId: requestedId, + note: note ?? null, + }, }); const fresh = await this.bookingsService.findById(bookingId); - this.notifier.operationRequestedToStaff(fresh); + this.notifier.operationRescheduled(fresh, previousDay, note); return fresh; } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 66958bc38..85a98ef57 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -82,6 +82,7 @@ import { ReviewDocumentDto, RequestOperationDto, OperationReviewDto, + RescheduleOperationDto, StaffRejectDto, } from "./dto/request-changes.dto"; import { ContractViewDto } from "./dto/contract-view.dto"; @@ -1344,6 +1345,29 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(":id/operation/reschedule") + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: + "Operations changes a pending operation request's shipment day and/or " + + "train on the customer's behalf (OPERATION_REQUEST_PENDING | " + + "OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)", + }) + async rescheduleOperationRequest( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: RescheduleOperationDto, + @CurrentUser() user: AuthUserPayload, + ) { + const booking = await this.transitionService.rescheduleOperationRequest( + id, + dto.scheduledDate, + dto.trainScheduleId ?? null, + resolveAuthUserId(user), + { note: dto.note }, + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts index 9631def73..bf718bb70 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -107,6 +107,38 @@ export class RequestOperationDto { trainScheduleId?: string; } +/** + * Operations changes a pending operation request's shipment day and/or train + * on the customer's behalf (instead of returning it for changes). + */ +export class RescheduleOperationDto { + @ApiProperty({ + description: + 'The new shipment day (train departure day). ISO date — must have an ' + + 'open departure on the booking route that can carry the cargo.', + example: '2026-07-15', + }) + @IsDateString() + scheduledDate!: string; + + @ApiPropertyOptional({ + description: + 'EXPORT rail only: the train (schedule id) to ride, from ' + + 'GET /bookings/:id/export-trains for the new day. Required for export ' + + 'rail; ignored for import/domestic/road bookings.', + }) + @IsOptional() + @IsUUID() + trainScheduleId?: string; + + @ApiPropertyOptional({ + description: 'Optional note to the customer explaining the change.', + }) + @IsOptional() + @IsString() + note?: string; +} + export class OperationReviewDto { @ApiProperty({ description: diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 733b6e441..ad8383082 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -330,6 +330,7 @@ export class CompaniesController { dto.nationality, dto.cooperative, dto.investorLicence, + dto.transitAgentId, ); return new CompanyInfoResponseDto(profile, company); } @@ -363,6 +364,7 @@ export class CompaniesController { dto.type, dto.businessLicense, dto.licenceNumber, + dto.transitAgentId, ); return new ResponseCompanyProfileDto(profile); } diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index da2497d90..397e4fd66 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -80,6 +80,9 @@ function makeService(overrides: Partial = {}) { const company = () => ({ id: "company-1", + // Already named its roster entry: taking the forwarder role needs one, + // and these tests are about the PoA gate, not the transit-agent link. + transitAgentId: "ta-et", status: ctx.status, nationality: ctx.nationality, attributes: ctx.attributes, @@ -175,6 +178,14 @@ function makeService(overrides: Partial = {}) { deps.companyNotifier as never, {} as never, deps.verifayda as never, + { + findById: jest.fn(async () => ({ + id: "ta-et", + name: "Abyssinia Transit", + isActive: true, + country: "ET", + })), + } as never, // transitAgentsRepo ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts index 9de99186f..a9042df46 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.investor-licence.spec.ts @@ -66,6 +66,7 @@ function makeService(company: Record | null) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts index 5a73b6040..416d70bc5 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.license-supersede.spec.ts @@ -101,6 +101,7 @@ function makeService( { changeRequestSubmitted: jest.fn() } as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts index b801a793f..38440747c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.poa-delegation.spec.ts @@ -46,6 +46,9 @@ function makeService(overrides: Partial = {}) { const company = () => ({ id: "company-1", + // Already named its roster entry: taking the forwarder role needs one, + // and these tests are about the PoA gate, not the transit-agent link. + transitAgentId: "ta-et", status: ctx.status, attributes: ctx.attributes, companyProfiles: ctx.profileTypes.map((type, i) => ({ @@ -146,6 +149,14 @@ function makeService(overrides: Partial = {}) { deps.companyNotifier as never, {} as never, {} as never, + { + findById: jest.fn(async () => ({ + id: "ta-et", + name: "Abyssinia Transit", + isActive: true, + country: "ET", + })), + } as never, // transitAgentsRepo ); // getCompanyInfoByUserId does its own lookups; the stubs above are enough for diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts index 79fbdd479..929607f5d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-approval.spec.ts @@ -75,6 +75,7 @@ function makeService(status: ProfileStatus) { companyNotifier as never, dataSource as never, {} as never, + {} as never, ); return { service, profile, written, companyProfilesRepo }; diff --git a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts index c04ce40f9..59fc0fce3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.profile-etrade-business.spec.ts @@ -70,6 +70,7 @@ function makeService(attributes: Record = {}) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts index ea81e4dbe..4065173cc 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.role-deselect.spec.ts @@ -54,6 +54,7 @@ function makeService(existing: ExistingProfile[]) { {} as never, {} as never, {} as never, + {} as never, ); jest diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 1080ceb12..ca7d4c12b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -31,6 +31,31 @@ import { POA_DELEGATION_PENDING_CODE, } from "../file-upload-settings/poa-delegation.constants"; import { VerifaydaService } from "../verifayda/verifayda.service"; +import { + TransitAgent, + TransitAgentCountry, +} from "../transit-agents/entities/transit-agent.entity"; +import { TransitAgentsRepository } from "../transit-agents/transit-agents.repository"; + +/** + * The roles that ARE an Ethiopian transit-agent roster entry. Both name the + * company's `transitAgentId`; the difference is what else the company does — + * a forwarder also trades on other companies' behalf, a plain transit agent + * only clears customs for bookings customers assign to it. + */ +function isAgentRole(type: ProfileType): boolean { + // A function rather than a module-level array: the entity module is still + // initialising when this file loads (company-profile → company → …), so + // reading the enum at load time throws. + return ( + type === ProfileType.freightForwarder || type === ProfileType.transitAgent + ); +} + +/** True when the company does nothing but act as a transit agent. */ +function isTransitAgentOnly(types: ProfileType[]): boolean { + return types.length > 0 && types.every((t) => t === ProfileType.transitAgent); +} import { buildCompanyIdentityState, CompanyIdentityStateDto, @@ -204,6 +229,7 @@ export class CompaniesService { private readonly companyNotifier: CompanyNotifierService, private readonly dataSource: DataSource, private readonly verifaydaService: VerifaydaService, + private readonly transitAgentsRepo: TransitAgentsRepository, ) { } /** @@ -387,21 +413,21 @@ export class CompaniesService { nationality?: CompanyNationality, cooperative?: boolean, investorLicence?: boolean, + transitAgentId?: string, ): Promise<{ profile: ExternalProfile; company: Company }> { + const needsAgent = roles.some(isAgentRole); + // A company that is ONLY a transit agent has nothing else to tell us: its + // registration IS the roster entry it picked, so onboarding ends here. + const transitAgentOnly = isTransitAgentOnly(roles); // Already started — reuse the existing draft, just ensure roles exist and // keep the nationality up to date if it was (re)selected. const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; - // Only load the row when the answer actually depends on it: to merge the - // flag into `attributes`, or to read a stored one the caller didn't send. - const needsCompany = - cooperative !== undefined || - investorLicence !== undefined || - roles.includes(ProfileType.freightForwarder); - const current = needsCompany - ? await this.companiesRepo.findById(companyId) - : null; + // The stored row decides more than one answer here: the flags the caller + // didn't send, the transit agent a forwarder already linked, and whether + // dropping the forwarder role has a link to clear. + const current = await this.companiesRepo.findById(companyId); const isCoop = cooperative ?? isCooperative(current); const isInvestor = investorLicence ?? hasInvestorLicence(current); this.assertRolesAllowedForCooperative(isCoop, roles); @@ -411,8 +437,38 @@ export class CompaniesService { isCoop, nationality ?? current?.nationality ?? undefined, ); + // A re-run that keeps an agent role may omit the agent it already + // picked; one that drops both agent roles drops the link with it, so a + // company that later re-adds one is asked again rather than inheriting a + // stale answer. + const linkedAgent = needsAgent + ? await this.resolveLinkedTransitAgent( + transitAgentId ?? current?.transitAgentId ?? undefined, + ) + : null; + const before = await this.companyProfilesRepo.findByCompanyId(companyId); + const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type)); await this.syncCompanyProfiles(companyId, companyType, roles); const updates: Partial = {}; + if ((linkedAgent?.id ?? null) !== (current?.transitAgentId ?? null)) { + updates.transitAgentId = linkedAgent?.id ?? null; + } + const profilePatch: Partial = {}; + if (transitAgentOnly && linkedAgent) { + // The company is the agent — it gets the roster's name, and there is + // no company/owner/documents step left to take. + updates.name = linkedAgent.name; + if (!existing.onboardingCompleted) { + profilePatch.onboardingCompleted = true; + profilePatch.onboardingStep = "transit-agent"; + } + } else if (existing.onboardingCompleted && wasTransitAgentOnly) { + // Adding a licensed role to a transit-agent-only company reopens the + // wizard at the company step: importing or forwarding needs the TIN, + // owner, contact and documents the transit agent never had to give. + profilePatch.onboardingCompleted = false; + profilePatch.onboardingStep = "company"; + } if (nationality) updates.nationality = nationality; // Ticking the box on a draft that was saved as foreign has to correct the // stored nationality too, or the company keeps resolving to the foreign @@ -446,10 +502,9 @@ export class CompaniesService { if (Object.keys(updates).length > 0) { await this.companiesRepo.update(companyId, updates); } - if (backToEtrade) { - await this.profilesRepo.update(existing.id, { - onboardingStep: "company", - }); + if (backToEtrade) profilePatch.onboardingStep = "company"; + if (Object.keys(profilePatch).length > 0) { + await this.profilesRepo.update(existing.id, profilePatch); } return this.getCompanyInfoByUserId(identity.userId); } @@ -463,16 +518,23 @@ export class CompaniesService { ); const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); + const linkedAgent = needsAgent + ? await this.resolveLinkedTransitAgent(transitAgentId) + : null; const company = await this.companiesRepo.create({ - name: identity.firstName - ? `${identity.firstName}'s company` - : "New company", + name: + transitAgentOnly && linkedAgent + ? linkedAgent.name + : identity.firstName + ? `${identity.firstName}'s company` + : "New company", type: companyType, tin: await this.generateDraftTin(), country: "Ethiopia", nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, + transitAgentId: linkedAgent?.id ?? null, ...(cooperative || investorLicence ? { attributes: { @@ -489,8 +551,10 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - onboardingStep: "company", - onboardingCompleted: false, + // A transit-agent-only company is done the moment it picks its roster + // entry — see `transitAgentOnly` above. + onboardingStep: transitAgentOnly ? "transit-agent" : "company", + onboardingCompleted: transitAgentOnly, }); await this.syncCompanyProfiles(company.id, companyType, chosenTypes); @@ -498,6 +562,75 @@ export class CompaniesService { return this.getCompanyInfoByUserId(identity.userId); } + /** + * The transit agent a freight forwarder registers itself as. + * + * A forwarder and an Ethiopian transit agent are the same business, so the + * role cannot be taken without naming which roster entry it is: a company + * that is not on the roster asks support to be added first, which is what + * the portal's "didn't find my company" note says. Foreign and suspended + * entries are refused for the same reason a missing one is — none of them + * is a forwarder EDR will assign work to. + */ + private async resolveLinkedTransitAgent( + transitAgentId: string | undefined, + ): Promise { + if (!transitAgentId) { + throw new BadRequestException( + "Select your company from the transit agent list to register as a transit agent or freight forwarder. If it is not listed, contact support to be added.", + ); + } + const agent = await this.transitAgentsRepo.findById(transitAgentId); + if ( + !agent || + !agent.isActive || + agent.country !== TransitAgentCountry.Ethiopia + ) { + throw new BadRequestException( + "The selected transit agent is not an active Ethiopian transit agent. Pick another one or contact support.", + ); + } + return agent; + } + + /** + * Make `agent` the company's transit agent if it is not already. Shared by + * every add-role path: the link is per company, so a forwarder that already + * picked its roster entry is not asked again when it adds the transit agent + * role, and vice versa. + */ + private async linkTransitAgent( + company: Company, + transitAgentId: string | undefined, + ): Promise { + const agent = await this.resolveLinkedTransitAgent( + transitAgentId ?? company.transitAgentId ?? undefined, + ); + if (company.transitAgentId === agent.id) return; + await this.companiesRepo.update(company.id, { transitAgentId: agent.id }); + company.transitAgentId = agent.id; + } + + /** + * A transit-agent-only company that takes on a licensed role has to go + * back through the wizard: importing, exporting or forwarding needs the TIN, + * owner, contact and documents the transit agent never had to give. The + * portal reopens the wizard at the company step the moment this flips. + */ + private async reopenOnboardingForLicensedRole( + profile: ExternalProfile, + profilesBefore: CompanyProfile[], + addedTypes: ProfileType[], + ): Promise { + if (!profile.onboardingCompleted) return; + if (!isTransitAgentOnly(profilesBefore.map((p) => p.type))) return; + if (!addedTypes.some((t) => t !== ProfileType.transitAgent)) return; + await this.profilesRepo.update(profile.id, { + onboardingCompleted: false, + onboardingStep: "company", + }); + } + /** * A co-operative union or farm cannot hold the freight-forwarder role. * @@ -517,6 +650,11 @@ export class CompaniesService { "A co-operative union or farm cannot register as a freight forwarder — that role requires a business licence.", ); } + if (roles.includes(ProfileType.transitAgent)) { + throw new BadRequestException( + "A co-operative union or farm cannot register as a transit agent — that is licensed customs work.", + ); + } } /** @@ -675,6 +813,7 @@ export class CompaniesService { // External profiles carry the onboarding flag the backoffice gates // approval decisions on (see ResponseCompanyDto.onboardingCompleted). company.profiles = await this.profilesRepo.findByCompanyId(id); + await this.attachTransitAgent(company); return company; } @@ -715,10 +854,22 @@ export class CompaniesService { company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( company.id, ); + await this.attachTransitAgent(company); return { profile, company }; } + /** + * Hang the linked transit agent off the company so responses can name it. + * A separate read rather than a relation join: `findById` on the repository + * loads no relations, and every other caller of it has no use for the agent. + */ + private async attachTransitAgent(company: Company): Promise { + company.transitAgent = company.transitAgentId + ? await this.transitAgentsRepo.findById(company.transitAgentId) + : null; + } + /** * Dashboard KPIs for the portal home (MyPortalPage), aggregated from the * current user's company bookings. All figures are scoped to that company. @@ -1799,6 +1950,7 @@ export class CompaniesService { ProfileType.importer, ProfileType.exporter, ProfileType.freightForwarder, + ProfileType.transitAgent, ]; case "freight_forwarder": return [ProfileType.freightForwarder]; @@ -2154,7 +2306,11 @@ export class CompaniesService { */ async addCompanyProfilesForUser( userId: string, - inputs: Array<{ type: ProfileType; licenceNumber?: string }>, + inputs: Array<{ + type: ProfileType; + licenceNumber?: string; + transitAgentId?: string; + }>, ): Promise { const types = inputs.map((i) => i.type); const profile = await this.profilesRepo.findByUserId(userId); @@ -2164,6 +2320,8 @@ export class CompaniesService { const companyId = profile.company?.id ?? profile.companyId; const company = await this.findCompanyById(companyId); const allowedTypes = this.getProfileTypeForCompanyType(company.type); + const before = company.companyProfiles ?? []; + const wasTransitAgentOnly = isTransitAgentOnly(before.map((p) => p.type)); for (const type of types) { if (!allowedTypes.includes(type)) { @@ -2191,14 +2349,31 @@ export class CompaniesService { ); } + // A transit agent or forwarder IS a roster entry — name it, once per + // company, before the role exists. + if (isAgentRole(type)) { + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); + await this.linkTransitAgent( + company, + inputs.find((i) => i.type === type)?.transitAgentId, + ); + } + // Which eTrade business this role operates as. Resolved (and rejected if // absent) BEFORE the row is created, so a role never lands unattached on - // a company that has licences to pick from. - const etradeBusiness = await this.resolveProfileBusiness( - company, - inputs.find((i) => i.type === type)?.licenceNumber, - type, - ); + // a company that has licences to pick from. A transit agent has none — + // and a transit-agent-only company has no eTrade record to pick from + // yet: its first licensed role attaches the business on the wizard's + // licence step, once the TIN has been looked up, exactly like a role + // picked at onboarding. + const etradeBusiness = + type === ProfileType.transitAgent || wasTransitAgentOnly + ? null + : await this.resolveProfileBusiness( + company, + inputs.find((i) => i.type === type)?.licenceNumber, + type, + ); // Self-service role adds start Pending and carry no reference — a reference // is minted only when a backoffice reviewer approves the role. @@ -2210,6 +2385,7 @@ export class CompaniesService { }); } + await this.reopenOnboardingForLicensedRole(profile, before, types); return this.companyProfilesRepo.findByCompanyId(companyId); } @@ -2224,6 +2400,7 @@ export class CompaniesService { type: ProfileType, businessLicense?: string, licenceNumber?: string, + transitAgentId?: string, ): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -2248,12 +2425,18 @@ export class CompaniesService { await this.effectivePoaAttributes(company), ); } + if (!created && isAgentRole(type)) { + this.assertRolesAllowedForCooperative(isCooperative(company), [type]); + await this.linkTransitAgent(company, transitAgentId); + } if (!created) { - const etradeBusiness = await this.resolveProfileBusiness( - company, - licenceNumber, - type, - ); + // See addCompanyProfilesForUser: no business for a transit agent, nor + // for a transit-agent-only company's first licensed role. + const etradeBusiness = + type === ProfileType.transitAgent || + isTransitAgentOnly((company.companyProfiles ?? []).map((p) => p.type)) + ? null + : await this.resolveProfileBusiness(company, licenceNumber, type); // New self-service roles start Pending (awaiting backoffice approval) and // carry no reference until approved. created = await this.companyProfilesRepo.create({ @@ -2263,6 +2446,11 @@ export class CompaniesService { etradeBusiness, status: ProfileStatus.Pending, }); + await this.reopenOnboardingForLicensedRole( + profile, + company.companyProfiles ?? [], + [type], + ); } return created; @@ -2331,9 +2519,13 @@ export class CompaniesService { })); const missingDocs = documents.filter((d) => d.isRequired && !d.uploaded); - // 3. Per-operational-profile business licenses (FileRecord-backed). + // 3. Per-operational-profile business licenses (FileRecord-backed). A + // transit agent holds none here — its roster entry is its registration — + // so it owes neither a licence nor an eTrade business. const licenseProfiles = await Promise.all( - (company.companyProfiles ?? []).map(async (p) => { + (company.companyProfiles ?? []) + .filter((p) => p.type !== ProfileType.transitAgent) + .map(async (p) => { const records = await this.filesService.findByResource( p.id, LICENSE_RESOURCE, @@ -3775,7 +3967,11 @@ export class CompaniesService { companyId: string, tradeDirection: string, ): Promise { - const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + // A transit agent profile never carries a booking — it is the roster + // side of the company, not a trade role. + const profiles = ( + await this.companyProfilesRepo.findByCompanyId(companyId) + ).filter((p) => p.type !== ProfileType.transitAgent); if (profiles.length === 0) return null; const naturalType = diff --git a/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts new file mode 100644 index 000000000..3ff645d17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/companies.transit-agent-link.spec.ts @@ -0,0 +1,343 @@ +import { BadRequestException } from "@nestjs/common"; + +import { TransitAgentCountry } from "../transit-agents/entities/transit-agent.entity"; +import { CompaniesService } from "./companies.service"; +import { CompanyType } from "./entities/company.entity"; +import { ProfileStatus, ProfileType } from "./entities/company-profile.entity"; + +/** + * A freight forwarder IS an Ethiopian transit agent, so taking the role means + * naming which roster entry the company is. These lock the rule at the door: + * no agent → refused; a foreign or suspended one → refused; and the link + * follows the role, both on a fresh draft and when a draft is re-run. + */ + +interface ExistingProfile { + id: string; + type: ProfileType; + status: ProfileStatus; +} + +const ETHIOPIAN = { + id: "ta-et", + name: "Abyssinia Transit", + isActive: true, + country: TransitAgentCountry.Ethiopia, +}; +const DJIBOUTIAN = { + id: "ta-dj", + name: "Ahmed Bourhan", + isActive: true, + country: TransitAgentCountry.Djibouti, +}; +const SUSPENDED = { ...ETHIOPIAN, id: "ta-off", isActive: false }; +const AGENTS = [ETHIOPIAN, DJIBOUTIAN, SUSPENDED]; + +function makeService(opts: { + existing?: ExistingProfile[] | null; + linkedAgentId?: string | null; + /** The draft's owner already submitted onboarding (transit-agent-only). */ + onboardingCompleted?: boolean; +}) { + const companyProfilesRepo = { + findByCompanyId: jest.fn(async () => opts.existing ?? []), + create: jest.fn(async (row: Record) => ({ + id: "new", + ...row, + })), + softDelete: jest.fn(async () => undefined), + }; + const companiesRepo = { + update: jest.fn(async () => null), + create: jest.fn(async (row: Record) => ({ + id: "company-1", + ...row, + })), + findById: jest.fn(async () => ({ + id: "company-1", + attributes: {}, + transitAgentId: opts.linkedAgentId ?? null, + })), + }; + const profilesRepo = { + findByUserId: jest.fn(async () => + opts.existing === null + ? null + : { + id: "external-1", + companyId: "company-1", + company: { id: "company-1" }, + onboardingCompleted: opts.onboardingCompleted ?? false, + }, + ), + create: jest.fn(async (row: Record) => ({ + id: "external-1", + ...row, + })), + update: jest.fn(async () => null), + }; + const transitAgentsRepo = { + findById: jest.fn(async (id: string) => AGENTS.find((a) => a.id === id) ?? null), + }; + + const service = new CompaniesService( + companiesRepo as never, + companyProfilesRepo as never, + {} as never, + {} as never, + profilesRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + transitAgentsRepo as never, + ); + + jest + .spyOn(service, "getCompanyInfoByUserId") + .mockImplementation( + async () => + ({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never, + ); + // The draft TIN is random and irrelevant here. + jest + .spyOn(service as never, "generateDraftTin" as never) + .mockImplementation((async () => "D000000001") as never); + + return { service, companiesRepo, companyProfilesRepo, profilesRepo }; +} + +const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" }; + +const start = ( + service: CompaniesService, + roles: ProfileType[], + transitAgentId?: string, +) => + service.startOnboarding( + identity as never, + CompanyType.Customer, + roles, + undefined, + undefined, + undefined, + transitAgentId, + ); + +describe("a freight forwarder must name its transit agent", () => { + it("refuses the forwarder role without an agent on a fresh draft", async () => { + const { service, companiesRepo } = makeService({ existing: null }); + + await expect( + start(service, [ProfileType.freightForwarder]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(companiesRepo.create).not.toHaveBeenCalled(); + }); + + it("refuses a Djiboutian agent", async () => { + const { service } = makeService({ existing: null }); + + await expect( + start(service, [ProfileType.freightForwarder], DJIBOUTIAN.id), + ).rejects.toThrow(/not an active Ethiopian transit agent/); + }); + + it("refuses a suspended agent", async () => { + const { service } = makeService({ existing: null }); + + await expect( + start(service, [ProfileType.freightForwarder], SUSPENDED.id), + ).rejects.toThrow(/not an active Ethiopian transit agent/); + }); + + it("stores the Ethiopian agent on the new draft company", async () => { + const { service, companiesRepo } = makeService({ existing: null }); + + await start( + service, + [ProfileType.importer, ProfileType.freightForwarder], + ETHIOPIAN.id, + ); + + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ transitAgentId: ETHIOPIAN.id }), + ); + }); + + it("does not ask an importer for an agent, and stores none", async () => { + const { service, companiesRepo } = makeService({ existing: null }); + + await start(service, [ProfileType.importer], ETHIOPIAN.id); + + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ transitAgentId: null }), + ); + }); +}); + +describe("re-running role selection keeps the link in step with the role", () => { + const importerOnly: ExistingProfile[] = [ + { id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending }, + ]; + + it("links the agent when forwarding is added to an existing draft", async () => { + const { service, companiesRepo } = makeService({ existing: importerOnly }); + + await start( + service, + [ProfileType.importer, ProfileType.freightForwarder], + ETHIOPIAN.id, + ); + + expect(companiesRepo.update).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ transitAgentId: ETHIOPIAN.id }), + ); + }); + + it("keeps the agent already linked when the re-run omits it", async () => { + const { service, companiesRepo } = makeService({ + existing: importerOnly, + linkedAgentId: ETHIOPIAN.id, + }); + + await start(service, [ProfileType.importer, ProfileType.freightForwarder]); + + expect(companiesRepo.update).not.toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ transitAgentId: expect.anything() }), + ); + }); + + it("still refuses a re-run that adds forwarding with nothing linked", async () => { + const { service } = makeService({ existing: importerOnly }); + + await expect( + start(service, [ProfileType.importer, ProfileType.freightForwarder]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("clears the link when the forwarder role is dropped", async () => { + const { service, companiesRepo } = makeService({ + existing: importerOnly, + linkedAgentId: ETHIOPIAN.id, + }); + + await start(service, [ProfileType.importer]); + + expect(companiesRepo.update).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ transitAgentId: null }), + ); + }); +}); + +/** + * A company that is ONLY a transit agent has nothing else to tell us: its + * registration is the roster entry it picked, so onboarding ends right there + * — and reopens if it later takes on a licensed role. + */ +describe("a transit-agent-only company finishes onboarding on the roster pick", () => { + it("names the draft after the agent and completes onboarding at once", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + existing: null, + }); + + await start(service, [ProfileType.transitAgent], ETHIOPIAN.id); + + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + name: ETHIOPIAN.name, + transitAgentId: ETHIOPIAN.id, + }), + ); + expect(profilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + onboardingCompleted: true, + onboardingStep: "transit-agent", + }), + ); + }); + + it("still refuses the role without an agent", async () => { + const { service, companiesRepo } = makeService({ existing: null }); + + await expect( + start(service, [ProfileType.transitAgent]), + ).rejects.toBeInstanceOf(BadRequestException); + expect(companiesRepo.create).not.toHaveBeenCalled(); + }); + + it("keeps the full wizard when a trade role is picked alongside", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + existing: null, + }); + + await start( + service, + [ProfileType.transitAgent, ProfileType.importer], + ETHIOPIAN.id, + ); + + expect(companiesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ transitAgentId: ETHIOPIAN.id }), + ); + expect(companiesRepo.create).not.toHaveBeenCalledWith( + expect.objectContaining({ name: ETHIOPIAN.name }), + ); + expect(profilesRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + onboardingCompleted: false, + onboardingStep: "company", + }), + ); + }); + + it("completes an existing transit-agent-only draft on the pick", async () => { + const { service, companiesRepo, profilesRepo } = makeService({ + existing: [ + { id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Pending }, + ], + }); + + await start(service, [ProfileType.transitAgent], ETHIOPIAN.id); + + expect(companiesRepo.update).toHaveBeenCalledWith( + "company-1", + expect.objectContaining({ + transitAgentId: ETHIOPIAN.id, + name: ETHIOPIAN.name, + }), + ); + expect(profilesRepo.update).toHaveBeenCalledWith( + "external-1", + expect.objectContaining({ + onboardingCompleted: true, + onboardingStep: "transit-agent", + }), + ); + }); + + it("reopens onboarding at the company step when a licensed role is added", async () => { + const { service, profilesRepo } = makeService({ + existing: [ + { id: "p-ta", type: ProfileType.transitAgent, status: ProfileStatus.Active }, + ], + linkedAgentId: ETHIOPIAN.id, + onboardingCompleted: true, + }); + + await start(service, [ProfileType.transitAgent, ProfileType.importer]); + + expect(profilesRepo.update).toHaveBeenCalledWith( + "external-1", + expect.objectContaining({ + onboardingCompleted: false, + onboardingStep: "company", + }), + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index 9bf14cd61..1be1177db 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -10,6 +10,7 @@ const SEQUENCE_MAP: Record = { [ProfileType.freightForwarder]: "seq_company_profile_ffe", [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", [ProfileType.transporter]: "seq_company_profile_tr", + [ProfileType.transitAgent]: "seq_company_profile_ta", }; const PREFIX_MAP: Record = { @@ -18,6 +19,7 @@ const PREFIX_MAP: Record = { [ProfileType.freightForwarder]: "FF", [ProfileType.djFreightForwarder]: "FWJ", [ProfileType.transporter]: "TR", + [ProfileType.transitAgent]: "TA", }; const SERIES_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts index 9a43e65d4..5bf253ba9 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/account-info-response.dto.ts @@ -86,16 +86,6 @@ export class TransitAgentInfoResponseDto { @ApiProperty() isActive: boolean; - @ApiProperty({ - description: "Start of the agent's validity window (yyyy-MM-dd)", - }) - validFrom: string; - - @ApiProperty({ - description: "End of the agent's validity window (yyyy-MM-dd)", - }) - validTo: string; - /** Always null — see {@link ShippingLineInfoResponseDto.company}. */ @ApiProperty({ nullable: true }) company: null = null; @@ -112,8 +102,6 @@ export class TransitAgentInfoResponseDto { this.email = entity.email ?? null; this.phoneNumber = entity.phoneNumber ?? null; this.isActive = entity.isActive; - this.validFrom = entity.validFrom; - this.validTo = entity.validTo; } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts index 8809310cc..67377dffd 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -5,6 +5,7 @@ import { IsEnum, IsOptional, IsString, + IsUUID, MaxLength, ValidateNested, } from "class-validator"; @@ -26,6 +27,14 @@ export class AddCompanyProfileInputDto { @IsString() @MaxLength(120) licenceNumber?: string; + + /** + * Which Ethiopian transit agent the company is. Required for the transit + * agent and freight forwarder roles unless the company is already linked. + */ + @IsOptional() + @IsUUID() + transitAgentId?: string; } export class AddCompanyProfilesDto { diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts index 9bb5453ee..8149c84e2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -1,4 +1,4 @@ -import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsEnum, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { ProfileType } from '../entities/company-profile.entity'; export class CreateCompanyProfileDto { @@ -19,4 +19,12 @@ export class CreateCompanyProfileDto { @IsString() @MaxLength(120) licenceNumber?: string; + + /** + * Which Ethiopian transit agent the company is. Required for the transit + * agent and freight forwarder roles unless the company is already linked. + */ + @IsOptional() + @IsUUID() + transitAgentId?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 321d739e3..46b73867b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -87,6 +87,13 @@ export class ResponseCompanyDto { email?: string | null; website?: string | null; attributes?: Record | null; + /** + * The transit-agent roster entry a freight forwarder registered itself as + * (`Company.transitAgentId`). The name rides along when the relation was + * loaded, so the portal and backoffice can show it without a second lookup. + */ + transitAgentId: string | null; + transitAgent: { id: string; name: string } | null; profiles?: ResponseExternalProfileDto[]; companyProfiles?: ResponseCompanyProfileDto[]; /** @@ -144,6 +151,10 @@ export class ResponseCompanyDto { this.email = company.email; this.website = company.website; this.attributes = company.attributes; + this.transitAgentId = company.transitAgentId ?? null; + this.transitAgent = company.transitAgent + ? { id: company.transitAgent.id, name: company.transitAgent.name } + : null; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); this.companyProfiles = company.companyProfiles?.map( (p) => new ResponseCompanyProfileDto(p), diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts index 0eac35a5f..855b06ae3 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -4,6 +4,7 @@ import { IsBoolean, IsEnum, IsOptional, + IsUUID, } from "class-validator"; import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; @@ -42,4 +43,14 @@ export class StartOnboardingDto { @IsOptional() @IsBoolean() investorLicence?: boolean; + + /** + * Which Ethiopian transit agent this company is. Required whenever `roles` + * includes the freight forwarder — the two are the same business — and + * refused for any other agent (foreign, suspended, or unknown). Ignored when + * the forwarder role is not selected. + */ + @IsOptional() + @IsUUID() + transitAgentId?: string; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 0a16343f4..d2411aa1b 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -9,6 +9,14 @@ export enum ProfileType { freightForwarder = "freight_forwarder", djFreightForwarder = "dj_freight_forwarder", transporter = "transporter", + /** + * An Ethiopian transit agent registering on the portal as itself — the + * business customers pick to clear customs on a booking. Holds no trade + * licence or eTrade business here: its identity is the roster entry + * (`Company.transitAgentId`), and a company with ONLY this role finishes + * onboarding right after picking it. + */ + transitAgent = "transit_agent", } export enum ProfileStatus { diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 68bf73cdf..3d0bd0f16 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -1,5 +1,6 @@ import { BaseEntity } from "@edr/api-common"; -import { Column, Entity, Index, OneToMany } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from "typeorm"; +import { TransitAgent } from "../../transit-agents/entities/transit-agent.entity"; import { ExternalProfile } from "./external-profile.entity"; import { CompanyProfile } from "./company-profile.entity"; @@ -235,6 +236,22 @@ export class Company extends BaseEntity { @Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true }) etradePhone?: string | null; + /** + * The transit-agent roster entry this company IS, when it holds the + * freight-forwarder role. An Ethiopian transit agent and a freight forwarder + * are one business seen from two sides — the roster GL assigns officers + * from, and the customer signing contracts on other companies' behalf — and + * this is what ties the two rows together. Required at onboarding for a + * forwarder; null for every importer/exporter and for forwarders linked + * before the column existed. + */ + @Column({ name: "transit_agent_id", type: "uuid", nullable: true }) + transitAgentId?: string | null; + + @ManyToOne(() => TransitAgent, { nullable: true }) + @JoinColumn({ name: "transit_agent_id" }) + transitAgent?: TransitAgent | null; + @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index a2900174a..d0f9ee7a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import type { Freight } from '@edr/types'; +import { ManualPaymentSettingsService } from '../payment-settings/manual-payment-settings.service'; import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; @@ -30,6 +31,7 @@ export class BookingRequestService { private readonly contractBookingService: ContractBookingService, private readonly notifier: ContractNotifierService, private readonly yardScope: YardScopeService, + private readonly paymentSettings: ManualPaymentSettingsService, ) {} /** @@ -101,6 +103,17 @@ export class BookingRequestService { } } } + // The currency picker hides a switched-off currency, but a stale tab must + // not be able to raise a shipment nobody can pay for. + if ( + dto.paymentCurrency && + !(await this.paymentSettings.isCurrencyOffered(dto.paymentCurrency)) + ) { + throw new BadRequestException( + `${dto.paymentCurrency.toUpperCase()} is not accepted as a billing currency right now. Pick another currency.`, + ); + } + await this.contractBookingService.assertRequestWithinCapacity(contract, { containers: dto.containers, bulk: dto.bulk, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 565164995..2b52ea1f2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -31,6 +31,8 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); return { service, contractsRepository }; } @@ -158,7 +160,9 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, {} as never, {} as never, // consolidationApprovalService - ); + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService + ); return { service, contractsRepository }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fb68b3f5c..164153c80 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -65,6 +65,8 @@ describe('ContractBookingService — drawdown consolidation gate', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); return { service, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts index 6155eb583..1e1a86651 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.initiate-gate.spec.ts @@ -27,6 +27,8 @@ describe('ContractBookingService — customs booking gate', () => { {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts index 4fe111468..d40441363 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.manual-consolidation.spec.ts @@ -47,6 +47,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => { // The pairing is parked for approval rather than going straight to // Operations; the gate itself is covered by its own spec. { requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never, + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); return { service, bookingsRepository, dataSource }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts index 0a892b108..dbb3c06f6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.resubmit-cargo.spec.ts @@ -60,6 +60,8 @@ describe('ContractBookingService — changes-requested resubmit restating cargo' {} as never, // bookingBatchService {} as never, // bookingTransitionService {} as never, // consolidationApprovalService + {} as never, // transitAgentsRepository + {} as never, // transitAssignmentsService ); return { service, bookingsRepository, invoiceService }; } diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index b8205fd33..821e42e56 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -20,6 +20,9 @@ import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingTransitionService } from '../bookings/booking-transition.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; +import { TransitAgentCountry } from '../transit-agents/entities/transit-agent.entity'; +import { TransitAgentsRepository } from '../transit-agents/transit-agents.repository'; +import { TransitAssignmentsService } from '../transit-assignments/transit-assignments.service'; import { ConsolidationService } from '../bookings/consolidation.service'; import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service'; import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; @@ -136,6 +139,8 @@ export class ContractBookingService { private readonly bookingTransitionService: BookingTransitionService, @Inject(forwardRef(() => ConsolidationApprovalService)) private readonly consolidationApprovalService: ConsolidationApprovalService, + private readonly transitAgentsRepository: TransitAgentsRepository, + private readonly transitAssignmentsService: TransitAssignmentsService, ) {} async createUnderContract( @@ -857,34 +862,66 @@ export class ContractBookingService { if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } - // Without-customs import/export: the customer's own clearing agent (name, - // email, phone) is captured per booking at completion. A resubmit may omit - // the fields and keep what the booking already stored. Customs contracts - // (GL clears) and intercity (no border) never collect an agent. + // Without-customs import/export: the customer names who clears customs for + // this booking, one of two ways. Either a registered Ethiopian transit + // agent (a freight forwarder on the platform) — the booking is assigned to + // it and the forwarder is told — or their own clearing agent typed in + // (name, email, phone). A resubmit may omit the typed fields and keep what + // the booking already stored. Customs contracts (GL clears) and intercity + // (no border) never collect an agent. + let assignedTransitAgent: { id: string; name: string } | null = null; if ( !contract.customsClearingEnabled && contract.tradeDirection !== 'DOMESTIC' ) { - const agentName = - dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; - const agentEmail = - dto.customsClearingAgentEmail?.trim() || - booking.customsClearingAgentEmail || - null; - const agentPhone = - dto.customsClearingAgentPhone?.trim() || - booking.customsClearingAgentPhone || - null; - if (!agentName || !agentEmail || !agentPhone) { - throw new BadRequestException( - 'Customs clearing agent name, email and phone are required to complete this booking.', - ); + if (dto.transitAgentId) { + const agent = await this.transitAgentsRepository.findById(dto.transitAgentId); + if ( + !agent || + !agent.isActive || + agent.country !== TransitAgentCountry.Ethiopia + ) { + throw new BadRequestException( + 'The selected transit agent is not an active Ethiopian transit agent — pick another one or enter your clearing agent details.', + ); + } + // The forwarder company's own contact goes on the booking, so the + // customer sees who to reach; an Ethiopian agent row carries none. + const [forwarder]: Array<{ email: string | null; phone: string | null }> = + await this.dataSource.query( + `SELECT email, phone FROM freight.companies + WHERE transit_agent_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [agent.id], + ); + await this.bookingsRepository.update(booking.id, { + customsClearingAgent: agent.name, + customsClearingAgentEmail: forwarder?.email ?? null, + customsClearingAgentPhone: forwarder?.phone ?? null, + } as never); + assignedTransitAgent = { id: agent.id, name: agent.name }; + } else { + const agentName = + dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null; + const agentEmail = + dto.customsClearingAgentEmail?.trim() || + booking.customsClearingAgentEmail || + null; + const agentPhone = + dto.customsClearingAgentPhone?.trim() || + booking.customsClearingAgentPhone || + null; + if (!agentName || !agentEmail || !agentPhone) { + throw new BadRequestException( + 'Customs clearing agent name, email and phone are required to complete this booking — or pick a registered transit agent.', + ); + } + await this.bookingsRepository.update(booking.id, { + customsClearingAgent: agentName, + customsClearingAgentEmail: agentEmail, + customsClearingAgentPhone: agentPhone, + } as never); } - await this.bookingsRepository.update(booking.id, { - customsClearingAgent: agentName, - customsClearingAgentEmail: agentEmail, - customsClearingAgentPhone: agentPhone, - } as never); } // No expiry gate here on purpose: this booking was already initiated // before the contract lapsed (createUnderContract/initiateUnderContract @@ -1092,6 +1129,18 @@ export class ContractBookingService { dto.scheduledDate, dto.trainScheduleId ?? null, ); + + // The forwarder's work list and its notice come AFTER the booking is + // committed: a customer must never be told a forwarder has the job when + // the completion itself was refused a line above. + if (assignedTransitAgent) { + await this.transitAssignmentsService.ensureAssignment( + booking.id, + assignedTransitAgent.id, + (actorPermissions as { id?: string } | undefined)?.id, + ); + void this.bookingNotifier.transitAgentAssigned(completed, assignedTransitAgent); + } return { booking: completed, warnings }; } 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 1367e8c8a..3a5c938bb 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 @@ -215,9 +215,58 @@ export class ContractPricingService { // Conditional surcharges — shown only when the contract toggles them on AND // the rate has a non-zero value (a 0 rate means "no surcharge"). - if (contract.isHazardous) { + if (contract.isHazardous && contract.freightType === 'CONTAINER') { + // Container hazard is sold per direction + route + container type, like + // the empty-return service — one display line per contract size that has + // a configured rate (size-specific wins over the lane's catch-all). A + // size with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'HAZARD_SURCHARGE' && + r.rateUnit === 'PER_CONTAINER' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, + }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'HAZARD_SURCHARGE', + label: `Hazardous surcharge (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'is_hazardous', + }); + } + } + } else if (contract.isHazardous) { + // Bulk hazard is the global per-ton rate; the per-container rows belong + // to container lanes and must not price a bulk contract. const hazard = liveRates.find( - (r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD', + (r) => + r.rateType === 'HAZARD_SURCHARGE' && + r.rateUnit !== 'PER_CONTAINER' && + r.currency === 'USD', ); if (hazard && Number(hazard.rateValue) > 0) { lineItems.push({ 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 5d8c24060..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 @@ -1502,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 dc9f8ac7b..83e8f8952 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -953,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-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index b1dabfa16..4dd8bb254 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -256,6 +256,17 @@ export class CreateBookingUnderContractDto { @MaxLength(50) customsClearingAgentPhone?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Instead of typing a clearing agent: a registered Ethiopian transit agent (freight ' + + 'forwarder). The booking is assigned to it and the forwarder is notified; the typed ' + + 'agent fields are ignored when this is set.', + }) + @IsOptional() + @IsUUID() + transitAgentId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() 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 b776333b9..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 @@ -239,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; @@ -373,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/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts index aae59de39..424ff2357 100644 --- a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -54,6 +54,33 @@ export interface EmptyReturnQuote { unavailableReason: string | null; } +/** A queue row: the request plus the booking and payer names staff read it by. */ +export type EmptyReturnRequestRow = Pick< + EmptyReturnRequest, + | 'id' + | 'bookingId' + | 'companyId' + | 'status' + | 'containerNumbers' + | 'containerCount' + | 'quotedUnitAmount' + | 'quotedTotalAmount' + | 'currency' + | 'invoiceId' + | 'paidAt' + | 'requestedReturnDate' + | 'truckPlateNumber' + | 'truckDriverName' + | 'truckType' + | 'scheduledAt' + | 'submittedByUserId' + | 'submittedAt' + | 'reviewedByStaffId' + | 'reviewedAt' + | 'rejectionReason' + | 'completedAt' +> & { bookingReference: string | null; companyName: string | null }; + export interface EmptyReturnEligibility { eligible: boolean; /** Why the customer cannot request one, when `eligible` is false. */ @@ -82,13 +109,34 @@ export class EmptyReturnRequestsService { async findAll(filter: { status?: EmptyReturnRequestStatus; bookingId?: string; - }): Promise< - Array - > { + }): Promise { + // Raw SQL bypasses the entity mapping, so every column is aliased to the + // property name the clients read — `r.*` would hand them snake_case. return this.dataSource.query( - `SELECT r.*, - b.reference AS "bookingReference", - c.name AS "companyName" + `SELECT r.id, + r.booking_id AS "bookingId", + r.company_id AS "companyId", + r.status, + r.container_numbers AS "containerNumbers", + r.container_count AS "containerCount", + r.quoted_unit_amount::float8 AS "quotedUnitAmount", + r.quoted_total_amount::float8 AS "quotedTotalAmount", + r.currency, + r.invoice_id AS "invoiceId", + r.paid_at AS "paidAt", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + r.scheduled_at AS "scheduledAt", + r.submitted_by_user_id AS "submittedByUserId", + r.submitted_at AS "submittedAt", + r.reviewed_by_staff_id AS "reviewedByStaffId", + r.reviewed_at AS "reviewedAt", + r.rejection_reason AS "rejectionReason", + r.completed_at AS "completedAt", + b.reference AS "bookingReference", + c.name AS "companyName" FROM freight.empty_return_requests r LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.companies c ON c.id = r.company_id diff --git a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts index d36bbecd6..3cfd7a579 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/customers.dataset.ts @@ -130,6 +130,7 @@ export const customersDataset: ExportDataset = { { value: 'exporter', label: 'Exporter' }, { value: 'freight_forwarder', label: 'Freight forwarder' }, { value: 'dj_freight_forwarder', label: 'DJ freight forwarder' }, + { value: 'transit_agent', label: 'Transit agent' }, { value: 'transporter', label: 'Transporter' }, ] }, // The list's Status filter folds the review queues in, and sends these two diff --git a/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts new file mode 100644 index 000000000..d1f1a9baa --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/djf-currency-switch.spec.ts @@ -0,0 +1,56 @@ +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; +import type { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; + +/** Single-row repository stub: enough for get/update, nothing more. */ +const repoWith = (row: Partial) => { + const stored = { id: "settings-1", ...row } as ManualPaymentSetting; + return { + findOne: async () => stored, + create: (v: Partial) => v as ManualPaymentSetting, + save: async (v: ManualPaymentSetting) => v, + update: async (_id: string, patch: Partial) => { + Object.assign(stored, patch); + }, + }; +}; + +const serviceWith = (row: Partial) => + new ManualPaymentSettingsService(repoWith(row) as never); + +describe("DJF currency switch", () => { + it("offers DJF, and accepts it, while the switch is on", async () => { + const service = serviceWith({ djfPaymentsEnabled: true }); + + await expect(service.offeredCurrencies()).resolves.toEqual([ + "ETB", + "USD", + "DJF", + ]); + await expect(service.isCurrencyOffered("DJF")).resolves.toBe(true); + }); + + it("drops DJF from the offered currencies once switched off", async () => { + const service = serviceWith({ djfPaymentsEnabled: false }); + + await expect(service.offeredCurrencies()).resolves.toEqual(["ETB", "USD"]); + await expect(service.isCurrencyOffered("djf")).resolves.toBe(false); + }); + + it("never switches off ETB or USD — only DJF has a currency-level switch", async () => { + const service = serviceWith({ djfPaymentsEnabled: false }); + + await expect(service.isCurrencyOffered("ETB")).resolves.toBe(true); + await expect(service.isCurrencyOffered("USD")).resolves.toBe(true); + }); + + it("leaves the manual rail alone when the currency switch flips", async () => { + const service = serviceWith({ djfEnabled: true, djfPaymentsEnabled: true }); + + const updated = await service.update({ djfPaymentsEnabled: false }, "user-1"); + + expect(updated.djfPaymentsEnabled).toBe(false); + // Existing DJF invoices stay hand-settleable, so nothing is stranded. + expect(updated.djfEnabled).toBe(true); + await expect(service.isEnabled("DJF")).resolves.toBe(true); + }); +}); 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 39d4757e7..0672d573e 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 @@ -20,4 +20,12 @@ export class UpdateManualPaymentSettingDto { @IsOptional() @IsBoolean() djfEnabled?: boolean; + + @ApiPropertyOptional({ + description: + "Accept DJF as a payment currency at all — booking forms and online payment", + }) + @IsOptional() + @IsBoolean() + djfPaymentsEnabled?: 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 862456241..d201bb07e 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 @@ -24,6 +24,17 @@ export class ManualPaymentSetting extends BaseEntity { @Column({ name: "djf_enabled", type: "boolean", default: true }) djfEnabled!: boolean; + /** + * Whether DJF may be used as a payment currency AT ALL — offered on the + * booking/shipment forms and accepted for online payment (Waafi / CAC Bank). + * + * Wider than `djfEnabled`, which only governs the manual rail. Off leaves + * existing DJF invoices settleable by hand (while `djfEnabled` is on), so + * switching it off strands nothing — it only stops new DJF business. + */ + @Column({ name: "djf_payments_enabled", type: "boolean", default: true }) + djfPaymentsEnabled!: 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.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts index 786d1f7ca..6b29ee226 100644 --- a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -30,13 +30,26 @@ export class ManualPaymentSettingsController { return this.service.get(); } + /** + * Which currencies may be picked for new bookings and paid online. Read by + * the customer portal's booking forms, so it stays open like the other + * form-shaping settings reads (file-upload / dropdown settings) — it exposes + * nothing beyond what the currency picker already shows. + */ + @Get("currencies") + @ApiOperation({ summary: "Currencies customers may be billed and pay in" }) + currencies() { + return this.service.offeredCurrencies(); + } + @Patch() @BookingStaff([ FREIGHT_PERMS.settings.manualPayment.manage, FREIGHT_PERMS.admin, ]) @ApiOperation({ - summary: "Enable or disable manual invoice settlement for ETB and/or USD", + summary: + "Enable or disable manual invoice settlement per currency, and whether DJF is accepted at all", }) update( @Body() dto: UpdateManualPaymentSettingDto, 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 dc397cf79..50d05a7f9 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 @@ -37,7 +37,12 @@ export class ManualPaymentSettingsService { if (existing) return existing; return this.repository.save( - this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }), + this.repository.create({ + etbEnabled: false, + usdEnabled: true, + djfEnabled: true, + djfPaymentsEnabled: true, + }), ); } @@ -60,9 +65,34 @@ export class ManualPaymentSettingsService { return setting[field]; } + /** + * Currencies customers may be billed and pay in right now. ETB and USD are + * always offered; DJF only while its currency-level switch is on. Read by + * the booking forms (portal and backoffice) to decide which options to show. + */ + async offeredCurrencies(): Promise { + const setting = await this.get(); + return setting.djfPaymentsEnabled ? ["ETB", "USD", "DJF"] : ["ETB", "USD"]; + } + + /** + * Whether a currency may be used for NEW business and online payment — the + * currency-level switch, not the manual rail's {@link isEnabled}. Only DJF + * is switchable; ETB and USD have no off switch. + */ + async isCurrencyOffered(currency: string | null | undefined): Promise { + if (currency?.toUpperCase() !== "DJF") return true; + return (await this.get()).djfPaymentsEnabled; + } + /** Flip any toggle; an omitted field leaves that currency unchanged. */ async update( - patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean }, + patch: { + etbEnabled?: boolean; + usdEnabled?: boolean; + djfEnabled?: boolean; + djfPaymentsEnabled?: boolean; + }, updatedById?: string | null, ): Promise { const current = await this.get(); @@ -70,11 +100,14 @@ export class ManualPaymentSettingsService { ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), ...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }), + ...(patch.djfPaymentsEnabled === undefined + ? {} + : { djfPaymentsEnabled: patch.djfPaymentsEnabled }), updatedById: updatedById ?? null, }); const updated = await this.get(); this.logger.warn( - `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`, + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} (DJF accepted as a currency: ${updated.djfPaymentsEnabled}) by ${updatedById ?? "unknown user"}`, ); return updated; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index bca8ab278..7ae0b13f9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -7,7 +7,7 @@ import { RATE_UNITS, } from '../entities/rate.entity'; -// DOMESTIC is accepted only for FUEL rates (an intercity fuel lane). +// DOMESTIC is accepted only for FUEL and per-container HAZARDOUS rates (an intercity lane). const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; // ETB is accepted only for last-mile rates; the service forces USD elsewhere. const CURRENCIES = ['USD', 'ETB'] as const; @@ -26,7 +26,10 @@ export class CreateRateDto { @IsIn([...RATE_TRIGGERS]) trigger!: string; - @ApiPropertyOptional({ description: 'FK to container_types.id — set for container/intercity-container rates' }) + @ApiPropertyOptional({ + description: + 'FK to container_types.id — set for container/intercity-container rates, and optionally on the per-container HAZARDOUS surcharge (20ft / 40ft price differently; omitted = the lane catch-all)', + }) @IsOptional() @IsUUID() containerTypeId?: string; @@ -61,7 +64,7 @@ export class CreateRateDto { @ApiPropertyOptional({ description: - 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.', }) @IsOptional() @IsUUID() @@ -69,7 +72,7 @@ export class CreateRateDto { @ApiPropertyOptional({ description: - 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', + 'FK to yards.id — destination of the leg this rate prices. Required for base freight (bulk/container/intercity) and the lane-sold surcharges (customs clearance, empty return, fuel, per-container HAZARDOUS); rejected for every other surcharge and first/last mile.', }) @IsOptional() @IsUUID() 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 dd6432cfa..1fffd48e6 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 @@ -88,6 +88,10 @@ export type RateAppliesTo = typeof RATE_APPLIES_TO[number]; */ export const RATE_TRIGGERS = [ 'ALWAYS', + // Hazardous cargo. Two shapes under one trigger, told apart by the unit: + // PER_CONTAINER is the container surcharge, sold per direction + lane and + // optionally per box size (20ft / 40ft) like the empty-return service; + // PER_TON is the bulk surcharge, direction-agnostic and unscoped. 'HAZARDOUS', 'OVERWEIGHT', 'REEFER', @@ -121,6 +125,16 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; export const isCustomsClearanceTrigger = (trigger: string): boolean => trigger === 'CUSTOMS_CLEARANCE' || trigger === 'ETHIOPIAN_CUSTOMS_CLEARANCE'; +/** + * The container hazardous-cargo surcharge: HAZARDOUS billed per container. + * It is sold per trade direction + origin → destination lane, optionally + * narrowed to one container type (20ft / 40ft), and priced by the + * route-matched block in RuleEngineService — never by the additive loop. + * The per-ton (bulk) hazard rate keeps the old global, unscoped shape. + */ +export const isContainerHazardRate = (trigger: string, rateUnit: string): boolean => + trigger === 'HAZARDOUS' && rateUnit === 'PER_CONTAINER'; + @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) @@ -159,8 +173,10 @@ export class Rate extends BaseEntity { /** * The leg this rate prices. Base freight (trigger = ALWAYS) is quoted per * route — "container import, Djibouti → Dire Dawa" — so both yards are - * required for BULK/CONTAINER/INTERCITY and NULL for everything else. The - * `CK_rates_yard_scope` DB constraint enforces both halves of that. + * required for BULK/CONTAINER/EMPTY_CONTAINER/INTERCITY, for the lane-sold + * surcharges (customs clearance, empty return, fuel, container hazard) and + * NULL for everything else. The `CK_rates_yard_scope` DB constraint enforces + * both halves of that. */ @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true }) originYardId?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 2d91d2da7..f4f22e198 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -3,12 +3,14 @@ import type { BookingEvaluationInput } from './rule-engine.service'; import type { Rate } from './entities/rate.entity'; describe('RuleEngineService — requested service without a configured surcharge rate', () => { + // The bulk (per-ton) hazard rate — global, no lane. These bookings carry no + // containers, so they are bulk-shaped and price off this one. const hazardRate: Rate = { id: 'rate-hazard', rateType: 'HAZARD_SURCHARGE', trigger: 'HAZARDOUS', rateValue: 50, - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', currency: 'USD', status: 'LIVE', containerTypeId: null, @@ -650,6 +652,7 @@ describe('RuleEngineService — shipping-line rates override the standard ones', shippingLineCompanyId: LINE, } as Rate; + // Container hazard is sold per direction + lane (+ optional box size). const standardHazard: Rate = { id: 'rate-hazard-standard', rateType: 'HAZARD_SURCHARGE', @@ -661,6 +664,9 @@ describe('RuleEngineService — shipping-line rates override the standard ones', containerTypeId: null, cargoTypeId: null, shippingLineCompanyId: null, + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', } as Rate; const lineHazard: Rate = { @@ -764,3 +770,161 @@ describe('RuleEngineService — shipping-line rates override the standard ones', expect(result.hardBlocked[0]).toContain('hazardous'); }); }); + +describe('RuleEngineService — container hazard surcharge per lane and box size', () => { + const lane = { + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + }; + const catchAll: Rate = { + id: 'rate-hazard-lane', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + ...lane, + } as Rate; + const forty: Rate = { + ...catchAll, + id: 'rate-hazard-lane-40', + rateValue: 90, + containerTypeId: 'ct-40', + } as Rate; + const bulkHazard: Rate = { + ...catchAll, + id: 'rate-hazard-bulk', + rateValue: 3, + rateUnit: 'PER_TON', + tradeDirection: null, + originYardId: null, + destinationYardId: null, + } as Rate; + + const buildService = (rates: Rate[]) => + new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const booking = (overrides: Partial = {}): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + isHazardous: false, + totalWagons: 2, + ...lane, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30, hazardousQuantity: 2 }, + { containerTypeId: 'ct-40', quantity: 1, vgmPerUnitTons: 10, totalVgmTons: 10, hazardousQuantity: 1 }, + ], + ...overrides, + }); + + const hazardOf = (result: { appliedModifiers: Array<{ surchargeCode: string }> }) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'HAZARD_SURCHARGE'); + + it('bills each line off the lane rate for its own box size, catch-all otherwise', async () => { + const result = await buildService([catchAll, forty]).evaluate(booking()); + expect(result.hardBlocked).toHaveLength(0); + const lines = hazardOf(result); + expect(lines).toHaveLength(2); + // 2 hazardous 20ft on the lane catch-all, 1 hazardous 40ft on the 40ft rate. + expect(lines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ rateId: catchAll.id, triggerValue: 2, calculatedAmount: 100, unitPriceUsd: 50, billingUnit: 'PER_CONTAINER' }), + expect.objectContaining({ rateId: forty.id, triggerValue: 1, calculatedAmount: 90, unitPriceUsd: 90 }), + ]), + ); + }); + + it('bills the opted-in count, not the whole line', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + containers: [ + { containerTypeId: 'ct-20', quantity: 10, vgmPerUnitTons: 10, totalVgmTons: 100, hazardousQuantity: 4 }, + ], + }), + ); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ triggerValue: 4, calculatedAmount: 200 }), + ]); + }); + + it('falls back to every container when only the legacy booking-level flag is set', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + isHazardous: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 3, vgmPerUnitTons: 10, totalVgmTons: 30 }, + ], + }), + ); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ triggerValue: 3, calculatedAmount: 150 }), + ]); + }); + + it('never bills the per-container rate a second time through the additive loop', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ + isHazardous: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 2, vgmPerUnitTons: 10, totalVgmTons: 20 }, + ], + }), + ); + expect(hazardOf(result)).toHaveLength(1); + }); + + it('hard-blocks when the lane has no per-container hazard rate', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ destinationYardId: 'yard-elsewhere' }), + ); + expect(hazardOf(result)).toHaveLength(0); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + expect(result.hardBlocked[0]).toContain('route'); + }); + + it('hard-blocks when the rate is for the other direction', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ tradeDirection: 'EXPORT', originYardId: 'yard-adama', destinationYardId: 'yard-dj' }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); + + it('does not let the bulk per-ton rate stand in for a container booking', async () => { + const result = await buildService([bulkHazard]).evaluate(booking()); + expect(hazardOf(result)).toHaveLength(0); + expect(result.hardBlocked).toHaveLength(1); + }); + + it('bills a bulk booking off the global per-ton rate, untouched by the lane rule', async () => { + const result = await buildService([bulkHazard, catchAll]).evaluate( + booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }), + ); + expect(result.hardBlocked).toHaveLength(0); + expect(hazardOf(result)).toEqual([ + expect.objectContaining({ rateId: bulkHazard.id, triggerValue: 40, calculatedAmount: 120 }), + ]); + }); + + it('hard-blocks a hazardous bulk booking when only the container rate exists', async () => { + const result = await buildService([catchAll]).evaluate( + booking({ isHazardous: true, containers: [], bulkTons: 40, totalWagons: 1 }), + ); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); +}); + diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index f035f8a64..2eb7c658e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -1,7 +1,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity'; -import { Rate, RateTrigger } from './entities/rate.entity'; +import { Rate, RateTrigger, isContainerHazardRate } from './entities/rate.entity'; import { isBulkQuantityUnit } from './entities/rate-unit.util'; import { ICargoTypesRepository, @@ -366,9 +366,11 @@ export class RuleEngineService { // hard block — pricing would otherwise ship the service for free. System- // derived charges (consolidation, overweight, shipping line, lashing) stay // exempt: the customer never opted into those, so they must not block. + const isContainerBooking = input.containers.length > 0; const requestedServices: Array<{ trigger: RateTrigger; wanted: boolean; + configured: boolean; label: string; }> = [ { @@ -376,6 +378,15 @@ export class RuleEngineService { wanted: truthy(input.isHazardous) || input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), + // Container hazard is sold per lane + box size and checked by the + // route-matched block below (which blocks per missing lane/type rate); + // bulk hazard is the global per-ton rate this loop can vouch for. + configured: isContainerBooking + ? true + : surchargeRates.some( + (r) => + r.trigger === 'HAZARDOUS' && !isContainerHazardRate(r.trigger, r.rateUnit), + ), label: 'hazardous cargo', }, { @@ -383,11 +394,12 @@ export class RuleEngineService { wanted: hasReefer || input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), + configured: surchargeRates.some((r) => r.trigger === 'REEFER'), label: 'refrigerated (reefer) cargo', }, ]; for (const svc of requestedServices) { - if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { + if (svc.wanted && !svc.configured) { hardBlocked.push( `No ${svc.label} surcharge rate is configured — the booking cannot ` + `be priced with this service. Remove the ${svc.label} option or ` + @@ -411,6 +423,10 @@ export class RuleEngineService { // Fuel is sold per lane + cargo type — billed by the route-matched // block below, never by this route-agnostic loop. if (rate.trigger === 'FUEL') continue; + // The container hazard surcharge is sold per lane + box size like the + // empty-return service — billed by its own route-matched block below. + // The per-ton bulk hazard rate stays additive here. + if (isContainerHazardRate(rate.trigger, rate.rateUnit)) continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -521,6 +537,10 @@ export class RuleEngineService { appliedModifiers.push(...withReturn.modifiers); hardBlocked.push(...withReturn.blocked); + const containerHazard = this.containerHazardCharges(input, liveRates); + appliedModifiers.push(...containerHazard.modifiers); + hardBlocked.push(...containerHazard.blocked); + if (hasLashing) { appliedModifiers.push(...this.lashingCharges(input, liveRates)); } @@ -730,6 +750,80 @@ export class RuleEngineService { return { modifiers, blocked: [...new Set(blocked)] }; } + /** + * Container hazardous-cargo surcharge — sold per direction + route, optionally + * per container type, exactly like the empty-return service. Each container + * line that opted in (hazardousQuantity, or every container when only the + * legacy booking-level flag is set) bills the route-matched PER_CONTAINER + * HAZARDOUS rate for its own container type, falling back to the lane's + * catch-all (no type) rate; a line with no matching rate hard-blocks the + * booking instead of shipping the service for free. Bulk bookings never + * reach here — their per-ton hazard rate is billed by the additive loop. + * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one + * HAZARD_SURCHARGE snapshot code can't hold per-size route prices. + */ + private containerHazardCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { + const modifiers: AppliedCargoModifier[] = []; + const blocked: string[] = []; + if (input.containers.length === 0) return { modifiers, blocked }; + const bookingLevel = truthy(input.isHazardous); + const wanted = + bookingLevel || + input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0); + if (!wanted) return { modifiers, blocked }; + + const onLeg = liveRates.filter( + (r) => + isContainerHazardRate(r.trigger, r.rateUnit) && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + + for (const container of input.containers) { + const qty = + Number(container.hazardousQuantity ?? 0) > 0 + ? Number(container.hazardousQuantity) + : bookingLevel + ? Number(container.quantity || 0) + : 0; + if (!(qty > 0)) continue; + + // The rate scoped to this box size wins over the lane's catch-all. + const rate = + onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate) { + blocked.push( + 'No hazardous cargo surcharge rate is configured for this container ' + + 'type on this route — remove the hazardous option or ask EDR to ' + + 'configure its per-container rate for this origin → destination.', + ); + continue; + } + + const rateValue = Number(rate.rateValue); + const amount = qty * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: qty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + } + + // Same block deduplicated — several lines missing the rate is one problem. + return { modifiers, blocked: [...new Set(blocked)] }; + } + /** * Cargo securing / lashing — BULK only, sold per trade direction, optionally * narrowed to one leaf commodity (the commodity-scoped rate wins over the diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts index 0a4108a4e..47e56b776 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.duplicate-pattern.spec.ts @@ -1,4 +1,4 @@ -import { ConflictException } from '@nestjs/common'; +import { BadRequestException, ConflictException } from '@nestjs/common'; import { RatesService } from './rates.service'; import type { Rate } from '../entities/rate.entity'; @@ -101,8 +101,9 @@ describe('RatesService — one rate per pattern', () => { /** * Additive surcharges are billed per matching rate, each by its own unit, so - * hazard is legitimately per-container for boxes AND per-ton for bulk. The - * unit stays part of their identity or the second one could never be created. + * the bulk (per-ton) hazard rate coexists with the lane-sold container one. + * The unit stays part of their identity or the second one could never be + * created. */ it('keeps the unit in the key for an additive surcharge', async () => { await service.create( @@ -110,17 +111,44 @@ describe('RatesService — one rate per pattern', () => { appliesTo: 'OTHER', trigger: 'HAZARDOUS', rateValue: 300, - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', } as never, 'staff-1', ); expect(repository.findByPattern.mock.calls[0][0]).toMatchObject({ rateType: 'HAZARD_SURCHARGE', - rateUnit: 'PER_CONTAINER', + rateUnit: 'PER_TON', }); }); + it('keeps the per-ton hazard rate global — direction and lane are dropped', async () => { + await service.create( + { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + rateValue: 5, + rateUnit: 'PER_TON', + } as never, + 'staff-1', + ); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_TON', + tradeDirection: null, + originYardId: null, + destinationYardId: null, + containerTypeId: null, + }), + ); + }); + it('treats lashing as singly resolved — one unit per direction', async () => { await service.create( { @@ -138,3 +166,166 @@ describe('RatesService — one rate per pattern', () => { ); }); }); + +/** + * The container hazard surcharge (HAZARDOUS per container) is sold per + * direction + lane, optionally per box size — the same shape as the + * empty-return service. Pricing resolves exactly one rate per lane + size, so + * the unit leaves the identity and the lane joins it. + */ +describe('RatesService — per-container hazard is sold per lane', () => { + const DJ = '11111111-1111-4000-8000-000000000001'; + const ET = '11111111-1111-4000-8000-000000000002'; + const ET2 = '11111111-1111-4000-8000-000000000004'; + const CT20 = '11111111-1111-4000-8000-000000000003'; + + let repository: { findByPattern: jest.Mock; create: jest.Mock }; + let service: RatesService; + + beforeEach(() => { + repository = { + findByPattern: jest.fn().mockResolvedValue(null), + create: jest.fn(async (r) => ({ id: 'rate-new', ...r })), + }; + service = new RatesService( + repository as never, + { + findById: jest.fn(async (id: string) => ({ + id, + country: id === DJ ? 'Djibouti' : 'Ethiopia', + label: id === DJ ? 'Doraleh' : id === ET ? 'Gelan' : 'Dire Dawa', + })), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn() } as never, + ); + }); + + const containerHazard = { + appliesTo: 'OTHER', + trigger: 'HAZARDOUS', + rateValue: 300, + rateUnit: 'PER_CONTAINER', + }; + + it('requires a trade direction', async () => { + await expect( + service.create( + { ...containerHazard, originYardId: DJ, destinationYardId: ET } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('requires both yards of the lane', async () => { + await expect( + service.create( + { ...containerHazard, tradeDirection: 'IMPORT' } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('rejects a lane that contradicts the direction', async () => { + // Export runs Ethiopia → Djibouti; this leg is the import shape. + await expect( + service.create( + { + ...containerHazard, + tradeDirection: 'EXPORT', + originYardId: DJ, + destinationYardId: ET, + } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('files the rate per direction + lane + box size, unit out of the key', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + } as never, + 'staff-1', + ); + + const pattern = repository.findByPattern.mock.calls[0][0]; + expect(pattern).not.toHaveProperty('rateUnit'); + expect(pattern).toMatchObject({ + rateType: 'HAZARD_SURCHARGE', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + rateType: 'HAZARD_SURCHARGE', + rateUnit: 'PER_CONTAINER', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + cargoTypeId: null, + }), + ); + }); + + it('accepts a lane catch-all with no box size', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'EXPORT', + originYardId: ET, + destinationYardId: DJ, + } as never, + 'staff-1', + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + tradeDirection: 'EXPORT', + originYardId: ET, + destinationYardId: DJ, + containerTypeId: null, + }), + ); + }); + + it('accepts a DOMESTIC (intercity) lane inside Ethiopia', async () => { + await service.create( + { + ...containerHazard, + tradeDirection: 'DOMESTIC', + originYardId: ET, + destinationYardId: ET2, + } as never, + 'staff-1', + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ tradeDirection: 'DOMESTIC', originYardId: ET, destinationYardId: ET2 }), + ); + }); + + it('refuses a second rate for the same lane + box size', async () => { + repository.findByPattern.mockResolvedValue({ id: 'rate-existing' } as Rate); + await expect( + service.create( + { + ...containerHazard, + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: ET, + containerTypeId: CT20, + } as never, + 'staff-1', + ), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); + 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 87618bcdb..c2196b663 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 @@ -13,7 +13,7 @@ import { ShippingLineCompaniesService } from '../../shipping-lines/shipping-line import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; -import { Rate, isCustomsClearanceTrigger } from '../entities/rate.entity'; +import { Rate, isContainerHazardRate, isCustomsClearanceTrigger } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { @@ -39,7 +39,11 @@ const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = [ 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'CANCELLATION', ]; -/** Surcharges that keep a trade direction (everything else is direction-agnostic). */ +/** + * Surcharges that keep a trade direction (everything else is direction-agnostic). + * Container hazard (HAZARDOUS billed PER_CONTAINER) is directed too, but is + * keyed on the unit rather than the trigger — see {@link isDirectedSurcharge}. + */ const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ 'CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', @@ -148,18 +152,29 @@ export class RatesService { /** * Rates sold per direction + route. Base freight always; customs clearance, - * empty-container return and fuel are the surcharges that are too — their - * fee depends on the lane (and, for returns, the container type). + * empty-container return, fuel and the container hazard surcharge are the + * surcharges that are too — their fee depends on the lane (and, for returns + * and container hazard, the container type). */ - private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + private isRouteScoped( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + rateUnit: Rate['rateUnit'], + ): boolean { return ( this.isBaseFreight(appliesTo, trigger) || isCustomsClearanceTrigger(trigger) || trigger === 'WITH_RETURN' || - trigger === 'FUEL' + trigger === 'FUEL' || + isContainerHazardRate(trigger, rateUnit) ); } + /** Surcharges that carry a trade direction; everything else is direction-agnostic. */ + private isDirectedSurcharge(trigger: Rate['trigger'], rateUnit: Rate['rateUnit']): boolean { + return DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) || isContainerHazardRate(trigger, rateUnit); + } + /** * True when pricing resolves exactly ONE rate for this shape (base freight, * customs clearance, lashing, empty-container return — all `find()`-based @@ -174,9 +189,10 @@ export class RatesService { private resolvesSingleRate( appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], + rateUnit: Rate['rateUnit'], ): boolean { return ( - this.isRouteScoped(appliesTo, trigger) || + this.isRouteScoped(appliesTo, trigger, rateUnit) || trigger === 'LASHING' || trigger === 'CANCELLATION' ); @@ -191,8 +207,8 @@ export class RatesService { appliesTo: Rate['appliesTo'], tradeDirection: string | null, ): { origin: YardCountry; destination: YardCountry } { - // DOMESTIC only reaches here on a FUEL rate's intercity lane — it stays - // inside Ethiopia exactly like intercity base freight. + // DOMESTIC only reaches here on a FUEL or container-hazard rate's intercity + // lane — it stays inside Ethiopia exactly like intercity base freight. if (appliesTo === 'INTERCITY' || tradeDirection === 'DOMESTIC') { return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; } @@ -212,12 +228,13 @@ export class RatesService { private async resolveYardScope(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; + rateUnit: Rate['rateUnit']; tradeDirection: string | null; originYardId?: string | null; destinationYardId?: string | null; }): Promise { - const { appliesTo, trigger, tradeDirection } = input; - if (!this.isRouteScoped(appliesTo, trigger)) { + const { appliesTo, trigger, rateUnit, tradeDirection } = input; + if (!this.isRouteScoped(appliesTo, trigger, rateUnit)) { return { originYardId: null, destinationYardId: null }; } @@ -263,14 +280,36 @@ export class RatesService { private assertScopeCoherent(input: { appliesTo: Rate['appliesTo']; trigger: Rate['trigger']; + rateUnit: Rate['rateUnit']; tradeDirection: string | null; intercityKind: string | null; cargoKind: string | null; containerTypeId: string | null; cargoTypeId: string | null; }): void { - const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; + const { appliesTo, trigger, rateUnit, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; + if (isContainerHazardRate(trigger, rateUnit)) { + // The container hazard surcharge is sold per lane like the empty-return + // service: the direction says which countries the leg spans (DOMESTIC = + // intercity, inside Ethiopia) and the box size may narrow it (a 20ft and + // a 40ft hazardous box price differently; no size = the lane's catch-all). + if ( + tradeDirection !== 'IMPORT' && + tradeDirection !== 'EXPORT' && + tradeDirection !== 'DOMESTIC' + ) { + throw new BadRequestException( + 'A per-container hazardous surcharge must say whether it covers IMPORT, EXPORT or DOMESTIC (intercity).', + ); + } + if (cargoTypeId) { + throw new BadRequestException( + 'A per-container hazardous surcharge cannot be scoped to a bulk cargo type.', + ); + } + return; + } if (isCustomsClearanceTrigger(trigger) || trigger === 'CANCELLATION') { // Both fees are sold per direction + cargo kind + type: customs clearance // per lane, the wagon cancellation fee per direction only. @@ -602,18 +641,12 @@ export class RatesService { // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. // Exceptions: the directed surcharges (customs clearance, cancellation, - // empty-container return, lashing, fuel) keep direction + cargo scope. + // empty-container return, lashing, fuel, per-container hazard) keep + // direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) : null; - const containerTypeId = - trigger === 'WITH_RETURN' || - (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') - ? (dto.containerTypeId ?? null) - : isSurcharge - ? null - : (dto.containerTypeId ?? null); const cargoTypeId = (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || @@ -622,12 +655,30 @@ export class RatesService { : isSurcharge ? null : (dto.cargoTypeId ?? null); + // The unit is resolved before the scope because for hazard it IS the shape: + // per container is the lane-sold container surcharge (direction + yards + + // optional box size), per ton the global bulk one. + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + dto.rateUnit as Rate['rateUnit'] | undefined, + cargoKind, + cargoTypeId, + ); + const containerTypeId = + trigger === 'WITH_RETURN' || + isContainerHazardRate(trigger, rateUnit) || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — - // its yard pair already says where it runs. (Fuel is the exception: its - // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says - // nothing about the direction.) + // its yard pair already says where it runs. (Fuel and container hazard are + // the exception: their intercity lane is stored as DOMESTIC, since + // appliesTo = OTHER says nothing about the direction.) const tradeDirection = - DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) + this.isDirectedSurcharge(trigger, rateUnit) ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -637,6 +688,7 @@ export class RatesService { this.assertScopeCoherent({ appliesTo, trigger, + rateUnit, tradeDirection, intercityKind, cargoKind, @@ -646,6 +698,7 @@ export class RatesService { const { originYardId, destinationYardId } = await this.resolveYardScope({ appliesTo, trigger, + rateUnit, tradeDirection, originYardId: dto.originYardId, destinationYardId: dto.destinationYardId, @@ -662,13 +715,6 @@ export class RatesService { tradeDirection, isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); - const rateUnit = await this.resolveRateUnit( - appliesTo, - trigger, - dto.rateUnit as Rate['rateUnit'] | undefined, - cargoKind, - cargoTypeId, - ); const { minKm, maxKm } = this.resolveLastMileBand({ appliesTo, @@ -689,7 +735,7 @@ export class RatesService { await this.assertNoDuplicatePattern({ rateType, - ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + ...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }), shippingLineCompanyId, containerTypeId, cargoTypeId, @@ -826,15 +872,6 @@ export class RatesService { : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? (existing.containerTypeId ? 'CONTAINER' : 'BULK')); - const keepsContainerType = - !isSurcharge || - trigger === 'WITH_RETURN' || - (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); - const containerTypeId = !keepsContainerType - ? null - : dto.containerTypeId !== undefined - ? dto.containerTypeId - : existing.containerTypeId; const keepsCargoType = !isSurcharge || (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || @@ -845,8 +882,32 @@ export class RatesService { : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; + // Re-validate the unit against the (possibly changed) shape before the + // scope is settled: for hazard the unit decides whether the rate is the + // lane-sold container surcharge or the global bulk one. Overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + const rateUnit = await this.resolveRateUnit( + appliesTo, + trigger, + requestedUnit, + cargoKind, + cargoTypeId ?? null, + ); + updates.rateUnit = rateUnit; + + const keepsContainerType = + !isSurcharge || + trigger === 'WITH_RETURN' || + isContainerHazardRate(trigger, rateUnit) || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); + const containerTypeId = !keepsContainerType + ? null + : dto.containerTypeId !== undefined + ? dto.containerTypeId + : existing.containerTypeId; const tradeDirection = - DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) + this.isDirectedSurcharge(trigger, rateUnit) ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection @@ -868,6 +929,7 @@ export class RatesService { this.assertScopeCoherent({ appliesTo, trigger, + rateUnit, tradeDirection: updates.tradeDirection, intercityKind, cargoKind, @@ -879,6 +941,7 @@ export class RatesService { const yardScope = await this.resolveYardScope({ appliesTo, trigger, + rateUnit, tradeDirection: updates.tradeDirection, originYardId: dto.originYardId !== undefined ? dto.originYardId : existing.originYardId, @@ -910,18 +973,6 @@ export class RatesService { }); updates.rateType = rateType; - // Re-validate the unit against the (possibly changed) shape; overweight is - // forced to PER_TON. - const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - const rateUnit = await this.resolveRateUnit( - appliesTo, - trigger, - requestedUnit, - cargoKind, - updates.cargoTypeId, - ); - updates.rateUnit = rateUnit; - const { minKm, maxKm } = this.resolveLastMileBand({ appliesTo, rateUnit, @@ -948,7 +999,7 @@ export class RatesService { // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ rateType, - ...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }), + ...(this.resolvesSingleRate(appliesTo, trigger, rateUnit) ? {} : { rateUnit }), shippingLineCompanyId, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, 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/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/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/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.module.ts b/apps/edr-freight-api/src/modules/train-crew/train-crew.module.ts index e242204c3..8e9d01da9 100644 --- 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 @@ -1,14 +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])], - providers: [TrainCrewService], - controllers: [TrainCrewController], - exports: [TrainCrewService], + 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-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index aa0540544..983736f5e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -289,6 +289,99 @@ export function bookingCloseCutoff( return new Date(departure.getTime() - offsetMinutes * 60_000); } +/** The schedule fields the close-offset reopen guard reads. */ +export interface CloseOffsetReopenSchedule { + status: string; + direction?: string | null; + windowPhase?: string | null; + bookingWindowStatus?: string | null; + scheduledDepartureDate?: Date | null; +} + +export interface CloseOffsetReopenCheck { + /** True when shortening the close offset is the one thing that reopens booking. */ + eligible: boolean; + /** Why the schedule is not eligible; null when it is. */ + reason: string | null; + /** Minutes before departure this schedule currently stops taking bookings. */ + offsetMinutes: number | null; + /** The cutoff that shut booking (departure − offset); null without an offset. */ + cutoffAt: Date | null; +} + +/** + * Is this schedule's booking shut ONLY because of its close offset? That is the + * one case staff may fix from the board by shortening the offset (3 days → 1 + * day, 2 hours, …) so the desk reopens before departure. Every other way a + * window ends stays closed: the train departed, it is full, it never had an + * offset (booking ran until departure), or the window is still mid-cycle. + * + * The last guard — "a cycle would fit before departure with no offset at all" — + * is what makes the offset the ONLY problem: when the desk's next opening lands + * after the train leaves, no offset change can help. + */ +export function closeOffsetReopenCheck( + schedule: CloseOffsetReopenSchedule, + cfg: { + importCloseOffsetMinutes?: number | null; + exportCloseOffsetMinutes?: number | null; + windowOpenHour: number; + windowCloseHour: number; + }, + now: Date, +): CloseOffsetReopenCheck { + const departure = schedule.scheduledDepartureDate ?? null; + const offsetRaw = + schedule.direction === 'EXPORT' + ? cfg.exportCloseOffsetMinutes + : cfg.importCloseOffsetMinutes; + const offsetMinutes = offsetRaw != null && offsetRaw > 0 ? offsetRaw : null; + const cutoffAt = + departure && offsetMinutes != null + ? bookingCloseCutoff(departure, schedule.direction, cfg) + : null; + const no = (reason: string): CloseOffsetReopenCheck => ({ + eligible: false, + reason, + offsetMinutes, + cutoffAt, + }); + + if (schedule.status !== 'DRAFT' && schedule.status !== 'SCHEDULED') { + return no(`A ${schedule.status.toLowerCase()} train cannot reopen booking.`); + } + if (!departure || departure.getTime() <= now.getTime()) { + return no('This train has already departed (or has no departure date).'); + } + if (offsetMinutes == null) { + return no( + 'This train has no close offset — booking ran until departure, so there is nothing to shorten.', + ); + } + if (schedule.windowPhase !== 'DONE') { + return no( + schedule.windowPhase == null + ? 'This train does not run a managed booking window.' + : `Booking is not closed yet — the window is in its ${schedule.windowPhase} phase.`, + ); + } + if (schedule.bookingWindowStatus === 'FULL') { + return no( + 'Booking closed because the train is full, not because of the close offset.', + ); + } + const hours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + if (nextCycleOpensAt(now, hours, departure) == null) { + return no( + 'The desk would not reopen before departure even with no close offset — the offset is not what is blocking booking.', + ); + } + return { eligible: true, reason: null, offsetMinutes, cutoffAt }; +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; 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..738a29b11 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 @@ -66,6 +66,7 @@ import { AvailableDaysQueryDto } from "../dto/available-days-query.dto"; import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto"; import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; +import { ReduceScheduleCloseOffsetDto } from "../dto/reduce-schedule-close-offset.dto"; import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto"; import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto"; @@ -871,7 +872,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, @@ -985,6 +986,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/close-offset") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Shorten the booking-close offset of a schedule whose booking shut only because of that offset, so its window reopens before departure", + }) + async reduceScheduleCloseOffset( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: ReduceScheduleCloseOffsetDto, + ) { + await this.trainSchedulingService.reduceScheduleCloseOffset(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Patch("schedules/:id/schedule-date") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts new file mode 100644 index 000000000..b638a6b3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/reduce-schedule-close-offset.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, Min } from 'class-validator'; + +/** + * Shorten the booking-close offset of ONE schedule whose booking shut only + * because of that offset (staff action on the ops board). The value replaces the + * schedule's frozen offset; 0 means "close at departure". + */ +export class ReduceScheduleCloseOffsetDto { + @ApiProperty({ + example: 120, + description: + 'New minutes-before-departure at which booking closes. Must be shorter than the current offset; 0 = close at departure.', + }) + @Type(() => Number) + @IsInt() + @Min(0) + closeOffsetMinutes!: number; +} 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/schedule-close-offset.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts new file mode 100644 index 000000000..6deb0679d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-close-offset.spec.ts @@ -0,0 +1,266 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { closeOffsetReopenCheck } from './batch-window.util'; +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * "Reopen booking by shortening the close offset": a train whose booking shut + * ONLY because of its close offset (3 days → cut to 1 day / 2 hours) gets its + * window re-armed. Every other closed state is refused. Pure guard first, then + * the service against stub repositories. + */ +describe('close-offset reopen', () => { + // Wednesday 2026-09-09 10:00 EAT (07:00Z). A 3-day offset closes Sunday 10:00 EAT. + const DEPARTURE = new Date('2026-09-09T07:00:00.000Z'); + // Monday 2026-09-07 09:00 EAT — inside the desk day, past the 3-day cutoff. + const NOW = new Date('2026-09-07T06:00:00.000Z'); + const THREE_DAYS = 3 * 1_440; + + const cfg = { + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowCloseHour: 17, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + exportPaymentWindowMinutes: 60, + importCloseOffsetMinutes: THREE_DAYS, + exportCloseOffsetMinutes: THREE_DAYS, + }; + + const closedByOffset = (over: Record = {}) => ({ + id: 'S1', + reference: 'S-2026-00001', + status: 'SCHEDULED', + direction: 'IMPORT', + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + scheduledDepartureDate: DEPARTURE, + originStationId: 'Y-ADD', + destinationStationId: 'Y-DJ', + ruleWindowOpenHour: 8, + ruleWindowCloseHour: 17, + ruleWindowDurationHours: 3, + ruleImportWindowLeadDays: 3, + ruleExportBookingLeadHours: 24, + ruleImportCloseOffsetMinutes: THREE_DAYS, + ruleExportCloseOffsetMinutes: THREE_DAYS, + ...over, + }); + + describe('closeOffsetReopenCheck', () => { + it('is eligible when DONE, not full, departure ahead, and an offset shut it', () => { + const check = closeOffsetReopenCheck(closedByOffset(), cfg, NOW); + expect(check.eligible).toBe(true); + expect(check.offsetMinutes).toBe(THREE_DAYS); + expect(check.cutoffAt?.toISOString()).toBe('2026-09-06T07:00:00.000Z'); + }); + + it.each([ + ['dispatched train', { status: 'DISPATCHED' }, /dispatched/i], + ['already departed', { scheduledDepartureDate: new Date('2026-09-01T07:00:00.000Z') }, /departed/i], + ['full train', { bookingWindowStatus: 'FULL' }, /full/i], + ['window still open', { windowPhase: 'OPEN' }, /not closed yet/i], + ['legacy row with no window', { windowPhase: null }, /managed booking window/i], + ])('refuses a %s', (_label, over, reason) => { + const check = closeOffsetReopenCheck(closedByOffset(over), cfg, NOW); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(reason); + }); + + it('refuses when the schedule never had an offset (booking ran to departure)', () => { + const check = closeOffsetReopenCheck( + closedByOffset(), + { ...cfg, importCloseOffsetMinutes: null }, + NOW, + ); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(/no close offset/i); + expect(check.cutoffAt).toBeNull(); + }); + + it('refuses when the desk could not reopen before departure even with no offset', () => { + // Tuesday 18:00 EAT, desk 8–17: next opening is Wednesday 08:00, but the + // train departs Wednesday 07:00 EAT — the offset is not the blocker. + const lateNow = new Date('2026-09-08T15:00:00.000Z'); + const earlyDeparture = new Date('2026-09-09T04:00:00.000Z'); + const check = closeOffsetReopenCheck( + closedByOffset({ scheduledDepartureDate: earlyDeparture }), + cfg, + lateNow, + ); + expect(check.eligible).toBe(false); + expect(check.reason).toMatch(/would not reopen before departure/i); + }); + + it('reads the export offset for an EXPORT schedule', () => { + const check = closeOffsetReopenCheck( + closedByOffset({ direction: 'EXPORT' }), + { ...cfg, importCloseOffsetMinutes: null, exportCloseOffsetMinutes: 120 }, + NOW, + ); + expect(check.eligible).toBe(true); + expect(check.offsetMinutes).toBe(120); + }); + }); + + describe('TrainSchedulingService.reduceScheduleCloseOffset', () => { + type Fixture = { + schedule: Record | null; + siblings?: Record[]; + now?: Date; + }; + + const makeService = (fx: Fixture) => { + const updates: Array<{ id: string; patch: Record }> = []; + const repo = { + update: jest.fn().mockImplementation(async (id: string, patch: Record) => { + updates.push({ id, patch }); + }), + }; + const siblingsQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue(fx.siblings ?? []), + }; + const dataSource = { + getRepository: jest.fn().mockReturnValue(repo), + manager: { + getRepository: jest + .fn() + .mockReturnValue({ createQueryBuilder: () => siblingsQb }), + }, + }; + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + const emitted: string[] = []; + Object.assign(service, { + dataSource, + trainSchedulesRepository: { + findById: jest.fn().mockResolvedValue(fx.schedule), + }, + getWindowConfig: jest.fn().mockResolvedValue(cfg), + emitWindowState: jest.fn().mockImplementation(async (id: string) => { + emitted.push(id); + }), + logger: { log: jest.fn(), warn: jest.fn() }, + }); + jest.useFakeTimers().setSystemTime(fx.now ?? NOW); + return { service, updates, emitted }; + }; + + afterEach(() => jest.useRealTimers()); + + it('404s on an unknown schedule', async () => { + const { service } = makeService({ schedule: null }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('refuses a train whose window is not shut by its offset', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset({ bookingWindowStatus: 'FULL' }), + }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 60 }), + ).rejects.toThrow(/full/i); + expect(updates).toHaveLength(0); + }); + + it('refuses an offset that is not shorter than the current one', async () => { + const { service, updates } = makeService({ schedule: closedByOffset() }); + await expect( + service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: THREE_DAYS }), + ).rejects.toThrow(/shorter than the current 3 days/i); + expect(updates).toHaveLength(0); + }); + + it('refuses an offset whose new cutoff is still before the next desk opening', async () => { + // 2 days before departure = Monday 10:00 EAT; now is Monday 09:00 so a + // cycle fits… but 2 days 1 hour (Mon 09:00) does not. + const { service } = makeService({ schedule: closedByOffset() }); + await expect( + service.reduceScheduleCloseOffset('S1', { + closeOffsetMinutes: 2 * 1_440 + 60, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('shortens the offset, re-arms the window at now (desk open) and caps it at the new cutoff', async () => { + const { service, updates, emitted } = makeService({ schedule: closedByOffset() }); + + // 1 day before departure → new cutoff Tuesday 10:00 EAT. + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); + + expect(updates).toHaveLength(1); + const [{ id, patch }] = updates; + expect(id).toBe('S1'); + expect(patch).toMatchObject({ + ruleImportCloseOffsetMinutes: 1_440, + windowRuleCustom: true, + windowPhase: 'PRE_WINDOW', + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + // Desk is open at 09:00 → reopens now; 3h cycle → 12:00 EAT (09:00Z). + expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-07T09:00:00.000Z'); + expect(emitted).toEqual(['S1']); + }); + + it('stores 0 as null (booking runs to departure) and caps the cycle at departure', async () => { + // Tuesday 16:00 EAT: 3h cycle would run past the 17:00 desk close. + const tueAfternoon = new Date('2026-09-08T13:00:00.000Z'); + const { service, updates } = makeService({ + schedule: closedByOffset(), + now: tueAfternoon, + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 0 }); + const [{ patch }] = updates; + expect(patch.ruleImportCloseOffsetMinutes).toBeNull(); + expect((patch.windowOpensAt as Date).toISOString()).toBe(tueAfternoon.toISOString()); + // Desk close (17:00 EAT = 14:00Z) ends the cycle before departure. + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-08T14:00:00.000Z'); + }); + + it('reopens route+day siblings shut by the same offset and leaves the rest alone', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset(), + siblings: [ + closedByOffset({ id: 'S2' }), + // Already full — booking did not close because of the offset. + closedByOffset({ id: 'S3', bookingWindowStatus: 'FULL' }), + // Still mid-cycle — must keep the state its customers see. + closedByOffset({ id: 'S4', windowPhase: 'PAYMENT' }), + ], + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 1_440 }); + expect(updates.map((u) => u.id)).toEqual(['S1', 'S2']); + expect(updates[1].patch).toMatchObject({ + ruleImportCloseOffsetMinutes: 1_440, + windowPhase: 'PRE_WINDOW', + }); + }); + + it('an EXPORT reopen is a single FCFS window to the new cutoff and touches no sibling', async () => { + const { service, updates } = makeService({ + schedule: closedByOffset({ direction: 'EXPORT' }), + siblings: [closedByOffset({ id: 'S2', direction: 'EXPORT' })], + }); + await service.reduceScheduleCloseOffset('S1', { closeOffsetMinutes: 120 }); + expect(updates).toHaveLength(1); + const [{ patch }] = updates; + expect(patch).toMatchObject({ + ruleExportCloseOffsetMinutes: 120, + windowPhase: 'PRE_WINDOW', + }); + expect((patch.windowOpensAt as Date).toISOString()).toBe(NOW.toISOString()); + // Departure 07:00Z − 2h. + expect((patch.windowClosesAt as Date).toISOString()).toBe('2026-09-09T05:00:00.000Z'); + }); + }); +}); 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..0cbe9c277 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, @@ -122,6 +112,7 @@ import { UploadImportDjiboutiDocumentDto, } from '../dto/import-djibouti-operation.dto'; import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; +import { ReduceScheduleCloseOffsetDto } from '../dto/reduce-schedule-close-offset.dto'; import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto'; import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto'; @@ -159,6 +150,7 @@ import { validateMixedTrainLimitsPerEdge, MAX_TEU_SLOTS_PER_WAGON, type ContainerPlacementInput, + type ContainerPlacementRules, type WagonPlanSlot, } from '../utils/wagon-plan.util'; import { @@ -189,6 +181,8 @@ import { trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, + MAX_FALLBACK_LENGTH, + MAX_FALLBACK_WEIGHT, WagonTypeDimensions, } from '../train-capacity.util'; import { @@ -204,12 +198,15 @@ import { orderConsistWagons } from '../consist-order.util'; import { bookingCloseCutoff, clampCloseToOfficeHours, + closeOffsetReopenCheck, computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, eatDay, eatDayToUtc, + nextCycleOpensAt, shiftEatDay, + type OfficeHours, } from '../batch-window.util'; import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { BookingJourneyService } from '../booking-journey.service'; @@ -246,6 +243,19 @@ const HANDLING_FIELDS = [ type HandlingField = (typeof HANDLING_FIELDS)[number][0]; +/** "3 days" / "2 hours" / "45 minutes" for an error message. */ +function describeMinutes(minutes: number): string { + if (minutes % 1_440 === 0) { + const d = minutes / 1_440; + return `${d} day${d === 1 ? '' : 's'}`; + } + if (minutes % 60 === 0) { + const h = minutes / 60; + return `${h} hour${h === 1 ? '' : 's'}`; + } + return `${minutes} minute${minutes === 1 ? '' : 's'}`; +} + /** Drops the keys a partial override left undefined, so `...` merges keep the base value. */ function pickDefined(source: T): Partial { return Object.fromEntries( @@ -273,6 +283,16 @@ function windowRuleSnapshot(cfg: BookingWindowConfig) { }; } +/** Wire shape of a close-offset reopen check (dates as ISO strings). */ +function toCloseOffsetReopenInfo(check: ReturnType) { + return { + eligible: check.eligible, + reason: check.reason, + offsetMinutes: check.offsetMinutes, + cutoffAt: check.cutoffAt ? check.cutoffAt.toISOString() : null, + }; +} + /** * The booking-window config a specific schedule runs under: its frozen rule * snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live @@ -378,13 +398,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 { @@ -411,6 +431,12 @@ interface BookingWindowRow { route_stations: string[] | null; } +/** + * Dispatch crew gate (ITLMS Rolling Stock §1.2). Temporarily disabled so a + * train can depart with no crew assigned; set back to true to enforce. + */ +const ENFORCE_CREW_GATE_ON_DISPATCH = false; + @Injectable() export class TrainSchedulingService { private readonly logger = new Logger(TrainSchedulingService.name); @@ -446,9 +472,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 +836,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; @@ -1030,6 +1057,152 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Shorten the booking-close offset of ONE schedule whose booking shut ONLY + * because of that offset, and re-arm its window so the desk reopens. A 3-day + * offset that closed booking with the train still days away can be cut to a + * day or a couple of hours; the window then opens at the next desk opening + * (now, if the desk is open) and runs its normal cycles until the new cutoff. + * + * Refused for every other kind of closed window (departed, full, no offset, + * still mid-cycle) — see `closeOffsetReopenCheck`. The new offset must be + * shorter than the current one and must leave room for a cycle before the + * new cutoff. The offset is frozen onto the schedule (the global value is + * untouched) and the row is marked custom so a later global-rules save does + * not re-stamp it. + * + * IMPORT/DOMESTIC: the same shorter offset is applied to every route+day + * sibling that is likewise shut only by its offset, so the group keeps its + * single shared timeline (each capped at its own new cutoff). EXPORT windows + * are per-train, so an export change touches only this schedule. + */ + async reduceScheduleCloseOffset( + id: string, + dto: ReduceScheduleCloseOffsetDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + const now = new Date(); + const liveCfg = await this.getWindowConfig(); + const cfg = effectiveWindowConfig(schedule, liveCfg); + const check = closeOffsetReopenCheck(schedule, cfg, now); + if (!check.eligible || check.offsetMinutes == null) { + throw new BadRequestException( + check.reason ?? 'This schedule cannot reopen by shortening its close offset.', + ); + } + + const newOffset = dto.closeOffsetMinutes; + if (newOffset >= check.offsetMinutes) { + throw new BadRequestException( + `The new close offset must be shorter than the current ${describeMinutes( + check.offsetMinutes, + )} before departure.`, + ); + } + + const isExport = schedule.direction === 'EXPORT'; + // 0 is stored as null so "no offset" keeps its single canonical value. + const offsetPatch = isExport + ? { ruleExportCloseOffsetMinutes: newOffset || null } + : { ruleImportCloseOffsetMinutes: newOffset || null }; + const merged: BookingWindowConfig = { + ...cfg, + ...(isExport + ? { exportCloseOffsetMinutes: newOffset || null } + : { importCloseOffsetMinutes: newOffset || null }), + }; + const hours: OfficeHours = { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }; + const cutoff = bookingCloseCutoff( + schedule.scheduledDepartureDate, + schedule.direction, + merged, + ); + // The desk reopens at the next office-hours opening (now, when it is open), + // exactly as a reopen cycle would — and only if that lands before the cutoff. + const opensAt = nextCycleOpensAt(now, hours, cutoff); + if (opensAt == null) { + throw new BadRequestException( + 'Even with this offset the desk would not reopen before booking closes again ' + + `(new cutoff ${cutoff.toISOString()}) — shorten the offset further.`, + ); + } + let closesAt: Date; + if (isExport) { + // Export runs one FCFS window: from the reopen until the cutoff. + closesAt = cutoff; + } else { + closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, hours); + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; + } + + const cap = (d: Date, bound: Date): Date => + d.getTime() > bound.getTime() ? bound : d; + const targets: Array<{ id: string; cutoff: Date }> = [{ id, cutoff }]; + if (!isExport) { + const siblings = await this.findGroupSiblings( + this.dataSource.manager, + schedule.originStationId, + schedule.destinationStationId, + schedule.scheduledDepartureDate, + id, + ); + for (const sib of siblings) { + const sibCfg = effectiveWindowConfig(sib, liveCfg); + const sibCheck = closeOffsetReopenCheck(sib, sibCfg, now); + // Only a sibling that is ALSO shut purely by an offset longer than the + // new one joins in; anything else keeps the state its customers saw. + if ( + !sibCheck.eligible || + sibCheck.offsetMinutes == null || + sibCheck.offsetMinutes <= newOffset || + !sib.scheduledDepartureDate + ) { + continue; + } + const sibCutoff = bookingCloseCutoff(sib.scheduledDepartureDate, sib.direction, { + ...sibCfg, + importCloseOffsetMinutes: newOffset || null, + }); + if (opensAt.getTime() >= sibCutoff.getTime()) continue; + targets.push({ id: sib.id, cutoff: sibCutoff }); + } + } + + const repo = this.dataSource.getRepository(TrainSchedule); + for (const t of targets) { + await repo.update(t.id, { + ...offsetPatch, + // Staff-set — exempt from the global re-stamp. + windowRuleCustom: true, + // Back to PRE_WINDOW: the window tick opens it at windowOpensAt and runs + // the normal cycle from there (bookingWindowStatus flips OPEN then). + windowPhase: 'PRE_WINDOW', + windowOpensAt: cap(opensAt, t.cutoff), + windowClosesAt: cap(closesAt, t.cutoff), + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + } + this.logger.log( + `Close offset of schedule ${schedule.reference ?? id} shortened ` + + `${check.offsetMinutes} → ${newOffset} min before departure` + + ` (+${targets.length - 1} route+day sibling(s)) — booking reopens ` + + `${opensAt.toISOString()}, closes ${closesAt.toISOString()}`, + ); + for (const t of targets) void this.emitWindowState(t.id); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Correct a departure's operational run identifiers — the train number and * voyage number yards and customs quote. @@ -3027,6 +3200,15 @@ 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. + // TODO(crew-gate): temporarily off so trains can dispatch with no crew + // assigned. Flip ENFORCE_CREW_GATE_ON_DISPATCH once crew rostering is in use. + if (ENFORCE_CREW_GATE_ON_DISPATCH) { + 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 +3952,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 +3969,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 +4011,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, @@ -6367,8 +6496,11 @@ export class TrainSchedulingService { skip, take, }); + // Live window config: each row's frozen rule overlays it to decide whether + // the "shorten close offset" action applies (see closeOffsetReopenCheck). + const liveCfg = await this.getWindowConfig(); return { - items: schedules.map((s) => this.mapScheduleListItem(s)), + items: schedules.map((s) => this.mapScheduleListItem(s, liveCfg)), meta: buildPaginationMeta(total, page, pageSize), }; } @@ -6708,11 +6840,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 +6872,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 +7024,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 +7098,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 +7140,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, }; } @@ -8869,7 +9043,11 @@ export class TrainSchedulingService { throw new ConflictException('Could not allocate a unique schedule reference'); } - private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { + private mapScheduleListItem( + schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule, + /** Live window config; when given, the row carries its close-offset reopen state. */ + liveCfg?: BookingWindowConfig, + ) { // Wagon figures must match the detail page's wagon plan (WagonPlanGrid) — // see computeScheduleWagonUsage for why the stored counter cannot be used. const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } = @@ -8933,6 +9111,18 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + windowPhase: schedule.windowPhase ?? null, + // Whether booking shut ONLY because of the close offset — the board offers + // "shorten close offset" on exactly these rows. + closeOffsetReopen: liveCfg + ? toCloseOffsetReopenInfo( + closeOffsetReopenCheck( + schedule, + effectiveWindowConfig(schedule, liveCfg), + new Date(), + ), + ) + : null, cancellationReason: schedule.cancellationReason ?? null, cancelledAt: schedule.cancelledAt ?? null, maxWagons: schedule.maxWagons ?? 0, @@ -10977,6 +11167,14 @@ export class TrainSchedulingService { // settings" editor on the ops board (prefill + save one schedule's // override). docReview/payment are not snapshotted per schedule (only their // sum, as the frozen reopen gap), so the editor prefills them from live config. + // Shut only by its close offset? Drives the "shorten close offset" action. + closeOffsetReopen: toCloseOffsetReopenInfo( + closeOffsetReopenCheck( + schedule, + effectiveWindowConfig(schedule, windowCfg), + new Date(), + ), + ), windowRule: { windowOpenHour: schedule.ruleWindowOpenHour ?? null, windowCloseHour: schedule.ruleWindowCloseHour ?? null, @@ -10986,6 +11184,12 @@ export class TrainSchedulingService { : null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, + // The offsets this train actually runs under (its frozen snapshot, or + // the live global for a legacy row) — null = booking runs to departure. + importCloseOffsetMinutes: + effectiveWindowConfig(schedule, windowCfg).importCloseOffsetMinutes ?? null, + exportCloseOffsetMinutes: + effectiveWindowConfig(schedule, windowCfg).exportCloseOffsetMinutes ?? null, docReviewMinutes: windowCfg.docReviewMinutes, // Editor prefill: this schedule's own override when staff set one, // else the live global for the schedule's direction (import/export @@ -13049,3 +13253,4 @@ export class TrainSchedulingService { return fresh ?? schedule; } } +// \ No newline at end of file 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/transit-agents/dto/create-transit-agent.dto.ts b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts index d035c53f8..80cabe9ff 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/dto/create-transit-agent.dto.ts @@ -2,14 +2,15 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; import { Transform } from "class-transformer"; import { IsBoolean, - IsDateString, IsEmail, + IsEnum, IsOptional, IsString, MaxLength, } from "class-validator"; import { IsValidPhone } from "../../../common/validators/is-phone-number.validator"; +import { TransitAgentCountry } from "../entities/transit-agent.entity"; const toBoolean = ({ value }: { value: unknown }) => { if (typeof value === "boolean") return value; @@ -24,13 +25,18 @@ export class CreateTransitAgentDto { @MaxLength(150) name!: string; - @ApiProperty({ example: "2026-01-01" }) - @IsDateString() - validFrom!: string; - - @ApiProperty({ example: "2026-12-31" }) - @IsDateString() - validTo!: string; + /** + * Defaults to Djibouti, which is what the whole roster was before Ethiopian + * agents were added. Only `ET` agents are offered to a freight forwarder + * picking itself during onboarding. + */ + @ApiPropertyOptional({ + enum: TransitAgentCountry, + default: TransitAgentCountry.Djibouti, + }) + @IsOptional() + @IsEnum(TransitAgentCountry) + country?: TransitAgentCountry; @ApiPropertyOptional({ default: true }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts index 433b6587f..b5059d106 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/entities/transit-agent.entity.ts @@ -2,22 +2,38 @@ import { BaseEntity } from "@edr/api-common"; import { Column, Entity, Index } from "typeorm"; /** - * Djibouti transit officer GL Djibouti may assign against a shipment's - * transit-assignee handshake. Admin-managed so the roster and each officer's - * validity window arrive without a code change; `isActive` is the manual - * suspend/reactivate switch, independent of the validity window. + * Where the agent is licensed. The roster started Djibouti-only (the officers + * GL Djibouti assigns), so that is the column default. An Ethiopian transit + * agent is the same business as a freight forwarder — a forwarder onboarding on + * the portal picks itself from the `ET` entries (`Company.transitAgentId`). + */ +export enum TransitAgentCountry { + Ethiopia = "ET", + Djibouti = "DJ", +} + +/** + * Transit officer GL Djibouti may assign against a shipment's transit-assignee + * handshake, and — for the Ethiopian entries — the roster a freight forwarder + * registers itself against. Admin-managed so the roster arrives without a code + * change; `isActive` is the manual suspend/reactivate switch and the only + * thing that decides whether an agent may be assigned or picked. */ @Entity({ schema: "freight", name: "transit_agents" }) @Index(["isActive"]) +@Index(["country"]) export class TransitAgent extends BaseEntity { @Column({ name: "name", type: "varchar", length: 150 }) name!: string; - @Column({ name: "valid_from", type: "date" }) - validFrom!: string; - - @Column({ name: "valid_to", type: "date" }) - validTo!: string; + @Column({ + name: "country", + type: "varchar", + length: 2, + enum: TransitAgentCountry, + default: TransitAgentCountry.Djibouti, + }) + country!: TransitAgentCountry; @Column({ name: "is_active", type: "boolean", default: true }) isActive!: boolean; diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts index d254f7982..16b1fcc41 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.controller.ts @@ -13,6 +13,7 @@ import { } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { PortalCustomer } from "../../common/booking-guards"; import { RuleEngineCreate, RuleEngineDelete, @@ -50,16 +51,29 @@ export class TransitAgentsController { }); } - /** Active + currently valid officers — the transit-assignee assignment dropdown. */ + /** Active officers — the transit-assignee assignment dropdown. */ @Get("assignable") @RuleEngineView("transit-agents") - @ApiOperation({ - summary: "List transit agents assignable right now (active and in-window)", - }) + @ApiOperation({ summary: "List active transit agents (assignable)" }) findAssignable() { return this.transitAgentsService.findAssignable(); } + /** + * The Ethiopian roster, id + name only, for a customer registering as a + * freight forwarder to pick itself from. Declared before `:id` so the + * literal path is not swallowed by the UUID route. + */ + @Get("forwarder-options") + @PortalCustomer() + @ApiOperation({ + summary: + "List active Ethiopian transit agents (id + name) a freight forwarder can register as", + }) + findForwarderOptions() { + return this.transitAgentsService.findForwarderOptions(); + } + @Get(":id") @RuleEngineView("transit-agents") @ApiOperation({ summary: "Get a transit agent by ID" }) diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts index 5ae4191eb..d2cb4772c 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.repository.ts @@ -1,14 +1,15 @@ import { BaseRepository } from "@edr/api-common"; import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { - EntityManager, - LessThanOrEqual, - MoreThanOrEqual, - Repository, -} from "typeorm"; +import { EntityManager, Repository } from "typeorm"; -import { TransitAgent } from "./entities/transit-agent.entity"; +import { + TransitAgent, + TransitAgentCountry, +} from "./entities/transit-agent.entity"; + +/** What a forwarder picking itself from the roster needs: the id and a label. */ +export type ForwarderTransitAgentOption = Pick; @Injectable() export class TransitAgentsRepository extends BaseRepository { @@ -19,14 +20,23 @@ export class TransitAgentsRepository extends BaseRepository { super(repository); } - /** Active AND currently inside its validity window (today's date, server-side). */ - findAssignable(today: string): Promise { + /** Every active agent — the GL assignment dropdown. */ + findAssignable(): Promise { return this.repository.find({ - where: { - isActive: true, - validFrom: LessThanOrEqual(today), - validTo: MoreThanOrEqual(today), - }, + where: { isActive: true }, + order: { name: "ASC" }, + }); + } + + /** + * The Ethiopian roster a freight forwarder registers itself against, as + * `{ id, name }` only — this is served to customers, who have no business + * seeing another agent's email or phone. Suspended agents are left out. + */ + findForwarderOptions(): Promise { + return this.repository.find({ + select: { id: true, name: true }, + where: { isActive: true, country: TransitAgentCountry.Ethiopia }, order: { name: "ASC" }, }); } diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts index 3aa5e6e02..fed1fb141 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.spec.ts @@ -5,11 +5,12 @@ import { } from "@tria-plc/api-common/utils/enums/user.enum"; import { ResetChannel } from "../auth/dto/forgot-password.dto"; +import { TransitAgentCountry } from "./entities/transit-agent.entity"; import { TransitAgentsService } from "./transit-agents.service"; /** - * The account half of a transit agent. The roster half (validity window, - * assignability) predates this and is untouched — what these lock is that + * The account half of a transit agent. The roster half (assignability) + * predates this and is untouched — what these lock is that * adding a login did not make an account MANDATORY, since production is full of * roster-only agents that must keep working. */ @@ -36,8 +37,6 @@ describe("TransitAgentsService accounts", () => { const base = { name: "Ahmed Bourhan", - validFrom: "2026-01-01", - validTo: "2026-12-31", }; beforeEach(() => { @@ -343,4 +342,102 @@ describe("TransitAgentsService accounts", () => { expect(dataSource.transaction).not.toHaveBeenCalled(); }); }); + + /** + * An Ethiopian transit agent IS a freight forwarder, which signs up on the + * portal with its own email and phone. Nothing minted from this side may + * claim those first. + */ + describe("Ethiopian agents carry no contact details or account", () => { + const ethiopian = { ...base, country: TransitAgentCountry.Ethiopia }; + + it("refuses an email on create", async () => { + await expect( + service.createWithInvite({ ...ethiopian, email: "ff@example.et" }), + ).rejects.toThrow(BadRequestException); + expect(repo.create).not.toHaveBeenCalled(); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses a phone number on create", async () => { + await expect( + service.createWithInvite({ ...ethiopian, phoneNumber: "+251911223344" }), + ).rejects.toThrow(BadRequestException); + expect(repo.create).not.toHaveBeenCalled(); + }); + + it("creates the roster entry with neither", async () => { + const { agent } = await service.createWithInvite(ethiopian); + + expect(agent.country).toBe(TransitAgentCountry.Ethiopia); + expect(agent.hasAccount).toBe(false); + }); + + it("refuses to invite one", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...ethiopian, + isActive: true, + userId: null, + }); + + await expect( + service.invite("ta-1", { email: "ff@example.et" }), + ).rejects.toThrow(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("refuses an email on update", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...ethiopian, + isActive: true, + userId: null, + }); + + await expect( + service.update("ta-1", { email: "ff@example.et" }), + ).rejects.toThrow(BadRequestException); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it("clears the contact details when a Djiboutian row is switched", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + country: TransitAgentCountry.Djibouti, + isActive: true, + userId: null, + email: "a@transit.dj", + phoneNumber: "+25377834567", + }); + + await service.update("ta-1", { country: TransitAgentCountry.Ethiopia }); + + expect(repo.update).toHaveBeenCalledWith( + "ta-1", + expect.objectContaining({ + country: TransitAgentCountry.Ethiopia, + email: null, + phoneNumber: null, + }), + ); + }); + + it("refuses the switch when the row already has a portal account", async () => { + repo.findById.mockResolvedValue({ + id: "ta-1", + ...base, + country: TransitAgentCountry.Djibouti, + isActive: true, + userId: "user-1", + email: "a@transit.dj", + }); + + await expect( + service.update("ta-1", { country: TransitAgentCountry.Ethiopia }), + ).rejects.toThrow(BadRequestException); + expect(repo.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts index b54f1fddb..c99296e85 100644 --- a/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts +++ b/apps/edr-freight-api/src/modules/transit-agents/transit-agents.service.ts @@ -26,13 +26,16 @@ import { isDomesticPhone } from "../otp/otp.service"; import { CreateTransitAgentDto } from "./dto/create-transit-agent.dto"; import { InviteTransitAgentDto } from "./dto/invite-transit-agent.dto"; import { UpdateTransitAgentDto } from "./dto/update-transit-agent.dto"; -import { TransitAgent } from "./entities/transit-agent.entity"; -import { TransitAgentsRepository } from "./transit-agents.repository"; - -export type TransitAgentValidityStatus = "VALID" | "NOT_STARTED" | "EXPIRED"; +import { + TransitAgent, + TransitAgentCountry, +} from "./entities/transit-agent.entity"; +import { + ForwarderTransitAgentOption, + TransitAgentsRepository, +} from "./transit-agents.repository"; export type TransitAgentView = TransitAgent & { - validityStatus: TransitAgentValidityStatus; /** True once an IAM account backs this agent — i.e. it can sign in. */ hasAccount: boolean; }; @@ -52,24 +55,9 @@ type TransitAgentListFilter = { sortOrder?: string; }; -/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */ -function todayISODate(): string { - return new Date().toISOString().slice(0, 10); -} - -function validityStatus( - agent: Pick, -): TransitAgentValidityStatus { - const today = todayISODate(); - if (today < agent.validFrom) return "NOT_STARTED"; - if (today > agent.validTo) return "EXPIRED"; - return "VALID"; -} - -function withValidityStatus(agent: TransitAgent): TransitAgentView { +function toView(agent: TransitAgent): TransitAgentView { return { ...agent, - validityStatus: validityStatus(agent), hasAccount: Boolean(agent.userId), }; } @@ -92,9 +80,7 @@ export class TransitAgentsService { }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 500; - const sortBy = ["name", "validFrom", "validTo", "isActive"].includes( - filter.sortBy ?? "", - ) + const sortBy = ["name", "country", "isActive"].includes(filter.sortBy ?? "") ? (filter.sortBy as keyof TransitAgent) : "name"; const sortOrder = @@ -108,7 +94,7 @@ export class TransitAgentsService { }); return { - data: data.map(withValidityStatus), + data: data.map(toView), meta: { total, page, @@ -118,9 +104,14 @@ export class TransitAgentsService { }; } - /** Active and currently inside its validity window — the DJ assignment dropdown. */ + /** Every active agent — the DJ assignment dropdown. */ async findAssignable(): Promise { - return this.transitAgentsRepository.findAssignable(todayISODate()); + return this.transitAgentsRepository.findAssignable(); + } + + /** The Ethiopian roster a freight forwarder picks itself from at onboarding. */ + findForwarderOptions(): Promise { + return this.transitAgentsRepository.findForwarderOptions(); } async findById(id: string): Promise { @@ -128,10 +119,10 @@ export class TransitAgentsService { if (!agent) { throw new NotFoundException(`Transit agent ${id} not found`); } - return withValidityStatus(agent); + return toView(agent); } - /** Used by the assignment flow — rejects a suspended or out-of-window officer. */ + /** Used by the assignment flow — rejects a suspended officer. */ async getAssignable(id: string): Promise { const agent = await this.transitAgentsRepository.findById(id); if (!agent) { @@ -142,11 +133,6 @@ export class TransitAgentsService { `${agent.name} is suspended — pick another transit officer.`, ); } - if (validityStatus(agent) !== "VALID") { - throw new BadRequestException( - `${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`, - ); - } return agent; } @@ -221,6 +207,32 @@ export class TransitAgentsService { return { email, username, phoneNumber }; } + /** + * An Ethiopian transit agent never gets a portal account of its own. + * + * It IS a freight forwarder, and the forwarder signs up on the portal as a + * customer with its own email and phone — the same ones staff would type + * here. An IAM account minted from this side would then claim that email + * first, and the forwarder's own registration would fail with "already + * registered". So for `ET` the contact fields are refused outright, and the + * invite path is closed. + */ + private assertNoAccountForEthiopian( + country: TransitAgentCountry, + dto: { + email?: string | null; + phoneNumber?: string | null; + username?: string; + }, + ): void { + if (country !== TransitAgentCountry.Ethiopia) return; + if (dto.email || dto.phoneNumber || dto.username) { + throw new BadRequestException( + "An Ethiopian transit agent has no email, phone or portal account here — it registers itself on the portal as a freight forwarder with its own contact details.", + ); + } + } + /** * Create a transit agent. * @@ -237,24 +249,18 @@ export class TransitAgentsService { async createWithInvite( dto: CreateTransitAgentDto, ): Promise { - if (dto.validTo < dto.validFrom) { - throw new BadRequestException( - "Valid-to date must be on or after valid-from date.", - ); - } - const base = { name: dto.name.trim(), - validFrom: dto.validFrom, - validTo: dto.validTo, + country: dto.country ?? TransitAgentCountry.Djibouti, isActive: dto.isActive ?? true, }; + this.assertNoAccountForEthiopian(base.country, dto); if (!dto.email) { // Roster-only agent — no account, nothing to send. const agent = await this.transitAgentsRepository.create(base); return { - agent: withValidityStatus(agent), + agent: toView(agent), activationSentTo: null, activationChannel: null, }; @@ -286,7 +292,7 @@ export class TransitAgentsService { // valid without it. const activation = await this.sendActivationLink(agent); return { - agent: withValidityStatus(agent), + agent: toView(agent), activationSentTo: activation?.maskedTarget ?? null, activationChannel: activation?.channel ?? null, }; @@ -312,6 +318,7 @@ export class TransitAgentsService { "This transit agent already has a portal account — resend the activation link instead.", ); } + this.assertNoAccountForEthiopian(current.country, dto); const { email, username, phoneNumber } = await this.prepareAccountFields( dto, @@ -338,7 +345,7 @@ export class TransitAgentsService { const activation = await this.sendActivationLink(agent); return { - agent: withValidityStatus(agent), + agent: toView(agent), activationSentTo: activation?.maskedTarget ?? null, activationChannel: activation?.channel ?? null, }; @@ -436,13 +443,6 @@ export class TransitAgentsService { dto: UpdateTransitAgentDto, ): Promise { const current = await this.findById(id); - const nextValidFrom = dto.validFrom ?? current.validFrom; - const nextValidTo = dto.validTo ?? current.validTo; - if (nextValidTo < nextValidFrom) { - throw new BadRequestException( - "Valid-to date must be on or after valid-from date.", - ); - } // `username` only ever names an IAM account, and it is chosen once at // account creation. Accepting it here (PartialType inherits it from the @@ -450,6 +450,21 @@ export class TransitAgentsService { const { username: _ignoredUsername, email, phoneNumber, ...rest } = dto; const contact: Partial = {}; + const nextCountry = dto.country ?? current.country; + if (nextCountry === TransitAgentCountry.Ethiopia) { + this.assertNoAccountForEthiopian(nextCountry, { email, phoneNumber }); + if (current.userId) { + // The account already holds the email the forwarder would sign up + // with; there is no way to hand it back, so the row stays Djiboutian. + throw new BadRequestException( + `${current.name} already has a portal account, so it cannot become an Ethiopian transit agent — create a new Ethiopian entry instead.`, + ); + } + // Whatever contact details a Djiboutian row carried go with the switch, + // so the forwarder's own registration cannot collide with them. + contact.email = null; + contact.phoneNumber = null; + } if (email !== undefined) { const normalized = email.trim().toLowerCase(); if (await this.transitAgentsRepository.existsByEmail(normalized, id)) { @@ -483,7 +498,7 @@ export class TransitAgentsService { await this.syncIamContact(updated); } - return withValidityStatus(updated); + return toView(updated); } /** diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts index 44862e2ff..0c2b8d755 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Booking } from "../bookings/entities/booking.entity"; +import { ExternalProfile } from "../companies/entities/external-profile.entity"; import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesModule } from "../files/files.module"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; @@ -19,11 +20,14 @@ import { TransitAssignmentsService } from "./transit-assignments.service"; // Milestones and train schedules are read for the agent's dashboard // timings (declaration stamps, departure/arrival fallbacks) — entities // only, for the same reason as Booking. + // ExternalProfile: `/my` resolves a freight forwarder's portal user to the + // transit agent its company registered as — entity only, same reason. TypeOrmModule.forFeature([ TransitAssignment, Booking, ClearanceMilestone, TrainSchedule, + ExternalProfile, ]), FilesModule, TransitAgentsModule, diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts index 68e856eff..14018274b 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.spec.ts @@ -93,6 +93,7 @@ describe("TransitAssignmentsService", () => { files as never, milestones as never, trainSchedules as never, + { findOne: jest.fn().mockResolvedValue(null) } as never, // externalProfiles ); }); diff --git a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts index 80f8d6be9..e992c2b96 100644 --- a/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts +++ b/apps/edr-freight-api/src/modules/transit-assignments/transit-assignments.service.ts @@ -17,6 +17,11 @@ import { } from "@edr/types"; import { Booking } from "../bookings/entities/booking.entity"; +import { ExternalProfile } from "../companies/entities/external-profile.entity"; +import { + ProfileStatus, + ProfileType, +} from "../companies/entities/company-profile.entity"; import { ClearanceMilestone } from "../contracts/entities/clearance-milestone.entity"; import { FilesService } from "../files/files.service"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; @@ -170,6 +175,11 @@ export class TransitAssignmentsService { private readonly milestonesRepository: Repository, @InjectRepository(TrainSchedule) private readonly trainSchedulesRepository: Repository, + // The ExternalProfile ENTITY (not CompaniesModule) for the same reason as + // Booking above: `/my` only has to walk portal user → company → the + // transit agent that company registered itself as. + @InjectRepository(ExternalProfile) + private readonly externalProfilesRepository: Repository, ) {} private static minutesBetween( @@ -252,9 +262,52 @@ export class TransitAssignmentsService { // client-supplied id: an agent must not be able to read or edit another // agent's assignments by guessing one. - /** The transit agent this portal user signs in as. */ - private async requireAgentForUser(userId: string) { - const agent = await this.transitAgentsRepository.findByUserId(userId); + /** + * The transit agent this portal user acts as. + * + * Two kinds of account reach `/my`: a Djibouti transit officer, who signs in + * AS the agent (`transit_agents.user_id`), and a customer company that + * registered itself as an Ethiopian transit agent (`companies. + * transit_agent_id`) — under the transit agent role, the forwarder role, or + * both. It may look at its assigned bookings from the moment the role is + * requested — that is how it learns work is waiting — but may only act on + * them (`forWrite`) once a roster role has been approved. + */ + private async requireAgentForUser( + userId: string, + opts: { forWrite?: boolean } = {}, + ) { + const own = await this.transitAgentsRepository.findByUserId(userId); + if (own) return own; + + const profile = await this.externalProfilesRepository.findOne({ + where: { userId }, + relations: { company: { companyProfiles: true } }, + }); + const company = profile?.company; + if (!company?.transitAgentId) { + throw new ForbiddenException("This account is not a transit agent"); + } + // Either roster role will do — a plain transit agent or a forwarder. + const agentRoles = (company.companyProfiles ?? []).filter( + (p) => + p.type === ProfileType.transitAgent || + p.type === ProfileType.freightForwarder, + ); + if (agentRoles.length === 0) { + throw new ForbiddenException("This account is not a transit agent"); + } + if ( + opts.forWrite && + !agentRoles.some((p) => p.status === ProfileStatus.Active) + ) { + throw new ForbiddenException( + "Your transit agent role is not approved yet — you can view assigned bookings but not act on them until it is.", + ); + } + const agent = await this.transitAgentsRepository.findById( + company.transitAgentId, + ); if (!agent) { throw new ForbiddenException("This account is not a transit agent"); } @@ -640,8 +693,12 @@ export class TransitAssignmentsService { return { ...this.toView(assignment), files: await this.listFiles(id) }; } - /** Assert the assignment is this user's before any write reaches it. */ + /** + * Assert the assignment is this user's before any write reaches it — and + * that the user may write at all (an unapproved forwarder may only look). + */ private async assertMine(userId: string, id: string): Promise { + await this.requireAgentForUser(userId, { forWrite: true }); await this.findMineById(userId, id); } @@ -677,6 +734,7 @@ export class TransitAssignmentsService { id: string, input: { finish: boolean; note?: string }, ): Promise { + await this.requireAgentForUser(userId, { forWrite: true }); const current = await this.findMineById(userId, id); if (current.status === TransitAssignmentStatus.Finished) { throw new ForbiddenException("This assignment is already finished."); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts index 85417ea87..0a04b040b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/bulk-receive.dto.ts @@ -176,6 +176,49 @@ export class TruckEntranceDto { warehouseManagerName?: string; } +/** + * One physical truck at the gate, with the containers it is carrying. + * + * A customer whose containers arrive together sends several trucks, and each + * carries its own load: the plate, driver and boxes belong to that truck, not + * to the receive operation as a whole. Each truck is validated and given its + * own GRN batch exactly as a single-truck receive always was. + */ +export class ReceiveTruckDto { + /** + * The physical containers delivered by this truck: either one 40ft box or up + * to two 20ft boxes, the same physical limit a single-truck receive enforces. + */ + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container number must match ISO container format, e.g. ABCD1234567', + }) + containerNumbers?: string[]; + + @ApiProperty({ type: TruckEntranceDto }) + @ValidateNested() + @Type(() => TruckEntranceDto) + truckEntrance!: TruckEntranceDto; + + /** + * The bookings this truck delivers against. Defaults to the operation's + * `bookingIds` when omitted; container freight still requires exactly one, + * so its containers and documents stay separate per truck. + */ + @ApiPropertyOptional({ type: [String], format: 'uuid' }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @IsUUID('all', { each: true }) + bookingIds?: string[]; +} + /** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */ export class BulkReceiveDto { @ApiProperty({ enum: ['IMPORT', 'EXPORT'] }) @@ -200,9 +243,24 @@ export class BulkReceiveDto { @IsUUID('all', { each: true }) bookingIds!: string[]; + /** + * Several trucks arriving together, each with its own plate, driver and + * containers. When present this supersedes the single-truck + * `containerNumbers` / `truckEntrance` pair below, which is kept so existing + * callers (and single-truck arrivals) keep working unchanged. + */ + @ApiPropertyOptional({ type: [ReceiveTruckDto] }) + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => ReceiveTruckDto) + trucks?: ReceiveTruckDto[]; + /** * The physical containers delivered by this truck. Container exports are * received one truck at a time: either one 40ft box or up to two 20ft boxes. + * Ignored when `trucks` is given. */ @ApiPropertyOptional({ type: [String] }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts new file mode 100644 index 000000000..79034312f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/inspection-booking-cascade.spec.ts @@ -0,0 +1,72 @@ +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * A booking's cargo spans one inventory row per container, and a multi-truck + * arrival files a GRN batch per truck. Inspection is a judgement on the cargo, + * not on the row it happens to sit in, so ticking one row must pass every + * still-inspectable row of the same booking — otherwise a six-container + * booking stays half-inspected and never reaches Ready To Load. + * + * Only the DataSource is touched, so the instance is built off the prototype + * rather than stubbing all 20-odd collaborators. + */ +type Expand = (inventoryIds: string[], eligibleStatuses: string[]) => Promise; + +const ELIGIBLE = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; + +function makeExpand(rows: Array<{ id: string }>) { + const query = jest.fn().mockResolvedValue(rows); + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.dataSource = { query }; + const expand = ( + service as unknown as { expandInspectionToBooking: Expand } + ).expandInspectionToBooking.bind(service); + return { expand, query }; +} + +describe('bulkMarkInspected — booking cascade', () => { + it('pulls in the booking siblings of a selected row', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }, { id: 'inv-3' }]); + + await expect(expand(['inv-1'], ELIGIBLE)).resolves.toEqual([ + 'inv-1', + 'inv-2', + 'inv-3', + ]); + }); + + it('leads with the rows the operator actually ticked', async () => { + // The response the operator reads should open with their own selection, + // whatever order the database returned the siblings in. + const { expand } = makeExpand([{ id: 'inv-3' }, { id: 'inv-2' }, { id: 'inv-1' }]); + + const result = await expand(['inv-1'], ELIGIBLE); + + expect(result[0]).toBe('inv-1'); + expect(result.slice(1).sort()).toEqual(['inv-2', 'inv-3']); + }); + + it('never repeats a row when two siblings are both selected', async () => { + const { expand } = makeExpand([{ id: 'inv-1' }, { id: 'inv-2' }]); + + const result = await expand(['inv-1', 'inv-2'], ELIGIBLE); + + expect(result).toEqual(['inv-1', 'inv-2']); + expect(new Set(result).size).toBe(result.length); + }); + + it('passes the eligible statuses to the query rather than hard-coding them', async () => { + const { expand, query } = makeExpand([{ id: 'inv-1' }]); + + await expand(['inv-1'], ELIGIBLE); + + expect(query).toHaveBeenCalledWith(expect.any(String), [['inv-1'], ELIGIBLE]); + }); + + it('does not query at all for an empty selection', async () => { + const { expand, query } = makeExpand([]); + + await expect(expand([], ELIGIBLE)).resolves.toEqual([]); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts b/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts new file mode 100644 index 000000000..651ffd590 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/inspection-load-gate.spec.ts @@ -0,0 +1,146 @@ +import { BadRequestException } from '@nestjs/common'; + +import { WarehouseInspectionService } from './warehouse-inspection.service'; +import { WarehouseInventoryService } from './warehouse-inventory.service'; + +/** + * Cargo whose inspection failed or is under review must not travel. It becomes + * loadable only by being re-inspected and passed, and that reversal has to say + * why — the cargo was deliberately held, so its release is deliberate too. + * + * Only the collaborators each rule touches are stubbed; the instances are built + * off the prototype rather than wiring all 20-odd dependencies. + */ + +describe('load() — inspection gate', () => { + const loadWithInspection = (inspectionStatus: string | null) => { + const service = Object.create(WarehouseInventoryService.prototype) as Record; + service.findById = jest.fn().mockResolvedValue({ + id: 'inv-1', + status: 'READY_FOR_LOADING', + inspectionStatus, + warehouseId: 'w-1', + yardId: 'y-1', + zoneId: 'z-1', + }); + service.assertTransition = jest.fn(); + // Reached only if the gate lets the item through — failing loudly here + // proves the gate did NOT stop it. + service.scheduling = { + findWagon: jest.fn().mockRejectedValue(new Error('gate did not block')), + }; + return ( + service as unknown as { load: (id: string, dto: unknown) => Promise } + ).load.bind(service); + }; + + it.each(['FAILED', 'NEEDS_REVIEW'])('refuses to load %s cargo', async (status) => { + await expect(loadWithInspection(status)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('refuses to load cargo that was never inspected', async () => { + await expect(loadWithInspection(null)('inv-1', { wagonId: 'w' })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('names the outcome so the operator knows what to fix', async () => { + await expect(loadWithInspection('FAILED')('inv-1', { wagonId: 'w' })).rejects.toThrow( + /FAILED/, + ); + }); + + it('lets passed cargo through the gate', async () => { + // It fails later, at the wagon lookup — which is the proof it got past the + // inspection gate rather than being stopped by it. + await expect(loadWithInspection('PASSED')('inv-1', { wagonId: 'w' })).rejects.toThrow( + 'gate did not block', + ); + }); +}); + +describe('inspection report — reversing a held inspection', () => { + const createReport = (previousStatus: string, itemStatus = 'RECEIVED') => { + const update = jest.fn().mockResolvedValue(undefined); + const service = Object.create(WarehouseInspectionService.prototype) as Record; + service.dataSource = { + getRepository: () => ({ + findOne: jest.fn().mockResolvedValue({ + id: 'inv-1', + bookingId: 'b-1', + inspectionStatus: previousStatus, + status: itemStatus, + }), + update, + }), + }; + service.inspectionRepository = { + findAll: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({ id: 'rep-1' }), + }; + service.markImportPickupReadyAndAcceptLastMile = jest.fn().mockResolvedValue(undefined); + const create = ( + service as unknown as { + create: (id: string, dto: unknown) => Promise; + } + ).create.bind(service); + return { create, update }; + }; + + it.each(['FAILED', 'NEEDS_REVIEW'])( + 'rejects passing %s cargo with no reason given', + async (previous) => { + const { create } = createReport(previous); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }), + ).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it('rejects whitespace as a reason', async () => { + const { create } = createReport('FAILED'); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED', remarks: ' ' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('accepts the reversal once a reason is recorded', async () => { + const { create } = createReport('FAILED'); + + await expect( + create('inv-1', { + reportType: 'INSPECTION', + inspectionStatus: 'PASSED', + remarks: 'Reworked packaging, re-weighed and verified.', + }), + ).resolves.toBeDefined(); + }); + + it('needs no reason for a first-time pass', async () => { + const { create } = createReport(null as unknown as string); + + await expect( + create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'PASSED' }), + ).resolves.toBeDefined(); + }); + + it('pulls failed cargo back out of the ready-to-load queue', async () => { + const { create, update } = createReport('PASSED', 'READY_FOR_LOADING'); + + await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' }); + + expect(update).toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' }); + }); + + it('leaves cargo that never reached the ready queue where it is', async () => { + const { create, update } = createReport('PASSED', 'STORED'); + + await create('inv-1', { reportType: 'INSPECTION', inspectionStatus: 'FAILED' }); + + expect(update).not.toHaveBeenCalledWith('inv-1', { status: 'RECEIVED' }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts index be1c68bbd..fd7c4cf6a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { NotificationAudience, NotificationType } from '@edr/types'; @@ -37,6 +37,20 @@ export class WarehouseInspectionService { throw new NotFoundException(`Inventory item ${inventoryId} not found`); } + // Overturning a held inspection is a deliberate act: the cargo was kept off + // the train, and the record has to say why it may now travel. A bare PASS + // with no remarks leaves the release unexplained. + const previousStatus = inventory.inspectionStatus; + if ( + dto.inspectionStatus === 'PASSED' && + (previousStatus === 'FAILED' || previousStatus === 'NEEDS_REVIEW') && + !dto.remarks?.trim() + ) { + throw new BadRequestException( + `Give a reason in Remarks for passing cargo whose inspection is ${previousStatus}`, + ); + } + const expected = dto.expectedWeight ?? null; const actual = dto.actualWeight ?? null; const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; @@ -83,6 +97,15 @@ export class WarehouseInspectionService { if (dto.inspectionStatus === 'PASSED') { await this.markImportPickupReadyAndAcceptLastMile(inventoryId); + } else if ( + inventory.status === 'READY_FOR_LOADING' || + inventory.status === 'READY_FOR_PICKUP' + ) { + // A failed or under-review re-inspection pulls the cargo back out of the + // ready queue. load() refuses it either way, but leaving it READY_FOR_* + // would keep it sitting on the loading and pickup lists as if nothing + // had happened. + await inventoryRepo.update(inventoryId, { status: 'RECEIVED' }); } return report; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index ef68f3673..a1fa835df 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1441,8 +1441,10 @@ export class WarehouseInventoryService { -- true regardless of the customer's actual self-haul/EDR-haul choice. -- The address is the only per-booking record of that choice. (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "lastMileRequested", - oy.code AS "origin", - dy.code AS "destination", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", b.freight_type AS "freightType", @@ -1580,335 +1582,412 @@ export class WarehouseInventoryService { bookingId: string; }> = []; + // A customer's containers often arrive on several trucks at once. Each + // truck carries its own load, so the operation is a list of trucks; the + // legacy single-truck fields collapse to a one-element list so existing + // callers behave exactly as before. + const trucks: Array<{ + truckEntrance?: BulkReceiveDto['truckEntrance']; + containerNumbers?: string[]; + bookingIds: string[]; + }> = dto.trucks?.length + ? dto.trucks.map((truck) => ({ + truckEntrance: truck.truckEntrance, + containerNumbers: truck.containerNumbers, + bookingIds: truck.bookingIds?.length ? truck.bookingIds : dto.bookingIds, + })) + : [ + { + truckEntrance: dto.truckEntrance, + containerNumbers: dto.containerNumbers, + bookingIds: dto.bookingIds, + }, + ]; + + // Two trucks cannot deliver the same box. The per-booking check below only + // catches this once a unit is marked received, which would let a duplicate + // through on the truck that happens to be processed first. + const seenContainers = new Set(); + for (const truck of trucks) { + for (const raw of truck.containerNumbers ?? []) { + const number = raw.trim().toUpperCase(); + if (seenContainers.has(number)) { + throw new BadRequestException( + `Container ${number} is listed on more than one truck`, + ); + } + seenContainers.add(number); + } + } + await this.dataSource.transaction(async (manager) => { const { warehouse, yard, zone } = await this.validateLocation(manager, { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, }); - // The receive location is whatever the operator selected above — never a - // hand-typed string. Stamp it on the truck entrance for the GRN/notes. - if (dto.truckEntrance && !dto.truckEntrance.warehouseCodeLocation) { - dto.truckEntrance.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] - .filter(Boolean) - .join(' / '); - } - - for (const bookingId of dto.bookingIds) { - const skip = (reason: string) => { - result.skippedCount += 1; - result.results.push({ bookingId, status: 'SKIPPED', reason }); - }; - - const [booking] = await manager.query( - `SELECT b.reference AS "reference", - b.payment_status AS "paymentStatus", - b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weight", - company.name AS "customer", - company.tin AS "customerTin", - ${companyNotifyPhoneExpr('company')} AS "customerPhone", - bc.container_numbers AS "containerNumber", - bc.container_quantity AS "containerQuantity", - bc.container_packaging_type AS "containerPackagingType", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", - oy.country AS "originCountry", dy.country AS "destinationCountry", - -- No service_types OR here either — see eligibleBookings above. - (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", - fm.id AS "firstMileRequestId", - fm.status AS "firstMileStatus", - v.plate_number AS "firstMileTruckPlateNumber", - v.trailer_plate_no AS "firstMileTrailerPlateNumber", - COALESCE( - NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), - v.assigned_driver_name - ) AS "firstMileDriverName", - driver.phone_number AS "firstMileDriverPhone", - driver.license_number AS "firstMileDriverLicenseNumber", - v.vehicle_type AS "firstMileTruckType", - COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_plate_number) AS "customerTruckPlateNumber", - COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') - FROM freight.customer_truck_assignments cta - WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), - b.customer_truck_driver_name) AS "customerTruckDriverName", - b.customer_truck_type AS "customerTruckType", - b.customer_truck_container_number AS "customerTruckContainerNumber", - b.customer_truck_assigned_at AS "customerTruckAssignedAt", - b.company_id AS "companyId", - (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" - FROM freight.bookings b - LEFT JOIN freight.companies company ON company.id = b.company_id - ${primaryContactUserJoin('company')} - LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id - LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id - LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id - LEFT JOIN LATERAL ( - SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, - SUM(booking_container.quantity)::int AS container_quantity, - CASE - WHEN COUNT(booking_container.id) = 0 THEN NULL - WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' - WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' - WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' - WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' - WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' - ELSE 'OTHER_CONTAINER' - END AS container_packaging_type - FROM freight.booking_container booking_container - LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id - WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL - ) bc ON true - LEFT JOIN LATERAL ( - SELECT first_mile.id, first_mile.status, first_mile.vehicle_id - FROM freight.first_mile first_mile - WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL - ORDER BY first_mile.created_at DESC - LIMIT 1 - ) fm ON true - LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id - LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id - WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, - [bookingId], - ); - if (!booking) { skip('Booking not found'); continue; } - if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } - // Direction is derived from the route (yard countries), not the stored field. - const bookingDirection = deriveTradeDirection( - { country: booking.originCountry }, - { country: booking.destinationCountry }, - ); - if (bookingDirection !== dto.direction) { - skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); - continue; - } - if (dto.direction === 'EXPORT' && booking.hasFirstMile) { - if (!booking.firstMileRequestId) { - skip('First-mile request not created'); - continue; - } - if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { - skip('First-mile truck has not arrived'); - continue; - } + // Each arriving truck is its own unit of work: its own plate and driver, + // its own containers, its own physical-load check and its own GRN batch. + // A single-truck arrival is just the one-element case, so the per-truck + // body below is unchanged from when this only ever handled one truck. + for (const truck of trucks) { + const truckEntranceInput = truck.truckEntrance; + const truckContainerNumbers = truck.containerNumbers; + const truckBookingIds = truck.bookingIds; + // The receive location is whatever the operator selected above — never a + // hand-typed string. Stamp it on the truck entrance for the GRN/notes. + if (truckEntranceInput && !truckEntranceInput.warehouseCodeLocation) { + truckEntranceInput.warehouseCodeLocation = [warehouse.code, yard.code, zone.code] + .filter(Boolean) + .join(' / '); } - const containerQuantity = Number(booking.containerQuantity ?? 0); - if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { - skip('Container booking has no container quantity'); - continue; - } + for (const bookingId of truckBookingIds) { + const skip = (reason: string) => { + result.skippedCount += 1; + result.results.push({ bookingId, status: 'SKIPPED', reason }); + }; - const now = new Date(); - const truckEntrance = dto.truckEntrance - ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) - : undefined; - // Multi-truck self-haul is selected explicitly at the gate. The booking - // source contains comma-joined legacy summary fields, which must never - // replace the one physical truck the receiver selected. - if (truckEntrance && !booking.hasFirstMile && dto.truckEntrance) { - truckEntrance.truckPlateNumber = dto.truckEntrance.truckPlateNumber; - truckEntrance.driverName = dto.truckEntrance.driverName; - truckEntrance.driverPhone = dto.truckEntrance.driverPhone; - truckEntrance.truckType = dto.truckEntrance.truckType; - } - if (dto.direction === 'EXPORT') { - this.assertTruckEntrance(truckEntrance); - } - - type ReceiveContainerUnit = { - containerNumber: string; - containerSize: string | null; - weightTons: string | number; - sealNumber: string | null; - bookingContainerId: string; - containerTypeId: string | null; - received: boolean; - }; - let selectedUnits: ReceiveContainerUnit[] = []; - let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); - - if (booking.freightType === 'CONTAINER') { - if (dto.bookingIds.length !== 1) { - throw new BadRequestException( - 'Receive one container booking per arriving truck so its containers and documents stay separate', - ); - } - const selectedNumbers = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase()); - if (!selectedNumbers.length) { - throw new BadRequestException('Select the containers arriving on this truck'); - } - const allUnits: ReceiveContainerUnit[] = await manager.query( - `SELECT UPPER(bcu.container_number) AS "containerNumber", - bc.container_size AS "containerSize", - bcu.vgm_tons AS "weightTons", - bcu.seal_number AS "sealNumber", - bc.id AS "bookingContainerId", - bc.container_type_id AS "containerTypeId", - bcu.received_to_port AS received - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL - FOR UPDATE OF bcu`, + const [booking] = await manager.query( + `SELECT b.reference AS "reference", + b.payment_status AS "paymentStatus", + b.freight_type AS "freightType", + b.cargo_total_weight_vgm AS "weight", + company.name AS "customer", + company.tin AS "customerTin", + ${companyNotifyPhoneExpr('company')} AS "customerPhone", + bc.container_numbers AS "containerNumber", + bc.container_quantity AS "containerQuantity", + bc.container_packaging_type AS "containerPackagingType", + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription", + oy.country AS "originCountry", dy.country AS "destinationCountry", + -- No service_types OR here either — see eligibleBookings above. + (NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL) AS "hasFirstMile", + fm.id AS "firstMileRequestId", + fm.status AS "firstMileStatus", + v.plate_number AS "firstMileTruckPlateNumber", + v.trailer_plate_no AS "firstMileTrailerPlateNumber", + COALESCE( + NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''), + v.assigned_driver_name + ) AS "firstMileDriverName", + driver.phone_number AS "firstMileDriverPhone", + driver.license_number AS "firstMileDriverLicenseNumber", + v.vehicle_type AS "firstMileTruckType", + COALESCE((SELECT string_agg(DISTINCT cta.plate_number, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_plate_number) AS "customerTruckPlateNumber", + COALESCE((SELECT string_agg(DISTINCT cta.driver_name, ', ') + FROM freight.customer_truck_assignments cta + WHERE cta.booking_id = b.id AND cta.deleted_at IS NULL), + b.customer_truck_driver_name) AS "customerTruckDriverName", + b.customer_truck_type AS "customerTruckType", + b.customer_truck_container_number AS "customerTruckContainerNumber", + b.customer_truck_assigned_at AS "customerTruckAssignedAt", + b.company_id AS "companyId", + (NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL) AS "hasLastMile" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + ${primaryContactUserJoin('company')} + LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id + LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id + LEFT JOIN LATERAL ( + SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers, + SUM(booking_container.quantity)::int AS container_quantity, + CASE + WHEN COUNT(booking_container.id) = 0 THEN NULL + WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER' + WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER' + WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT' + WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT' + WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT' + ELSE 'OTHER_CONTAINER' + END AS container_packaging_type + FROM freight.booking_container booking_container + LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id + WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL + ) bc ON true + LEFT JOIN LATERAL ( + SELECT first_mile.id, first_mile.status, first_mile.vehicle_id + FROM freight.first_mile first_mile + WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL + ORDER BY first_mile.created_at DESC + LIMIT 1 + ) fm ON true + LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id + LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id + WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`, [bookingId], ); - assertTruckLoad({ - containers: selectedNumbers, - bookingContainers: allUnits.map((unit) => unit.containerNumber), - sizes: allUnits - .filter((unit) => selectedNumbers.includes(unit.containerNumber)) - .map((unit) => unit.containerSize ?? ''), - }); - selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); - if (selectedUnits.some((unit) => unit.received)) { - const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); - throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + if (!booking) { skip('Booking not found'); continue; } + if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; } + // Direction is derived from the route (yard countries), not the stored field. + const bookingDirection = deriveTradeDirection( + { country: booking.originCountry }, + { country: booking.destinationCountry }, + ); + if (bookingDirection !== dto.direction) { + skip(`Booking route is ${bookingDirection}, not ${dto.direction}`); + continue; } - - // If this is a customer-assigned truck, it may only deliver the boxes - // assigned to that plate. Manual/unassigned arrivals retain the same - // physical capacity validation but have no assignment list to check. - if (truckEntrance?.truckPlateNumber) { - const assigned: Array<{ containerNumber: string }> = await manager.query( - `SELECT UPPER(ctc.container_number) AS "containerNumber" - FROM freight.customer_truck_assignments cta - JOIN freight.customer_truck_containers ctc - ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL - WHERE cta.booking_id = $1 - AND UPPER(cta.plate_number) = UPPER($2) - AND cta.deleted_at IS NULL`, - [bookingId, truckEntrance.truckPlateNumber], - ); - if ( - assigned.length > 0 && - selectedNumbers.some( - (number) => !assigned.some((container) => container.containerNumber === number), - ) - ) { - throw new BadRequestException( - `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, - ); + if (dto.direction === 'EXPORT' && booking.hasFirstMile) { + if (!booking.firstMileRequestId) { + skip('First-mile request not created'); + continue; + } + if (booking.firstMileStatus !== 'RECEIVED_TO_PORT') { + skip('First-mile truck has not arrived'); + continue; } } - const [{ batches }]: Array<{ batches: string }> = await manager.query( - `SELECT COUNT(DISTINCT inv.grn_number) AS batches - FROM freight.warehouse_inventory inv - WHERE inv.booking_id = $1 - AND inv.grn_number IS NOT NULL - AND inv.deleted_at IS NULL`, - [bookingId], - ); - grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; - if (truckEntrance) { - truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); - truckEntrance.unitCount = selectedNumbers.length; - truckEntrance.netWeightKg = selectedUnits.reduce( - (total, unit) => total + Number(unit.weightTons || 0), - 0, - ); - } - } else { - const existing = await manager - .getRepository(WarehouseInventory) - .findOne({ where: { bookingId } }); - if (existing) { - skip('Already received'); + const containerQuantity = Number(booking.containerQuantity ?? 0); + if (booking.freightType === 'CONTAINER' && containerQuantity <= 0) { + skip('Container booking has no container quantity'); continue; } - } - const receivedBefore = - booking.freightType === 'CONTAINER' - ? Number( - ( - await manager.query( - `SELECT COUNT(*) AS count - FROM freight.booking_container_units bcu - JOIN freight.booking_container bc - ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL - WHERE bc.booking_id = $1 - AND bcu.received_to_port = true - AND bcu.deleted_at IS NULL`, - [bookingId], - ) - )[0]?.count ?? 0, - ) - : 0; - const receivedAfter = receivedBefore + selectedUnits.length; - const remainingAfter = Math.max(0, containerQuantity - receivedAfter); - const receiveNote = this.buildReceiveNote({ - grnNumber, - direction: dto.direction, - notes: - booking.freightType === 'CONTAINER' - ? `${selectedUnits.length} container(s) arrived: ${selectedUnits - .map((unit) => unit.containerNumber) - .join(', ')}. ${remainingAfter} container(s) left.` - : `Bulk received (${dto.direction})`, - truckEntrance, - }); + const now = new Date(); + const truckEntrance = truckEntranceInput + ? this.mergeSystemTruckEntrance(truckEntranceInput, booking) + : undefined; + // Multi-truck self-haul is selected explicitly at the gate. The booking + // source contains comma-joined legacy summary fields, which must never + // replace the one physical truck the receiver selected. + if (truckEntrance && !booking.hasFirstMile && truckEntranceInput) { + truckEntrance.truckPlateNumber = truckEntranceInput.truckPlateNumber; + truckEntrance.driverName = truckEntranceInput.driverName; + truckEntrance.driverPhone = truckEntranceInput.driverPhone; + truckEntrance.truckType = truckEntranceInput.truckType; + } + if (dto.direction === 'EXPORT') { + this.assertTruckEntrance(truckEntrance); + } - // Validate capacity before saving - const weight = - booking.freightType === 'CONTAINER' - ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) - : Number(booking.weight) || 0; - const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; - this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); - this.assertCapacity('Yard', yard, weight, 0, containerCount); - this.assertCapacity('Zone', zone, weight, 0, containerCount); + type ReceiveContainerUnit = { + containerNumber: string; + containerSize: string | null; + weightTons: string | number; + sealNumber: string | null; + bookingContainerId: string; + containerTypeId: string | null; + received: boolean; + }; + let selectedUnits: ReceiveContainerUnit[] = []; + let grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); - const inventoryIds: string[] = []; - if (booking.freightType === 'CONTAINER') { - const containers = manager.getRepository(Container); - for (const unit of selectedUnits) { - let container = await containers.findOne({ - where: { containerNumber: unit.containerNumber }, - withDeleted: true, + if (booking.freightType === 'CONTAINER') { + if (truckBookingIds.length !== 1) { + throw new BadRequestException( + 'Receive one container booking per arriving truck so its containers and documents stay separate', + ); + } + const selectedNumbers = (truckContainerNumbers ?? []).map((n) => n.trim().toUpperCase()); + if (!selectedNumbers.length) { + throw new BadRequestException('Select the containers arriving on this truck'); + } + const allUnits: ReceiveContainerUnit[] = await manager.query( + `SELECT UPPER(bcu.container_number) AS "containerNumber", + bc.container_size AS "containerSize", + bcu.vgm_tons AS "weightTons", + bcu.seal_number AS "sealNumber", + bc.id AS "bookingContainerId", + bc.container_type_id AS "containerTypeId", + bcu.received_to_port AS received + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL + FOR UPDATE OF bcu`, + [bookingId], + ); + assertTruckLoad({ + containers: selectedNumbers, + bookingContainers: allUnits.map((unit) => unit.containerNumber), + sizes: allUnits + .filter((unit) => selectedNumbers.includes(unit.containerNumber)) + .map((unit) => unit.containerSize ?? ''), }); - if (!container && !unit.containerTypeId) { - throw new BadRequestException( - `Container ${unit.containerNumber} has no container type and cannot be received`, + selectedUnits = allUnits.filter((unit) => selectedNumbers.includes(unit.containerNumber)); + if (selectedUnits.some((unit) => unit.received)) { + const repeated = selectedUnits.filter((unit) => unit.received).map((unit) => unit.containerNumber); + throw new BadRequestException(`Container(s) already received: ${repeated.join(', ')}`); + } + + // If this is a customer-assigned truck, it may only deliver the boxes + // assigned to that plate. Manual/unassigned arrivals retain the same + // physical capacity validation but have no assignment list to check. + if (truckEntrance?.truckPlateNumber) { + const assigned: Array<{ containerNumber: string }> = await manager.query( + `SELECT UPPER(ctc.container_number) AS "containerNumber" + FROM freight.customer_truck_assignments cta + JOIN freight.customer_truck_containers ctc + ON ctc.assignment_id = cta.id AND ctc.deleted_at IS NULL + WHERE cta.booking_id = $1 + AND UPPER(cta.plate_number) = UPPER($2) + AND cta.deleted_at IS NULL`, + [bookingId, truckEntrance.truckPlateNumber], + ); + if ( + assigned.length > 0 && + selectedNumbers.some( + (number) => !assigned.some((container) => container.containerNumber === number), + ) + ) { + throw new BadRequestException( + `Selected containers are not assigned to truck ${truckEntrance.truckPlateNumber}`, + ); + } + } + + const [{ batches }]: Array<{ batches: string }> = await manager.query( + `SELECT COUNT(DISTINCT inv.grn_number) AS batches + FROM freight.warehouse_inventory inv + WHERE inv.booking_id = $1 + AND inv.grn_number IS NOT NULL + AND inv.deleted_at IS NULL`, + [bookingId], + ); + grnNumber = `${grnNumber}-${String(Number(batches ?? 0) + 1).padStart(2, '0')}`; + if (truckEntrance) { + truckEntrance.assignedEquipmentNumber = selectedNumbers.join(', '); + truckEntrance.unitCount = selectedNumbers.length; + truckEntrance.netWeightKg = selectedUnits.reduce( + (total, unit) => total + Number(unit.weightTons || 0), + 0, ); } - if (!container) { - container = await containers.save( - containers.create({ - containerNumber: unit.containerNumber, - containerTypeId: unit.containerTypeId as string, - bookingContainerId: unit.bookingContainerId, + } else { + const existing = await manager + .getRepository(WarehouseInventory) + .findOne({ where: { bookingId } }); + if (existing) { + skip('Already received'); + continue; + } + } + + const receivedBefore = + booking.freightType === 'CONTAINER' + ? Number( + ( + await manager.query( + `SELECT COUNT(*) AS count + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.received_to_port = true + AND bcu.deleted_at IS NULL`, + [bookingId], + ) + )[0]?.count ?? 0, + ) + : 0; + const receivedAfter = receivedBefore + selectedUnits.length; + const remainingAfter = Math.max(0, containerQuantity - receivedAfter); + const receiveNote = this.buildReceiveNote({ + grnNumber, + direction: dto.direction, + notes: + booking.freightType === 'CONTAINER' + ? `${selectedUnits.length} container(s) arrived: ${selectedUnits + .map((unit) => unit.containerNumber) + .join(', ')}. ${remainingAfter} container(s) left.` + : `Bulk received (${dto.direction})`, + truckEntrance, + }); + + // Validate capacity before saving + const weight = + booking.freightType === 'CONTAINER' + ? selectedUnits.reduce((total, unit) => total + Number(unit.weightTons || 0), 0) + : Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? selectedUnits.length : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + + const inventoryIds: string[] = []; + if (booking.freightType === 'CONTAINER') { + const containers = manager.getRepository(Container); + for (const unit of selectedUnits) { + let container = await containers.findOne({ + where: { containerNumber: unit.containerNumber }, + withDeleted: true, + }); + if (!container && !unit.containerTypeId) { + throw new BadRequestException( + `Container ${unit.containerNumber} has no container type and cannot be received`, + ); + } + if (!container) { + container = await containers.save( + containers.create({ + containerNumber: unit.containerNumber, + containerTypeId: unit.containerTypeId as string, + bookingContainerId: unit.bookingContainerId, + bookingId, + sealNumber: unit.sealNumber, + tareWeight: 0, + maxGrossWeight: Number(unit.weightTons || 0), + status: 'LOADED', + wagonId: null, + position: null, + wagonBookingAllocationId: null, + }), + ); + } else { + await containers.update(container.id, { bookingId, + bookingContainerId: unit.bookingContainerId, sealNumber: unit.sealNumber, - tareWeight: 0, - maxGrossWeight: Number(unit.weightTons || 0), status: 'LOADED', - wagonId: null, - position: null, - wagonBookingAllocationId: null, + deletedAt: null, + }); + } + const saved = await manager.getRepository(WarehouseInventory).save( + manager.getRepository(WarehouseInventory).create({ + warehouseId: dto.warehouseId, + yardId: dto.yardId, + zoneId: dto.zoneId, + bookingId, + containerId: container.id, + quantity: 1, + weight: Number(unit.weightTons || 0), + grnNumber, + status: 'RECEIVED', + arrivedAt: now, + notes: receiveNote, }), ); - } else { - await containers.update(container.id, { - bookingId, - bookingContainerId: unit.bookingContainerId, - sealNumber: unit.sealNumber, - status: 'LOADED', - deletedAt: null, - }); + inventoryIds.push(saved.id); } + await manager.query( + `UPDATE freight.booking_container_units bcu + SET received_to_port = true, + received_at = COALESCE(bcu.received_at, NOW()), + grn_number = $3, + updated_at = NOW() + FROM freight.booking_container bc + WHERE bc.id = bcu.booking_container_id + AND bc.booking_id = $1 + AND UPPER(bcu.container_number) = ANY($2::varchar[]) + AND bc.deleted_at IS NULL + AND bcu.deleted_at IS NULL`, + [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], + ); + } else { const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, bookingId, - containerId: container.id, quantity: 1, - weight: Number(unit.weightTons || 0), + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1917,90 +1996,60 @@ export class WarehouseInventoryService { ); inventoryIds.push(saved.id); } - await manager.query( - `UPDATE freight.booking_container_units bcu - SET received_to_port = true, - received_at = COALESCE(bcu.received_at, NOW()), - grn_number = $3, - updated_at = NOW() - FROM freight.booking_container bc - WHERE bc.id = bcu.booking_container_id - AND bc.booking_id = $1 - AND UPPER(bcu.container_number) = ANY($2::varchar[]) - AND bc.deleted_at IS NULL - AND bcu.deleted_at IS NULL`, - [bookingId, selectedUnits.map((unit) => unit.containerNumber), grnNumber], - ); - } else { - const saved = await manager.getRepository(WarehouseInventory).save( - manager.getRepository(WarehouseInventory).create({ + + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + + // Export self-haul: this receive IS the truck's arrival — see + // markCustomerTruckArrived / receive()'s single-booking mirror. + if (dto.direction === 'EXPORT') { + await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); + } + + await this.activityLog.record( + { + activityType: 'INVENTORY_RECEIVED', + inventoryId: inventoryIds[0], warehouseId: dto.warehouseId, - yardId: dto.yardId, - zoneId: dto.zoneId, - bookingId, - quantity: 1, - weight, - grnNumber, - status: 'RECEIVED', - arrivedAt: now, - notes: receiveNote, - }), + description: truckEntrance?.truckPlateNumber + ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` + : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, + performedBy: dto.performedBy, + }, + manager, ); - inventoryIds.push(saved.id); - } - // Update warehouse/yard/zone capacity counters - await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); - - // Export self-haul: this receive IS the truck's arrival — see - // markCustomerTruckArrived / receive()'s single-booking mirror. - if (dto.direction === 'EXPORT') { - await this.markCustomerTruckArrived(manager, bookingId, truckEntrance?.truckPlateNumber); - } - - await this.activityLog.record( - { - activityType: 'INVENTORY_RECEIVED', - inventoryId: inventoryIds[0], - warehouseId: dto.warehouseId, - description: truckEntrance?.truckPlateNumber - ? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}` - : `GRN ${grnNumber}: bulk received ${dto.direction} booking`, - performedBy: dto.performedBy, - }, - manager, - ); - - // Queued, not sent here: an SMS/email round-trip inside the transaction - // holds capacity/location locks open for the whole gateway latency. - pendingNotifications.push({ - owner: { - phone: truckEntrance?.customerPhone ?? booking.customerPhone, - ownerName: truckEntrance?.ownerName ?? booking.customer, - bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, - grnNumber, - direction: dto.direction, - warehouseId: dto.warehouseId, + // Queued, not sent here: an SMS/email round-trip inside the transaction + // holds capacity/location locks open for the whole gateway latency. + pendingNotifications.push({ + owner: { + phone: truckEntrance?.customerPhone ?? booking.customerPhone, + ownerName: truckEntrance?.ownerName ?? booking.customer, + bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference, + grnNumber, + direction: dto.direction, + warehouseId: dto.warehouseId, + bookingId, + }, + booking, bookingId, - }, - booking, - bookingId, - }); + }); - result.receivedCount += 1; - result.results.push({ - bookingId, - status: 'RECEIVED', - inventoryId: inventoryIds[0], - inventoryIds, - grnNumber, - ...(booking.freightType === 'CONTAINER' - ? { - receivedContainers: receivedAfter, - remainingContainers: remainingAfter, - } - : {}), - }); + result.receivedCount += 1; + result.results.push({ + bookingId, + status: 'RECEIVED', + inventoryId: inventoryIds[0], + inventoryIds, + grnNumber, + ...(booking.freightType === 'CONTAINER' + ? { + receivedContainers: receivedAfter, + remainingContainers: remainingAfter, + } + : {}), + }); + } } }); @@ -2032,8 +2081,10 @@ export class WarehouseInventoryService { COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", inv.weight AS "weight", COALESCE(inv.grn_number, substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')) AS "grnNumber", - oy.code AS "origin", - dy.code AS "destination", + -- Operators know a yard by its name: KALITY is universally called + -- GMP / Gelan Multipurpose Port. Code is only a fallback. + COALESCE(oy.label, oy.code) AS "origin", + COALESCE(dy.label, dy.code) AS "destination", oy.country AS "originCountry", dy.country AS "destinationCountry", inv.inspection_status AS "inspectionStatus", @@ -3075,7 +3126,15 @@ export class WarehouseInventoryService { // UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state). const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED']; - for (const inventoryId of dto.inventoryIds) { + // Inspection is a judgement on the booking's cargo, not on the row it + // happens to sit in. A booking's cargo spans one inventory row per + // container, and a multi-truck arrival adds a GRN batch per truck — so + // ticking one row passes every eligible row of the same booking and the + // whole booking advances together. Without this a six-container booking + // stayed half-inspected and never reached Ready To Load. + const inventoryIds = await this.expandInspectionToBooking(dto.inventoryIds, eligible); + + for (const inventoryId of inventoryIds) { const skip = (reason: string) => { result.skippedCount += 1; result.results.push({ inventoryId, status: 'SKIPPED', reason }); @@ -3084,6 +3143,13 @@ export class WarehouseInventoryService { const item = await this.inventoryRepository.findById(inventoryId); if (!item) { skip('Inventory not found'); continue; } if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; } + // Overturning a failure is a deliberate, reasoned act — never a side + // effect of ticking a row in a list. Those items are held back for an + // individual re-inspection that records why the cargo may now travel. + if (item.inspectionStatus === 'FAILED' || item.inspectionStatus === 'NEEDS_REVIEW') { + skip(`Inspection ${item.inspectionStatus} — re-inspect this item individually and give a reason`); + continue; + } if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; } // Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt. @@ -3144,6 +3210,41 @@ export class WarehouseInventoryService { return result; } + /** + * Widen a set of selected inventory rows to every still-inspectable row of + * the same booking. + * + * The originally selected ids are always kept, even when ineligible, so the + * caller still reports their skip reason rather than dropping them silently. + * Rows with no booking (ad-hoc inventory) expand to themselves. + */ + private async expandInspectionToBooking( + inventoryIds: string[], + eligibleStatuses: string[], + ): Promise { + if (inventoryIds.length === 0) return []; + const rows: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT sibling.id AS id + FROM freight.warehouse_inventory selected + JOIN freight.warehouse_inventory sibling + ON sibling.booking_id = selected.booking_id + AND sibling.deleted_at IS NULL + AND sibling.inspection_status IS DISTINCT FROM 'PASSED' + AND sibling.status = ANY($2::text[]) + WHERE selected.id = ANY($1::uuid[]) + AND selected.deleted_at IS NULL + AND selected.booking_id IS NOT NULL + UNION + SELECT id FROM freight.warehouse_inventory + WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, + [inventoryIds, eligibleStatuses], + ); + // Selected rows first so their results lead the response the operator sees. + const expanded = rows.map((row) => row.id); + const selectedFirst = inventoryIds.filter((id) => expanded.includes(id)); + return [...selectedFirst, ...expanded.filter((id) => !selectedFirst.includes(id))]; + } + // ── Receive ────────────────────────────────────────────────────────────── private async acceptLastMileIfRequested(bookingId?: string | null): Promise { @@ -5769,6 +5870,18 @@ export class WarehouseInventoryService { // 1. inventory status must be READY_FOR_LOADING (and not already LOADED). this.assertTransition(item.status, 'LOADED'); + // 1b. Failed or under-review cargo does not travel. Status alone is not + // enough: an item that passed, reached READY_FOR_LOADING and was then + // re-inspected as FAILED keeps that status, so the inspection outcome is + // checked here — the one choke point every loading path runs through. + if (item.inspectionStatus !== 'PASSED') { + throw new BadRequestException( + item.inspectionStatus + ? `Inspection is ${item.inspectionStatus} — the cargo must be re-inspected and passed, with a reason, before it can be loaded` + : 'Inventory must pass inspection before it can be loaded', + ); + } + // 2. inventory is at a valid warehouse/yard/zone location. if (!item.warehouseId || !item.yardId || !item.zoneId) { throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading'); 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 1c27b0db8..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 @@ -1531,6 +1538,11 @@ export const TRAIN_CREW_PERMISSIONS: FreightPermissionSeed[] = [ "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) @@ -2119,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", @@ -2364,6 +2377,7 @@ export const FREIGHT_PERMS = { 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", @@ -2952,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/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx index 31b026c4e..9a812df5e 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -1,8 +1,10 @@ +import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { ExternalLink, MoreHorizontal, Receipt } from "lucide-react"; import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core"; import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { OperationRescheduleModal } from "./OperationRescheduleModal"; import { useBookingActionDialog } from "./useBookingActionDialog"; import { useAuth } from "@/auth/useAuth"; import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions"; @@ -10,6 +12,7 @@ import { isAllocateAction, isClearanceNavAction, isContractNavAction, + isRescheduleAction, listRowHasActions, type BookingActionContext, } from "@/features/bookings/booking-actions.config"; @@ -44,6 +47,8 @@ export function BookingActionsMenu({ const flow = useBookingActionDialog(row.id, context); const { actions, pendingAction, mutations } = flow; + // Day / train reschedule has its own modal (date + export train picker). + const [rescheduleOpen, setRescheduleOpen] = useState(false); const goToContract = () => navigate(`/dashboard/booking-requests/${row.id}/contract`); @@ -67,6 +72,8 @@ export function BookingActionsMenu({ goToClearanceTab(); } else if (isAllocateAction(action.id)) { onAllocateBooking?.(); + } else if (isRescheduleAction(action.id)) { + setRescheduleOpen(true); } else { flow.openAction(action); } @@ -109,6 +116,14 @@ export function BookingActionsMenu({ consolidationPartnerId={row.consolidationPartnerId} consolidationPartnerReference={row.consolidationPartnerReference} /> + { + onSuppressRowClick?.(); + setRescheduleOpen(false); + }} + /> ); } @@ -183,6 +198,14 @@ export function BookingActionsMenu({ consolidationPartnerId={row.consolidationPartnerId} consolidationPartnerReference={row.consolidationPartnerReference} /> + { + onSuppressRowClick?.(); + setRescheduleOpen(false); + }} + /> ); } diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx new file mode 100644 index 000000000..0e31ceff4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/OperationRescheduleModal.tsx @@ -0,0 +1,260 @@ +import { useEffect, useMemo, useState } from "react"; +import { + Alert, + Badge, + Box, + Button, + Group, + Loader, + Modal, + Select, + Stack, + Text, + Textarea, + ThemeIcon, +} from "@mantine/core"; +import { DateInput } from "@mantine/dates"; +import { CalendarClock, Info } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; + +import { api } from "@/services/api"; +import { + useBookingDetail, + useBookingMutations, +} from "@/hooks/bookings/useBookings"; +import { + eatDay, + exportTrainOption, + isExportRailBooking, +} from "@/features/bookings/shipmentDay"; + +export interface OperationRescheduleModalProps { + bookingId: string; + opened: boolean; + onClose: () => void; +} + +/** + * Operations moves a pending operation request to another shipment day and, + * for export rail, another train — instead of returning it to the customer. + * The server re-runs the customer's own gates (open departure that day, wagon + * that can carry the cargo, export train with room) and refuses with the + * reason if the new day does not work. + */ +export function OperationRescheduleModal({ + bookingId, + opened, + onClose, +}: OperationRescheduleModalProps) { + const detailQuery = useBookingDetail(opened ? bookingId : undefined); + const booking = detailQuery.data; + const mutations = useBookingMutations(bookingId); + + const isExportRail = booking ? isExportRailBooking(booking) : false; + + const [day, setDay] = useState(null); + const [trainId, setTrainId] = useState(null); + const [note, setNote] = useState(""); + + // Seed from the booking each time the modal opens: the current day and, for + // export, the train the customer picked (the detail's requested/allocated train). + useEffect(() => { + if (!opened || !booking) return; + setDay(booking.scheduledDate ? new Date(booking.scheduledDate) : null); + setTrainId(booking.trainScheduleSummary?.id ?? null); + setNote(""); + }, [opened, booking]); + + const dayKey = day ? eatDay(day) : null; + const currentDayKey = booking?.scheduledDate + ? eatDay(booking.scheduledDate) + : null; + + // Days with an open departure on the booking's route — a planning hint; the + // server still validates the pick. + const daysQuery = useQuery({ + ...api.trainScheduling.availableDays.queryOptions({ + input: { + originYardId: booking?.originYard?.id ?? null, + destinationYardId: booking?.destinationYard?.id ?? null, + }, + }), + enabled: + opened && + Boolean(booking?.originYard?.id && booking?.destinationYard?.id), + }); + const availableDays = useMemo( + () => new Set((daysQuery.data ?? []).map((d) => eatDay(d))), + [daysQuery.data], + ); + const dayHasDeparture = dayKey ? availableDays.has(dayKey) : false; + + // Export rail: the day's export trains with free space, so staff pick one. + const trainsQuery = useQuery({ + ...api.trainScheduling.exportTrains.queryOptions({ + input: { bookingId, date: day ? day.toISOString() : "" }, + }), + enabled: opened && isExportRail && Boolean(day), + }); + const trainOptions = useMemo( + () => (trainsQuery.data ?? []).map(exportTrainOption), + [trainsQuery.data], + ); + // A train belongs to one day: changing the day drops a pick from another day. + useEffect(() => { + if (!isExportRail || !trainsQuery.data) return; + if (trainId && !trainsQuery.data.some((t) => t.scheduleId === trainId)) { + setTrainId(null); + } + }, [isExportRail, trainsQuery.data, trainId]); + + const unchanged = + dayKey != null && + dayKey === currentDayKey && + (!isExportRail || trainId === (booking?.trainScheduleSummary?.id ?? null)); + const canSave = + Boolean(day) && !unchanged && (!isExportRail || Boolean(trainId)); + + const handleSave = () => { + if (!day || !canSave) return; + mutations.rescheduleOperation.mutate( + { + scheduledDate: day.toISOString(), + ...(isExportRail && trainId ? { trainScheduleId: trainId } : {}), + ...(note.trim() ? { note: note.trim() } : {}), + }, + { onSuccess: () => onClose() }, + ); + }; + + return ( + + + + + + + Change train / shipment day + + + {booking?.reference ?? "Booking"} + + + + } + > + {detailQuery.isLoading || !booking ? ( + + + + ) : ( + + }> + Sets the shipment day + {isExportRail ? " and the export train " : " "} + for the customer, so nothing has to go back to them. The request + stays under review for the normal accept, and the customer is told + the new day. + {!isExportRail + ? " Import and domestic trains are assigned by the batch engine on the chosen day." + : ""} + + + + + Currently {currentDayKey ?? "no day"} + + {booking.trainScheduleSummary ? ( + + {booking.trainScheduleSummary.trainNumber ?? + booking.trainScheduleSummary.reference ?? + "train"} + {booking.trainScheduleSummary.isRequested ? " (requested)" : ""} + + ) : null} + + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + excludeDate={ + daysQuery.data && daysQuery.data.length + ? (d) => !availableDays.has(eatDay(d)) + : undefined + } + popoverProps={{ withinPortal: true }} + /> + {day && + daysQuery.data && + daysQuery.data.length && + !dayHasDeparture ? ( + + No open departure on this route for {dayKey}. + + ) : null} + + {isExportRail ? ( +