From 6467173c76b897dd32f4ff74aec4d5a6c25d75b1 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 17 Jul 2026 13:57:59 +0000 Subject: [PATCH] fix --- .../2320000000000-AddRateYardScope.ts | 140 +++++++++++ .../bookings/booking-pricing.service.ts | 44 +++- .../rule-engine/dto/create-rate.dto.ts | 26 ++ .../rule-engine/entities/rate.entity.ts | 23 ++ .../interfaces/rates.repository.interface.ts | 2 + .../repositories/rates.repository.ts | 18 ++ .../rule-engine/services/rates.service.ts | 229 +++++++++++++++++- .../ruleEngine/RuleEngineFormDialog.tsx | 17 ++ .../src/hooks/rule-engine/useRuleEngine.ts | 31 +++ .../ruleEngine/RuleEngineResourcePage.tsx | 60 ++++- .../src/pages/ruleEngine/config/resources.ts | 78 +++++- .../TrainScheduleV2DetailPage.tsx | 20 -- 12 files changed, 642 insertions(+), 46 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts diff --git a/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts new file mode 100644 index 000000000..8e693cf2a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-AddRateYardScope.ts @@ -0,0 +1,140 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope base rail freight to a route (origin yard → destination yard). + * + * Until now a base-freight rate was keyed by direction + container/bulk scope + * only, so "container import" cost the same whether the box was railed to Dire + * Dawa or to Mojo. Rates now carry the yard pair the price is quoted for, which + * is what the business actually sells: `container import, Djibouti → Dire Dawa, + * 500 USD`. + * + * Existing base-freight rates predate the yard pair and cannot be backfilled — + * there is no way to know which route each was meant for. They are retired + * (SUPERSEDED + soft-deleted) rather than deleted, because booking_rate_snapshot + * and rate_change_requests hold FKs to them (RESTRICT) and those rows are price + * history. Retiring drops them out of pricing and the admin UI just the same; + * the yard-scoped replacements must be re-entered. + * + * Surcharges, first-mile and last-mile rates are untouched: they are not + * route-scoped and keep NULL yards. + */ +export class AddRateYardScope2320000000000 implements MigrationInterface { + name = 'AddRateYardScope2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. Yard columns + FKs ────────────────────────────────────────────── + await queryRunner.query(` + ALTER TABLE freight.rates + ADD COLUMN IF NOT EXISTS origin_yard_id uuid NULL, + ADD COLUMN IF NOT EXISTS destination_yard_id uuid NULL; + `); + + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_origin_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_origin_yard_id" + FOREIGN KEY (origin_yard_id) REFERENCES freight.yards(id); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_rates_destination_yard_id') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "FK_rates_destination_yard_id" + FOREIGN KEY (destination_yard_id) REFERENCES freight.yards(id); + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_origin_yard_id" ON freight.rates (origin_yard_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_rates_destination_yard_id" ON freight.rates (destination_yard_id);`, + ); + + // ── 2. Retire route-less base freight ────────────────────────────────── + // Soft-delete, not DELETE: booking_rate_snapshot.rate_id is ON DELETE + // RESTRICT and those snapshots are what past bookings were charged. + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'ALWAYS' + AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'); + `); + + // ── 3. Route is part of a rate's identity ────────────────────────────── + // Two rates may now share rateType + scope + unit as long as they price + // different legs, so the yard pair joins the uniqueness tuple. + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + COALESCE(origin_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(destination_yard_id, '00000000-0000-0000-0000-000000000000'::uuid), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + // ── 4. Base freight must carry a route; nothing else may ─────────────── + // Retired rows are exempt — they are the route-less rates step 2 just + // superseded, and they must stay readable for snapshot history. + await queryRunner.query(` + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'CK_rates_yard_scope') THEN + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + 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 + ); + END IF; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // The retired rates are not un-superseded: which route each belonged to was + // never recorded, so reviving them would restore rates that price the wrong + // legs. Down only reverses the schema. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_destination_yard_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_origin_yard_id";`); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_destination_yard_id";`, + ); + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_origin_yard_id";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + DROP COLUMN IF EXISTS destination_yard_id, + DROP COLUMN IF EXISTS origin_yard_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 9a1f127e8..d3fe02d5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -475,7 +475,14 @@ export class BookingPricingService { const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { - const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD'); + const rate = this.pickRate( + liveRates, + rateType, + container.containerTypeId, + 'USD', + booking.originYardId, + booking.destinationYardId, + ); if (!rate) continue; usedRatesMap.set(rate.id, rate); @@ -515,8 +522,15 @@ export class BookingPricingService { } if (lines.length === 0) { + // Bulk (and any booking with no container lines) still has to price off a + // rate configured for this leg — never one belonging to another route. const fallback = liveRates.find( - (r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE', + (r) => + r.rateType === rateType && + r.currency === 'USD' && + r.status === 'LIVE' && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, ); if (fallback) { usedRatesMap.set(fallback.id, fallback); @@ -711,20 +725,32 @@ export class BookingPricingService { } } + /** + * Base freight is quoted per leg, so a rate only applies to a booking running + * the exact origin → destination it was configured for. There is deliberately + * no route-agnostic fallback: charging a Dire Dawa price for a Mojo shipment + * because nobody configured Mojo yet is worse than surfacing no line at all. + * Within the leg, a rate scoped to the container type wins over one that + * covers every type. + */ private pickRate( rates: Rate[], rateType: string, containerTypeId: string, currency: string, + originYardId: string, + destinationYardId: string, ): Rate | undefined { + const onLeg = rates.filter( + (r) => + r.rateType === rateType && + r.currency === currency && + r.originYardId === originYardId && + r.destinationYardId === destinationYardId, + ); return ( - rates.find( - (r) => - r.rateType === rateType && - r.currency === currency && - r.containerTypeId === containerTypeId, - ) ?? - rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId) + onLeg.find((r) => r.containerTypeId === containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId) ); } 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 9a4cd642b..5507692d9 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 @@ -9,6 +9,7 @@ import { const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; +export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; export class CreateRateDto { @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) @@ -37,6 +38,31 @@ export class CreateRateDto { @IsIn([...TRADE_DIRECTIONS]) tradeDirection?: string; + @ApiPropertyOptional({ + enum: INTERCITY_KINDS, + description: + 'Whether an intercity rate covers containers or bulk. Required when appliesTo = INTERCITY; ignored otherwise. Not stored — it selects the INTERCITY_CONTAINER / INTERCITY_BULK rate type.', + }) + @IsOptional() + @IsIn([...INTERCITY_KINDS]) + intercityKind?: string; + + @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.', + }) + @IsOptional() + @IsUUID() + originYardId?: string; + + @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.', + }) + @IsOptional() + @IsUUID() + destinationYardId?: string; + @ApiPropertyOptional({ enum: CURRENCIES }) @IsOptional() @IsIn([...CURRENCIES]) 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 83c225ea1..6f7ac78ee 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 @@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { CargoType } from './cargo-type.entity'; import { ContainerType } from './container-type.entity'; +import { Yard } from './yard.entity'; export const RATE_TYPES = [ 'CONTAINER_IMPORT', @@ -91,6 +92,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Index(['status']) @Index(['containerTypeId']) @Index(['trigger']) +@Index(['originYardId']) +@Index(['destinationYardId']) export class Rate extends BaseEntity { @Column({ name: 'rate_type', type: 'varchar', length: 50 }) rateType!: RateType; @@ -118,6 +121,26 @@ export class Rate extends BaseEntity { @Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true }) tradeDirection?: string | null; + /** + * 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. + */ + @Column({ name: 'origin_yard_id', type: 'uuid', nullable: true }) + originYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true, eager: false }) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard | null; + + @Column({ name: 'destination_yard_id', type: 'uuid', nullable: true }) + destinationYardId?: string | null; + + @ManyToOne(() => Yard, { nullable: true, eager: false }) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard | null; + @Column({ name: 'currency', type: 'varchar', length: 5 }) currency!: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index be0962942..a23e47eea 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -12,6 +12,8 @@ export interface IRatesRepository { containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 85bfc783f..d855fb491 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -37,6 +37,8 @@ export class RatesRepository implements IRatesRepository { containerTypeId?: string | null; cargoTypeId?: string | null; tradeDirection?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; }): Promise { const qb = this.repo .createQueryBuilder('rate') @@ -59,6 +61,18 @@ export class RatesRepository implements IRatesRepository { } else { qb.andWhere('rate.trade_direction IS NULL'); } + if (pattern.originYardId) { + qb.andWhere('rate.origin_yard_id = :originYardId', { originYardId: pattern.originYardId }); + } else { + qb.andWhere('rate.origin_yard_id IS NULL'); + } + if (pattern.destinationYardId) { + qb.andWhere('rate.destination_yard_id = :destinationYardId', { + destinationYardId: pattern.destinationYardId, + }); + } else { + qb.andWhere('rate.destination_yard_id IS NULL'); + } return qb.getOne(); } @@ -75,6 +89,10 @@ export class RatesRepository implements IRatesRepository { findPaged(query: ListRatesQueryDto): Promise> { const qb = this.repo .createQueryBuilder('rate') + // The admin table shows the leg a base-freight rate prices — without the + // yards joined the route columns have only ids to render. + .leftJoinAndSelect('rate.originYard', 'originYard') + .leftJoinAndSelect('rate.destinationYard', 'destinationYard') .orderBy('rate.createdAt', query.sortOrder ?? 'DESC'); if (query.status) { 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 b9a5e7873..c429e3d45 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 @@ -6,7 +6,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { PaginatedResponse } from '@edr/types'; +import { PaginatedResponse, YardCountry } from '@edr/types'; import { CreateRateDto } from '../dto/create-rate.dto'; import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; @@ -14,12 +14,24 @@ import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; +import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface'; + +/** Categories priced per rail leg — they carry an origin → destination yard pair. */ +const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; + +/** The yard pair a rate scopes to, already validated against its direction. */ +interface YardScope { + originYardId: string | null; + destinationYardId: string | null; +} @Injectable() export class RatesService { constructor( @Inject(RATES_REPOSITORY) private readonly repository: IRatesRepository, + @Inject(YARDS_REPOSITORY) + private readonly yardsRepository: IYardsRepository, ) {} /** List rates — standard paginated envelope with server-side search. */ @@ -62,6 +74,136 @@ export class RatesService { return requestedUnit; } + /** Base rail freight is priced per leg; surcharges and truck legs are not. */ + private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); + } + + /** + * Which country each end of the leg must sit in, given what the rate is for. + * The railway only sells three shapes: import lands at the Djibouti ports and + * rails inland, export is the reverse, and intercity stays inside Ethiopia. + */ + private expectedYardCountries( + appliesTo: Rate['appliesTo'], + tradeDirection: string | null, + ): { origin: YardCountry; destination: YardCountry } { + if (appliesTo === 'INTERCITY') { + return { origin: YardCountry.ETHIOPIA, destination: YardCountry.ETHIOPIA }; + } + return tradeDirection === 'EXPORT' + ? { origin: YardCountry.ETHIOPIA, destination: YardCountry.DJIBOUTI } + : { origin: YardCountry.DJIBOUTI, destination: YardCountry.ETHIOPIA }; + } + + /** + * Validate and normalise the leg a rate prices. + * + * Base freight must name both yards and they must match the direction, so a + * "container import" rate cannot be quoted Ethiopia → Ethiopia. Everything + * else (surcharges, first/last mile) is route-agnostic and has its yards + * cleared, mirroring how container/cargo scope is cleared for surcharges. + */ + private async resolveYardScope(input: { + appliesTo: Rate['appliesTo']; + trigger: Rate['trigger']; + tradeDirection: string | null; + originYardId?: string | null; + destinationYardId?: string | null; + }): Promise { + const { appliesTo, trigger, tradeDirection } = input; + if (!this.isBaseFreight(appliesTo, trigger)) { + return { originYardId: null, destinationYardId: null }; + } + + const originYardId = input.originYardId ?? null; + const destinationYardId = input.destinationYardId ?? null; + if (!originYardId || !destinationYardId) { + throw new BadRequestException( + 'Base freight rates are priced per leg — pick both an origin and a destination yard.', + ); + } + if (originYardId === destinationYardId) { + throw new BadRequestException('Origin and destination yard must be different.'); + } + + const [origin, destination] = await Promise.all([ + this.yardsRepository.findById(originYardId), + this.yardsRepository.findById(destinationYardId), + ]); + if (!origin) throw new BadRequestException(`Origin yard ${originYardId} not found`); + if (!destination) { + throw new BadRequestException(`Destination yard ${destinationYardId} not found`); + } + + const expected = this.expectedYardCountries(appliesTo, tradeDirection); + if (origin.country !== expected.origin || destination.country !== expected.destination) { + const shape = + appliesTo === 'INTERCITY' ? 'Intercity' : `${tradeDirection ?? 'Import'} freight`; + throw new BadRequestException( + `${shape} runs ${expected.origin} → ${expected.destination}, but ${origin.label} is in ` + + `${origin.country} and ${destination.label} is in ${destination.country}.`, + ); + } + + return { originYardId, destinationYardId }; + } + + /** + * Guard the scope fields a base-freight category needs before we derive its + * rateType: import/export must say which, and intercity must say whether it + * carries containers or bulk (the two price differently and an unstated kind + * would silently file the rate as one of them). + */ + private assertScopeCoherent(input: { + appliesTo: Rate['appliesTo']; + trigger: Rate['trigger']; + tradeDirection: string | null; + intercityKind: string | null; + containerTypeId: string | null; + cargoTypeId: string | null; + }): void { + const { appliesTo, trigger, tradeDirection, intercityKind } = input; + const { containerTypeId, cargoTypeId } = input; + if (!this.isBaseFreight(appliesTo, trigger)) return; + + if (appliesTo === 'INTERCITY') { + if (intercityKind !== 'CONTAINER' && intercityKind !== 'BULK') { + throw new BadRequestException( + 'An intercity rate must say whether it covers containers or bulk.', + ); + } + // The scope field has to agree with the kind, or the rate would advertise + // one cargo kind and narrow by the other. + if (intercityKind === 'CONTAINER' && cargoTypeId) { + throw new BadRequestException( + 'An intercity container rate cannot be scoped to a bulk cargo type.', + ); + } + if (intercityKind === 'BULK' && containerTypeId) { + throw new BadRequestException( + 'An intercity bulk rate cannot be scoped to a container type.', + ); + } + return; + } + + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + `${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`, + ); + } + } + + /** + * Whether a rate covers bulk cargo — the flag `deriveRateType` splits + * INTERCITY_BULK from INTERCITY_CONTAINER on. Intercity states its kind + * explicitly; for BULK/CONTAINER the category already says it. + */ + private resolvesToBulk(appliesTo: Rate['appliesTo'], intercityKind: string | null): boolean { + return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK'; + } + /** * Reject a second rate with the same identity pattern (rateType + scope). With * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would @@ -73,12 +215,14 @@ export class RatesService { containerTypeId: string | null; cargoTypeId: string | null; tradeDirection: string | null; + originYardId: string | null; + destinationYardId: string | null; ignoreId?: string; }): Promise { const existing = await this.repository.findByPattern(pattern); if (existing && existing.id !== pattern.ignoreId) { throw new ConflictException( - 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + 'A rate for this exact combination already exists on this route. Edit or delete the existing rate instead of creating a duplicate.', ); } } @@ -92,17 +236,45 @@ export class RatesService { const isSurcharge = trigger !== 'ALWAYS'; const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); - const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + // Intercity never leaves Ethiopia, so it has no trade direction to store — + // its yard pair already says where it runs. + const tradeDirection = + isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); + + const intercityKind = dto.intercityKind ?? null; + this.assertScopeCoherent({ + appliesTo, + trigger, + tradeDirection, + intercityKind, + containerTypeId, + cargoTypeId, + }); + const { originYardId, destinationYardId } = await this.resolveYardScope({ + appliesTo, + trigger, + tradeDirection, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, + }); const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, - isBulk: Boolean(cargoTypeId), + isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); - await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + await this.assertNoDuplicatePattern({ + rateType, + rateUnit, + containerTypeId, + cargoTypeId, + tradeDirection, + originYardId, + destinationYardId, + }); return this.repository.create({ appliesTo, @@ -111,6 +283,8 @@ export class RatesService { containerTypeId, cargoTypeId, tradeDirection, + originYardId, + destinationYardId, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, rateUnit, @@ -194,21 +368,52 @@ export class RatesService { : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; - const tradeDirection = isSurcharge - ? null - : dto.tradeDirection !== undefined - ? dto.tradeDirection - : existing.tradeDirection; + const tradeDirection = + isSurcharge || appliesTo === 'INTERCITY' + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; updates.tradeDirection = tradeDirection ?? null; + + // A patch that leaves the cargo kind unsaid keeps the one the rate already + // has — read back off its rateType, the only place it is recorded. + const intercityKind = + dto.intercityKind ?? (existing.rateType === 'INTERCITY_BULK' ? 'BULK' : 'CONTAINER'); + + this.assertScopeCoherent({ + appliesTo, + trigger, + tradeDirection: updates.tradeDirection, + intercityKind, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + }); + // Re-validate the leg: changing direction can invalidate a yard pair that + // was legal under the old one (an import route is not an export route). + const yardScope = await this.resolveYardScope({ + appliesTo, + trigger, + tradeDirection: updates.tradeDirection, + originYardId: + dto.originYardId !== undefined ? dto.originYardId : existing.originYardId, + destinationYardId: + dto.destinationYardId !== undefined + ? dto.destinationYardId + : existing.destinationYardId, + }); + updates.originYardId = yardScope.originYardId; + updates.destinationYardId = yardScope.destinationYardId; + // Keep the derived rateType in sync with whatever changed. const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, - isBulk: Boolean(cargoTypeId), + isBulk: this.resolvesToBulk(appliesTo, intercityKind), }); updates.rateType = rateType; @@ -224,6 +429,8 @@ export class RatesService { containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, tradeDirection: updates.tradeDirection, + originYardId: updates.originYardId, + destinationYardId: updates.destinationYardId, ignoreId: id, }); diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index e6c01c26d..1c9d94825 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -176,6 +176,7 @@ const RuleEngineFormDialog = ({ ) { return false; } + if (field.showIf && !field.showIf(values)) return false; return true; }), [fields, values], @@ -192,6 +193,22 @@ const RuleEngineFormDialog = ({ if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) { next.rateUnit = ""; } + // The legal yards depend on what the rate is for and which way it runs, so + // a leg picked under the old answer is no longer valid — clear it instead + // of submitting a pair the API will reject. + if ( + (name === "appliesTo" || name === "tradeDirection") && + "originYardId" in current + ) { + next.originYardId = ""; + next.destinationYardId = ""; + } + // Intercity asks for a container type or a bulk cargo type, never both — + // switching kind drops whichever the other kind had filled in. + if (name === "intercityKind") { + next.containerTypeId = ""; + next.cargoTypeId = ""; + } return next; }); }; diff --git a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts index d0b34cb5b..1fdc611d4 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/rule-engine/useRuleEngine.ts @@ -155,6 +155,37 @@ export const useContainerTypeOptions = ( select: (rows) => buildContainerTypeSelectOptions(rows, includeNone), }); +/** A yard option that remembers its country, so callers can filter by leg. */ +export interface YardOption { + label: string; + value: string; + country: string; +} + +/** + * Active yards for the rate form's origin/destination pickers. The country + * rides along on each option because which yards are legal depends on the + * rate's direction (import starts in Djibouti, export starts in Ethiopia). + */ +export const useYardOptions = (enabled = true) => + useQuery({ + queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("yards", { activeOnly: true }), + queryFn: () => ruleEngineService.listAll("yards"), + enabled, + select: (rows): YardOption[] => + rows + .filter((row) => row.id && row.isActive !== false) + .map((row) => { + const label = String(row.label ?? "").trim(); + const code = String(row.code ?? "").trim(); + return { + label: label && code ? `${label} (${code})` : label || code || String(row.id), + value: String(row.id), + country: String(row.country ?? ""), + }; + }), + }); + /** * Active wagon-type options for the cargo-type / container-type "Wagon type" * picker. The FK the selection sets drives train-scheduling wagon resolution. diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx index 58a46f2f7..db7190ed7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RuleEngineResourcePage.tsx @@ -41,6 +41,8 @@ import { useContainerTypeOptions, useLiveRateOptions, useWagonTypeOptions, + useYardOptions, + type YardOption, usePriorityRuleWorkflow, useRateChangeWorkflow, useRateWorkflow, @@ -72,6 +74,41 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => { return undefined; }; +/** + * Which yards may sit at one end of the leg a base-freight rate prices. + * + * The railway sells three shapes and each pins the countries: an import lands + * at a Djibouti port and rails inland, an export is the reverse, and intercity + * stays inside Ethiopia. Narrowing the dropdown is what stops an import rate + * from being configured Ethiopia → Ethiopia — the API rejects that too, but + * the admin should never be offered it. Non-base-freight rates carry no leg, + * so they get nothing. + */ +const yardOptionsForLegEnd = ( + yards: YardOption[], + values: Record, + end: "origin" | "destination", +): { label: string; value: string }[] => { + const appliesTo = String(values.appliesTo ?? ""); + let country: string | undefined; + if (appliesTo === "INTERCITY") { + country = "Ethiopia"; + } else if (appliesTo === "CONTAINER" || appliesTo === "BULK") { + const direction = String(values.tradeDirection ?? ""); + // Direction is what decides the countries, so offer nothing until it is set + // rather than defaulting to one and letting it read as a real choice. + if (direction !== "IMPORT" && direction !== "EXPORT") return []; + const startsInEthiopia = direction === "EXPORT"; + country = (end === "origin" ? startsInEthiopia : !startsInEthiopia) + ? "Ethiopia" + : "Djibouti"; + } + if (!country) return []; + return yards + .filter((yard) => yard.country === country) + .map(({ label, value }) => ({ label, value })); +}; + const RuleEngineResourcePage = () => { const { user } = useAuth(); const { resource: resourceSlug } = useParams<{ resource: string }>(); @@ -205,6 +242,11 @@ const RuleEngineResourcePage = () => { useLiveRateOptions(usesLiveRateField); const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } = useWagonTypeOptions(usesWagonTypeField); + const usesYardField = Boolean( + config?.formFields.some((f) => f.name === "originYardId"), + ); + const { data: yardOptions, isLoading: yardOptionsLoading } = + useYardOptions(usesYardField); // Full rule list backing the auto-filled "min wagon count": the next range // always continues the chain for the selected type (per currency), so the @@ -279,9 +321,22 @@ const RuleEngineResourcePage = () => { options: wagonTypeOptions ?? [], }; } + // Each end of the leg only offers yards in the country that end of the + // trade actually sits in, so an import can't be configured as if it + // started inland. Resolved per keystroke because the legal set changes + // with the direction the admin picks. + if (field.name === "originYardId" || field.name === "destinationYardId") { + const end = field.name === "originYardId" ? "origin" : "destination"; + return { + ...field, + type: "select" as const, + optionsFromValues: (values: Record) => + yardOptionsForLegEnd(yardOptions ?? [], values, end), + }; + } return field; }); - }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, isPriorityRules, allPriorityRules, editing, editingId]); + }, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]); const rows = data?.items ?? []; const meta = data?.meta; @@ -709,7 +764,8 @@ const RuleEngineResourcePage = () => { (usesContainerTypeField && containerTypeOptionsLoading) || (usesCargoTypeField && cargoLeafOptionsLoading) || (usesLiveRateField && liveRateOptionsLoading) || - (usesWagonTypeField && wagonTypeOptionsLoading) + (usesWagonTypeField && wagonTypeOptionsLoading) || + (usesYardField && yardOptionsLoading) } positionOptions={!editing ? createPositionOptions : undefined} positionLoading={createPositionLoading} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 902d23427..00531d7b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -47,6 +47,13 @@ export interface FormFieldDef { * showWhen and not match hideWhen. */ showWhen?: { field: string; equals: string[] }; + /** + * Show this field only when the predicate accepts the live form values — for + * visibility that depends on more than one field, which `showWhen` cannot + * express (the intercity container/bulk pickers hang off both `appliesTo` + * and `intercityKind`). Combines with showWhen/hideWhen: all must pass. + */ + showIf?: (values: Record) => boolean; /** * Select options computed from other fields' current values. When set, the * form resolves the option list at render time from the live form state @@ -145,6 +152,21 @@ const RATE_TRIGGERS = [ { label: "Customs clearance service fee (prepaid)", value: "CUSTOMS_CLEARANCE" }, ]; +/** + * Intercity runs inside Ethiopia and can carry either boxes or bulk, but the + * two price differently. The admin says which up front and the form then asks + * for the matching scope field — this choice is not stored on the rate itself; + * the API reads container-vs-bulk back off whichever scope field was filled. + */ +const INTERCITY_KINDS = [ + { label: "Container", value: "CONTAINER" }, + { label: "Bulk", value: "BULK" }, +]; + +/** True when the rate being edited is base rail freight, which is priced per leg. */ +const isBaseFreightRate = (values: Record) => + ["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? "")); + const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value }); /** @@ -531,6 +553,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ columns: [ { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, + // Base freight is priced per leg, so the route is what tells two otherwise + // identical rates apart. Surcharges have no leg and render as "—". + { id: "originYard", header: "From", accessorKey: "originYard", format: "entityLabel" }, + { + id: "destinationYard", + header: "To", + accessorKey: "destinationYard", + format: "entityLabel", + }, { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, @@ -564,23 +595,62 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"), showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] }, }, - // ── Container type — Container & Intercity ──────────────────────────── + // ── Cargo kind — Intercity only (import/export get it from appliesTo) ─ + { + name: "intercityKind", + label: "Cargo type", + type: "select", + required: true, + options: INTERCITY_KINDS, + placeholder: "Is this rate for containers or bulk?", + description: "Intercity prices containers and bulk differently — pick which this covers.", + showWhen: { field: "appliesTo", equals: ["INTERCITY"] }, + // Not a stored column: an existing rate records its kind in the rateType + // the API derived (INTERCITY_BULK / INTERCITY_CONTAINER). + getInitialValue: (record) => + record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER", + }, + // ── Container type — Container freight, and container-kind intercity ── { name: "containerTypeId", label: "Container type", type: "select", optional: true, placeholder: "Select container type (optional)", - showWhen: { field: "appliesTo", equals: ["CONTAINER", "INTERCITY"] }, + showIf: (v) => + v.appliesTo === "CONTAINER" || + (v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"), }, - // ── Bulk cargo (leaf commodity) — Bulk & Intercity ─────────────────── + // ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─ { name: "cargoTypeId", label: "Bulk cargo type", type: "select", optional: true, placeholder: "Select bulk commodity (optional)", - showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] }, + showIf: (v) => + v.appliesTo === "BULK" || + (v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"), + }, + // ── The leg this rate prices — base freight only ────────────────────── + // Options are narrowed to the countries the direction allows (import + // starts in Djibouti, export in Ethiopia, intercity stays in Ethiopia); + // see RuleEngineResourcePage, which injects the yard lists. + { + name: "originYardId", + label: "Origin yard", + type: "select", + required: true, + placeholder: "Where the leg starts", + showIf: isBaseFreightRate, + }, + { + name: "destinationYardId", + label: "Destination yard", + type: "select", + required: true, + placeholder: "Where the leg ends", + showIf: isBaseFreightRate, }, { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, // Unit choices are driven by the rate shape (appliesTo + trigger). Overweight diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 33c4535ce..640b9bde1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -52,7 +52,6 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; -import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; // import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel"; import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep"; @@ -103,7 +102,6 @@ export default function TrainScheduleV2DetailPage() { const [previewResult, setPreviewResult] = useState(null); const [containerPlacements, setContainerPlacements] = useState([]); const [maintenanceOpen, setMaintenanceOpen] = useState(false); - const [adjustConsistOpen, setAdjustConsistOpen] = useState(false); const [windowSettingsOpen, setWindowSettingsOpen] = useState(false); const [gatepassSecuredAt, setGatepassSecuredAt] = useState(""); const [gatepassReference, setGatepassReference] = useState(""); @@ -976,18 +974,6 @@ export default function TrainScheduleV2DetailPage() { Reschedule train ) : null} - {schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? ( - - ) : null} {gatepassApplies ? ( gatepassSecured ? (