mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix
This commit is contained in:
@@ -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<void> {
|
||||||
|
// ── 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<void> {
|
||||||
|
// 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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -475,7 +475,14 @@ export class BookingPricingService {
|
|||||||
const wagonCount = await this.resolveWagonCount(booking);
|
const wagonCount = await this.resolveWagonCount(booking);
|
||||||
|
|
||||||
for (const container of evalInput.containers) {
|
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;
|
if (!rate) continue;
|
||||||
|
|
||||||
usedRatesMap.set(rate.id, rate);
|
usedRatesMap.set(rate.id, rate);
|
||||||
@@ -515,8 +522,15 @@ export class BookingPricingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (lines.length === 0) {
|
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(
|
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) {
|
if (fallback) {
|
||||||
usedRatesMap.set(fallback.id, 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(
|
private pickRate(
|
||||||
rates: Rate[],
|
rates: Rate[],
|
||||||
rateType: string,
|
rateType: string,
|
||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
currency: string,
|
currency: string,
|
||||||
|
originYardId: string,
|
||||||
|
destinationYardId: string,
|
||||||
): Rate | undefined {
|
): Rate | undefined {
|
||||||
|
const onLeg = rates.filter(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === rateType &&
|
||||||
|
r.currency === currency &&
|
||||||
|
r.originYardId === originYardId &&
|
||||||
|
r.destinationYardId === destinationYardId,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
rates.find(
|
onLeg.find((r) => r.containerTypeId === containerTypeId) ??
|
||||||
(r) =>
|
onLeg.find((r) => !r.containerTypeId)
|
||||||
r.rateType === rateType &&
|
|
||||||
r.currency === currency &&
|
|
||||||
r.containerTypeId === containerTypeId,
|
|
||||||
) ??
|
|
||||||
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||||
const CURRENCIES = ['USD'] as const;
|
const CURRENCIES = ['USD'] as const;
|
||||||
|
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||||
|
|
||||||
export class CreateRateDto {
|
export class CreateRateDto {
|
||||||
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
@ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' })
|
||||||
@@ -37,6 +38,31 @@ export class CreateRateDto {
|
|||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection?: string;
|
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 })
|
@ApiPropertyOptional({ enum: CURRENCIES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...CURRENCIES])
|
@IsIn([...CURRENCIES])
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
|||||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
import { CargoType } from './cargo-type.entity';
|
import { CargoType } from './cargo-type.entity';
|
||||||
import { ContainerType } from './container-type.entity';
|
import { ContainerType } from './container-type.entity';
|
||||||
|
import { Yard } from './yard.entity';
|
||||||
|
|
||||||
export const RATE_TYPES = [
|
export const RATE_TYPES = [
|
||||||
'CONTAINER_IMPORT',
|
'CONTAINER_IMPORT',
|
||||||
@@ -91,6 +92,8 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
|
|||||||
@Index(['status'])
|
@Index(['status'])
|
||||||
@Index(['containerTypeId'])
|
@Index(['containerTypeId'])
|
||||||
@Index(['trigger'])
|
@Index(['trigger'])
|
||||||
|
@Index(['originYardId'])
|
||||||
|
@Index(['destinationYardId'])
|
||||||
export class Rate extends BaseEntity {
|
export class Rate extends BaseEntity {
|
||||||
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
@Column({ name: 'rate_type', type: 'varchar', length: 50 })
|
||||||
rateType!: RateType;
|
rateType!: RateType;
|
||||||
@@ -118,6 +121,26 @@ export class Rate extends BaseEntity {
|
|||||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
@Column({ name: 'trade_direction', type: 'varchar', length: 10, nullable: true })
|
||||||
tradeDirection?: string | null;
|
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 })
|
@Column({ name: 'currency', type: 'varchar', length: 5 })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface IRatesRepository {
|
|||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
}): Promise<Rate | null>;
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
containerTypeId?: string | null;
|
containerTypeId?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
tradeDirection?: string | null;
|
tradeDirection?: string | null;
|
||||||
|
originYardId?: string | null;
|
||||||
|
destinationYardId?: string | null;
|
||||||
}): Promise<Rate | null> {
|
}): Promise<Rate | null> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.createQueryBuilder('rate')
|
||||||
@@ -59,6 +61,18 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
} else {
|
} else {
|
||||||
qb.andWhere('rate.trade_direction IS NULL');
|
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();
|
return qb.getOne();
|
||||||
}
|
}
|
||||||
@@ -75,6 +89,10 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||||
const qb = this.repo
|
const qb = this.repo
|
||||||
.createQueryBuilder('rate')
|
.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');
|
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
if (query.status) {
|
if (query.status) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PaginatedResponse } from '@edr/types';
|
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.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 { deriveRateType } from '../entities/rate-type.util';
|
||||||
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
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()
|
@Injectable()
|
||||||
export class RatesService {
|
export class RatesService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RATES_REPOSITORY)
|
@Inject(RATES_REPOSITORY)
|
||||||
private readonly repository: IRatesRepository,
|
private readonly repository: IRatesRepository,
|
||||||
|
@Inject(YARDS_REPOSITORY)
|
||||||
|
private readonly yardsRepository: IYardsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List rates — standard paginated envelope with server-side search. */
|
/** List rates — standard paginated envelope with server-side search. */
|
||||||
@@ -62,6 +74,136 @@ export class RatesService {
|
|||||||
return requestedUnit;
|
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<YardScope> {
|
||||||
|
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
|
* 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
|
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||||
@@ -73,12 +215,14 @@ export class RatesService {
|
|||||||
containerTypeId: string | null;
|
containerTypeId: string | null;
|
||||||
cargoTypeId: string | null;
|
cargoTypeId: string | null;
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
|
originYardId: string | null;
|
||||||
|
destinationYardId: string | null;
|
||||||
ignoreId?: string;
|
ignoreId?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const existing = await this.repository.findByPattern(pattern);
|
const existing = await this.repository.findByPattern(pattern);
|
||||||
if (existing && existing.id !== pattern.ignoreId) {
|
if (existing && existing.id !== pattern.ignoreId) {
|
||||||
throw new ConflictException(
|
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 isSurcharge = trigger !== 'ALWAYS';
|
||||||
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
||||||
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? 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({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: Boolean(cargoTypeId),
|
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||||
});
|
});
|
||||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
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({
|
return this.repository.create({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
@@ -111,6 +283,8 @@ export class RatesService {
|
|||||||
containerTypeId,
|
containerTypeId,
|
||||||
cargoTypeId,
|
cargoTypeId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
currency: dto.currency ?? 'USD',
|
currency: dto.currency ?? 'USD',
|
||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit,
|
rateUnit,
|
||||||
@@ -194,21 +368,52 @@ export class RatesService {
|
|||||||
: dto.cargoTypeId !== undefined
|
: dto.cargoTypeId !== undefined
|
||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection = isSurcharge
|
const tradeDirection =
|
||||||
? null
|
isSurcharge || appliesTo === 'INTERCITY'
|
||||||
: dto.tradeDirection !== undefined
|
? null
|
||||||
? dto.tradeDirection
|
: dto.tradeDirection !== undefined
|
||||||
: existing.tradeDirection;
|
? dto.tradeDirection
|
||||||
|
: existing.tradeDirection;
|
||||||
|
|
||||||
updates.containerTypeId = containerTypeId ?? null;
|
updates.containerTypeId = containerTypeId ?? null;
|
||||||
updates.cargoTypeId = cargoTypeId ?? null;
|
updates.cargoTypeId = cargoTypeId ?? null;
|
||||||
updates.tradeDirection = tradeDirection ?? 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.
|
// Keep the derived rateType in sync with whatever changed.
|
||||||
const rateType = deriveRateType({
|
const rateType = deriveRateType({
|
||||||
appliesTo,
|
appliesTo,
|
||||||
trigger,
|
trigger,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
isBulk: Boolean(cargoTypeId),
|
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||||
});
|
});
|
||||||
updates.rateType = rateType;
|
updates.rateType = rateType;
|
||||||
|
|
||||||
@@ -224,6 +429,8 @@ export class RatesService {
|
|||||||
containerTypeId: updates.containerTypeId,
|
containerTypeId: updates.containerTypeId,
|
||||||
cargoTypeId: updates.cargoTypeId,
|
cargoTypeId: updates.cargoTypeId,
|
||||||
tradeDirection: updates.tradeDirection,
|
tradeDirection: updates.tradeDirection,
|
||||||
|
originYardId: updates.originYardId,
|
||||||
|
destinationYardId: updates.destinationYardId,
|
||||||
ignoreId: id,
|
ignoreId: id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ const RuleEngineFormDialog = ({
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (field.showIf && !field.showIf(values)) return false;
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
[fields, values],
|
[fields, values],
|
||||||
@@ -192,6 +193,22 @@ const RuleEngineFormDialog = ({
|
|||||||
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
|
||||||
next.rateUnit = "";
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,6 +155,37 @@ export const useContainerTypeOptions = (
|
|||||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
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<RuleEngineRecord>("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"
|
* Active wagon-type options for the cargo-type / container-type "Wagon type"
|
||||||
* picker. The FK the selection sets drives train-scheduling wagon resolution.
|
* picker. The FK the selection sets drives train-scheduling wagon resolution.
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import {
|
|||||||
useContainerTypeOptions,
|
useContainerTypeOptions,
|
||||||
useLiveRateOptions,
|
useLiveRateOptions,
|
||||||
useWagonTypeOptions,
|
useWagonTypeOptions,
|
||||||
|
useYardOptions,
|
||||||
|
type YardOption,
|
||||||
usePriorityRuleWorkflow,
|
usePriorityRuleWorkflow,
|
||||||
useRateChangeWorkflow,
|
useRateChangeWorkflow,
|
||||||
useRateWorkflow,
|
useRateWorkflow,
|
||||||
@@ -72,6 +74,41 @@ const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
|
|||||||
return 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<string, unknown>,
|
||||||
|
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 RuleEngineResourcePage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
const { resource: resourceSlug } = useParams<{ resource: string }>();
|
||||||
@@ -205,6 +242,11 @@ const RuleEngineResourcePage = () => {
|
|||||||
useLiveRateOptions(usesLiveRateField);
|
useLiveRateOptions(usesLiveRateField);
|
||||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||||
useWagonTypeOptions(usesWagonTypeField);
|
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
|
// 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
|
// always continues the chain for the selected type (per currency), so the
|
||||||
@@ -279,9 +321,22 @@ const RuleEngineResourcePage = () => {
|
|||||||
options: wagonTypeOptions ?? [],
|
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<string, unknown>) =>
|
||||||
|
yardOptionsForLegEnd(yardOptions ?? [], values, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
return field;
|
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 rows = data?.items ?? [];
|
||||||
const meta = data?.meta;
|
const meta = data?.meta;
|
||||||
@@ -709,7 +764,8 @@ const RuleEngineResourcePage = () => {
|
|||||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||||
(usesWagonTypeField && wagonTypeOptionsLoading)
|
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||||
|
(usesYardField && yardOptionsLoading)
|
||||||
}
|
}
|
||||||
positionOptions={!editing ? createPositionOptions : undefined}
|
positionOptions={!editing ? createPositionOptions : undefined}
|
||||||
positionLoading={createPositionLoading}
|
positionLoading={createPositionLoading}
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export interface FormFieldDef {
|
|||||||
* showWhen and not match hideWhen.
|
* showWhen and not match hideWhen.
|
||||||
*/
|
*/
|
||||||
showWhen?: { field: string; equals: string[] };
|
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<string, unknown>) => boolean;
|
||||||
/**
|
/**
|
||||||
* Select options computed from other fields' current values. When set, the
|
* Select options computed from other fields' current values. When set, the
|
||||||
* form resolves the option list at render time from the live form state
|
* 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" },
|
{ 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<string, unknown>) =>
|
||||||
|
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||||
|
|
||||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -531,6 +553,15 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
columns: [
|
columns: [
|
||||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
{ 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: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
{ 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"),
|
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
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",
|
name: "containerTypeId",
|
||||||
label: "Container type",
|
label: "Container type",
|
||||||
type: "select",
|
type: "select",
|
||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select container type (optional)",
|
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",
|
name: "cargoTypeId",
|
||||||
label: "Bulk cargo type",
|
label: "Bulk cargo type",
|
||||||
type: "select",
|
type: "select",
|
||||||
optional: true,
|
optional: true,
|
||||||
placeholder: "Select bulk commodity (optional)",
|
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" },
|
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityR
|
|||||||
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
|
||||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||||
import AdjustConsistModal from "@/components/trainScheduling/AdjustConsistModal";
|
|
||||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||||
@@ -103,7 +102,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||||
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
|
|
||||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||||
const [gatepassReference, setGatepassReference] = useState("");
|
const [gatepassReference, setGatepassReference] = useState("");
|
||||||
@@ -976,18 +974,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Reschedule train
|
Reschedule train
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{schedule.train && ["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
radius="lg"
|
|
||||||
size="sm"
|
|
||||||
leftSection={<Train size={16} />}
|
|
||||||
onClick={() => setAdjustConsistOpen(true)}
|
|
||||||
>
|
|
||||||
Adjust consist
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
{gatepassApplies ? (
|
{gatepassApplies ? (
|
||||||
gatepassSecured ? (
|
gatepassSecured ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -1212,12 +1198,6 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AdjustConsistModal
|
|
||||||
scheduleId={schedule.id}
|
|
||||||
opened={adjustConsistOpen}
|
|
||||||
onClose={() => setAdjustConsistOpen(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BookingWindowSettingsModal
|
<BookingWindowSettingsModal
|
||||||
scheduleId={scheduleId ?? null}
|
scheduleId={scheduleId ?? null}
|
||||||
opened={windowSettingsOpen}
|
opened={windowSettingsOpen}
|
||||||
|
|||||||
Reference in New Issue
Block a user