mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 17:08:18 +00:00
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules/
|
|||||||
# build output
|
# build output
|
||||||
**/dist/
|
**/dist/
|
||||||
.next/
|
.next/
|
||||||
|
**/out/
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
**/*.tsbuildinfo
|
**/*.tsbuildinfo
|
||||||
@@ -62,3 +63,6 @@ integration/.it-shards.yaml
|
|||||||
*.crt
|
*.crt
|
||||||
secrets/
|
secrets/
|
||||||
certs/
|
certs/
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -32,6 +32,7 @@ copying a pattern across:
|
|||||||
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
|
| `edr-passenger-web/portal` | `@edr/passenger-portal` | **Next.js** | 5174 |
|
||||||
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
|
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | **Next.js** | 5184 |
|
||||||
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
|
| `edr-payment-api` | `@edr/payment-api` | NestJS + **TypeORM** | 3003 |
|
||||||
|
| `edr-landing` | `@edr/landing` | **Next.js** static export | 5163 |
|
||||||
|
|
||||||
Those are the **fallbacks compiled into the code**, not what you will be running. Every
|
Those are the **fallbacks compiled into the code**, not what you will be running. Every
|
||||||
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
|
port is overridden by `PORT` in the app's `.env` / `.env.development`; the freight vite
|
||||||
@@ -43,8 +44,16 @@ the whole team and the low ports are contested — see the workspace root `CLAUD
|
|||||||
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
Each holds a `portal/` and `backoffice/` sub-app, both independent pnpm workspace
|
||||||
packages (see `pnpm-workspace.yaml`).
|
packages (see `pnpm-workspace.yaml`).
|
||||||
|
|
||||||
`apps/edr-landing/` exists on disk but has **no `package.json`** — it is not a workspace
|
`apps/edr-landing/` is the public front door at `edrsc.com`: one static page that routes
|
||||||
package and is not built, linted, or type-checked. Leave it alone unless asked.
|
visitors to the passenger or freight app. It is a Next.js **static export**
|
||||||
|
(`output: 'export'`), so its build artifact is `out/`, not `.next/`. It depends on no
|
||||||
|
workspace package — not even `@edr/ui-common`, whose Tailwind 4 tokens do not fit its
|
||||||
|
Tailwind 3 setup.
|
||||||
|
|
||||||
|
Its two destinations come from `NEXT_PUBLIC_PASSENGER_URL` and `NEXT_PUBLIC_FREIGHT_URL`
|
||||||
|
(each an origin; the entry path is appended in `src/lib/apps.ts`). A static export inlines
|
||||||
|
those at **build** time, so they must be passed as Docker build args — setting them in the
|
||||||
|
runtime environment does nothing.
|
||||||
|
|
||||||
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
|
`apps/edr-gps-tracker/` is a separate service with its own `.env.example`.
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod
|
|||||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||||
|
import { PublicationsModule } from "./modules/publications/publications.module";
|
||||||
import { OtpModule } from "./modules/otp/otp.module";
|
import { OtpModule } from "./modules/otp/otp.module";
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
@@ -108,6 +109,7 @@ import { ExportsModule } from "./modules/exports/exports.module";
|
|||||||
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
import { UserTradeAccessModule } from "./modules/user-trade-access/user-trade-access.module";
|
||||||
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
import { VehiclesModule } from "./modules/vehicles/vehicles.module";
|
||||||
import { DriversModule } from "./modules/drivers/drivers.module";
|
import { DriversModule } from "./modules/drivers/drivers.module";
|
||||||
|
import { TrainCrewModule } from "./modules/train-crew/train-crew.module";
|
||||||
import { FuelModule } from "./modules/fuel/fuel.module";
|
import { FuelModule } from "./modules/fuel/fuel.module";
|
||||||
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
import { MaintenanceModule } from "./modules/maintenance/maintenance.module";
|
||||||
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
import { ComplianceModule } from "./modules/compliance/compliance.module";
|
||||||
@@ -231,6 +233,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
LogoSettingsModule,
|
LogoSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
SupportContentModule,
|
SupportContentModule,
|
||||||
|
PublicationsModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
@@ -250,6 +253,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
|
TrainCrewModule,
|
||||||
FuelModule,
|
FuelModule,
|
||||||
MaintenanceModule,
|
MaintenanceModule,
|
||||||
ComplianceModule,
|
ComplianceModule,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export default registerAs("app", () => ({
|
|||||||
portalBaseUrl: (
|
portalBaseUrl: (
|
||||||
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
process.env.FREIGHT_PORTAL_URL ?? "http://localhost:5173"
|
||||||
).replace(/\/+$/, ""),
|
).replace(/\/+$/, ""),
|
||||||
|
// Train weight/length are not env-configured: they come from locomotive
|
||||||
|
// configuration (see TrainSchedulingService.resolveTrainLimitConfig).
|
||||||
trainScheduling: {
|
trainScheduling: {
|
||||||
maxTrainWeightTons: numberFromEnv("TRAIN_SCHEDULING_MAX_WEIGHT_TONS", 3500),
|
|
||||||
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
|
|
||||||
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
|
||||||
},
|
},
|
||||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||||
|
// An empty-equipment contract resolves to the carriage-only paper.
|
||||||
|
contract.cargoCondition,
|
||||||
);
|
);
|
||||||
dynamicTemplate = dynamicSource
|
dynamicTemplate = dynamicSource
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -96,3 +96,55 @@ describe('ContractRateScheduleBuilder', () => {
|
|||||||
expect(s.isEmpty).toBe(true);
|
expect(s.isEmpty).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty and laden freight are separate tariffs on the same lanes. Each
|
||||||
|
* contract's schedule must show only its own, or the printed paper quotes a
|
||||||
|
* price the customer is not being charged.
|
||||||
|
*/
|
||||||
|
describe('ContractRateScheduleBuilder — empty container contracts', () => {
|
||||||
|
const ladenImport = rate({
|
||||||
|
appliesTo: 'CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
rateType: 'CONTAINER_IMPORT',
|
||||||
|
rateValue: 900,
|
||||||
|
originYard: { label: 'Negad' } as never,
|
||||||
|
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||||
|
containerType: { label: '40ft GP' } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyImport = rate({
|
||||||
|
appliesTo: 'EMPTY_CONTAINER',
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
rateType: 'EMPTY_CONTAINER_IMPORT',
|
||||||
|
rateValue: 250,
|
||||||
|
originYard: { label: 'Negad' } as never,
|
||||||
|
destinationYard: { label: 'Mojo Dry Port' } as never,
|
||||||
|
containerType: { label: '40ft GP' } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const builder = new ContractRateScheduleBuilder({
|
||||||
|
findLiveRatesDetailed: jest.fn().mockResolvedValue([ladenImport, emptyImport]),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
it('shows only the empty lane on an empty contract', async () => {
|
||||||
|
const schedule = await builder.build('IMP', 'CON', 'EMPTY');
|
||||||
|
|
||||||
|
expect(schedule.freightLanes).toHaveLength(1);
|
||||||
|
expect(schedule.freightLanes[0].amount).toBe('250');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows only the laden lane on a laden contract', async () => {
|
||||||
|
const schedule = await builder.build('IMP', 'CON', 'LADEN');
|
||||||
|
|
||||||
|
expect(schedule.freightLanes).toHaveLength(1);
|
||||||
|
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a contract with no condition as laden', async () => {
|
||||||
|
const schedule = await builder.build('IMP', 'CON');
|
||||||
|
|
||||||
|
expect(schedule.freightLanes).toHaveLength(1);
|
||||||
|
expect(schedule.freightLanes[0].amount).toBe('900');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ export class ContractRateScheduleBuilder {
|
|||||||
async build(
|
async build(
|
||||||
direction: ContractDirection,
|
direction: ContractDirection,
|
||||||
freight: ContractFreight,
|
freight: ContractFreight,
|
||||||
|
cargoCondition?: string | null,
|
||||||
): Promise<RateSchedule> {
|
): Promise<RateSchedule> {
|
||||||
|
const isEmpty = cargoCondition === 'EMPTY';
|
||||||
const rates = await this.ratesService.findLiveRatesDetailed();
|
const rates = await this.ratesService.findLiveRatesDetailed();
|
||||||
|
|
||||||
const freightLanes: RateScheduleRow[] = [];
|
const freightLanes: RateScheduleRow[] = [];
|
||||||
@@ -93,7 +95,7 @@ export class ContractRateScheduleBuilder {
|
|||||||
|
|
||||||
for (const rate of rates) {
|
for (const rate of rates) {
|
||||||
if (this.isBaseFreight(rate)) {
|
if (this.isBaseFreight(rate)) {
|
||||||
if (this.baseFreightMatches(rate, direction, freight)) {
|
if (this.baseFreightMatches(rate, direction, freight, isEmpty)) {
|
||||||
freightLanes.push(this.laneRow(rate));
|
freightLanes.push(this.laneRow(rate));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -140,6 +142,7 @@ export class ContractRateScheduleBuilder {
|
|||||||
rate.trigger === 'ALWAYS' &&
|
rate.trigger === 'ALWAYS' &&
|
||||||
(rate.appliesTo === 'BULK' ||
|
(rate.appliesTo === 'BULK' ||
|
||||||
rate.appliesTo === 'CONTAINER' ||
|
rate.appliesTo === 'CONTAINER' ||
|
||||||
|
rate.appliesTo === 'EMPTY_CONTAINER' ||
|
||||||
rate.appliesTo === 'INTERCITY')
|
rate.appliesTo === 'INTERCITY')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -148,7 +151,19 @@ export class ContractRateScheduleBuilder {
|
|||||||
rate: Rate,
|
rate: Rate,
|
||||||
direction: ContractDirection,
|
direction: ContractDirection,
|
||||||
freight: ContractFreight,
|
freight: ContractFreight,
|
||||||
|
isEmpty = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
// Empty and laden are separate tariffs on the same lanes, so each contract
|
||||||
|
// shows only its own. Without this an empty contract would print the laden
|
||||||
|
// lane prices it is not being charged.
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
rate.appliesTo === 'EMPTY_CONTAINER' &&
|
||||||
|
rate.tradeDirection === (direction === 'EXP' ? 'EXPORT' : 'IMPORT')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rate.appliesTo === 'EMPTY_CONTAINER') return false;
|
||||||
|
|
||||||
// Domestic contracts price off intercity rates; the freight kind is carried
|
// Domestic contracts price off intercity rates; the freight kind is carried
|
||||||
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
|
// in the derived rateType (INTERCITY_BULK vs INTERCITY_CONTAINER).
|
||||||
if (direction === 'DOM') {
|
if (direction === 'DOM') {
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ export class ContractViewModelBuilder {
|
|||||||
const rateSchedule = await this.rateScheduleBuilder.build(
|
const rateSchedule = await this.rateScheduleBuilder.build(
|
||||||
template.direction,
|
template.direction,
|
||||||
template.freight,
|
template.freight,
|
||||||
|
// Empty bookings print the empty tariff, never the laden lane prices.
|
||||||
|
booking.cargoCondition,
|
||||||
);
|
);
|
||||||
const signatures = await this.loadSignatures(bookingId);
|
const signatures = await this.loadSignatures(bookingId);
|
||||||
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
const logoImageUrl = await this.logoSettings.getLogoImageUrl();
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public document library for the freight portal (PDFs, Markdown write-ups,
|
||||||
|
* PowerPoint decks about the platform), managed from the backoffice. Each row
|
||||||
|
* is one whole file stored in MinIO under `publications/` — a re-upload
|
||||||
|
* replaces the object and the row's file columns, there is no per-version
|
||||||
|
* history table like `support_documents` has.
|
||||||
|
*/
|
||||||
|
export class Publications3850000000000 implements MigrationInterface {
|
||||||
|
name = 'Publications3850000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.publications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
title varchar(200) NOT NULL,
|
||||||
|
description text,
|
||||||
|
category varchar(60),
|
||||||
|
file_key varchar(512) NOT NULL,
|
||||||
|
file_name varchar(255) NOT NULL,
|
||||||
|
file_mime_type varchar(120) NOT NULL,
|
||||||
|
file_size_bytes bigint NOT NULL,
|
||||||
|
sort_order integer NOT NULL DEFAULT 0,
|
||||||
|
published boolean NOT NULL DEFAULT true,
|
||||||
|
published_at timestamptz,
|
||||||
|
uploaded_by_id uuid,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Serves the public list: published rows in display order.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
|
||||||
|
ON freight.publications (published, sort_order)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
|
||||||
|
* composition). Separate from freight.drivers, which registers road/last-mile
|
||||||
|
* truck drivers and shares none of these fields.
|
||||||
|
*
|
||||||
|
* Role and nationality are stored as varchar rather than PG enums so adding a
|
||||||
|
* crew role later is an application change, not a type migration. The partial
|
||||||
|
* unique index keys on name + role — the roster has no employee number yet, so
|
||||||
|
* that is the only identity available to block an accidental re-entry; it is
|
||||||
|
* scoped to live rows so a soft-deleted member does not hold the name hostage.
|
||||||
|
*/
|
||||||
|
export class TrainCrewMembers3850000000000 implements MigrationInterface {
|
||||||
|
name = 'TrainCrewMembers3850000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
first_name varchar(100) NOT NULL,
|
||||||
|
last_name varchar(100) NOT NULL,
|
||||||
|
role varchar(32) NOT NULL,
|
||||||
|
nationality varchar(16) NOT NULL,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT chk_train_crew_role CHECK (role IN (
|
||||||
|
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
|
||||||
|
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
|
||||||
|
'ETHIOPIAN','DJIBOUTIAN'
|
||||||
|
)),
|
||||||
|
CONSTRAINT chk_train_crew_status CHECK (status IN (
|
||||||
|
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
|
||||||
|
))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
|
||||||
|
ON freight.train_crew_members (role)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
|
||||||
|
ON freight.train_crew_members (nationality)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
|
||||||
|
ON freight.train_crew_members (status)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
|
||||||
|
ON freight.train_crew_members (is_active)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
|
||||||
|
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds DJF to `freight.payments_currency_enum` — the only currency column in
|
||||||
|
* the schema backed by a real Postgres enum (every other currency column is
|
||||||
|
* a plain varchar and needed no migration).
|
||||||
|
*
|
||||||
|
* This statement must be the ONLY thing in its migration: `ALTER TYPE ... ADD
|
||||||
|
* VALUE` cannot be used within the same transaction that added it (Postgres
|
||||||
|
* restriction, still true on PG 12+), and migrations here run one-per-
|
||||||
|
* transaction (`migrationsTransactionMode: 'each'`). Do not add a seed insert
|
||||||
|
* that writes 'DJF' into `payments.currency` to this file.
|
||||||
|
*/
|
||||||
|
export class AddDjfPaymentsCurrency3860000000000 implements MigrationInterface {
|
||||||
|
name = 'AddDjfPaymentsCurrency3860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TYPE freight.payments_currency_enum ADD VALUE IF NOT EXISTS 'DJF'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Postgres cannot drop a single enum value. Reverting would require
|
||||||
|
// recreating the type and every dependent column/constraint — out of
|
||||||
|
// scope for a currency addition; leave it in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
|
||||||
|
*
|
||||||
|
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
|
||||||
|
* splits four drivers across the Dire Dawa boundary, and a driver who is
|
||||||
|
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
|
||||||
|
* roster edit cannot rewrite the crew of a run that already departed.
|
||||||
|
*
|
||||||
|
* The partial unique index on (schedule, segment, duty_role) applies to drivers
|
||||||
|
* only — one segment cannot have two Primaries, while four federal police on
|
||||||
|
* the same run carry no segment or duty role and are unconstrained by it.
|
||||||
|
*/
|
||||||
|
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
|
||||||
|
name = 'TrainCrewAssignments3860000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
train_schedule_id uuid NOT NULL,
|
||||||
|
crew_member_id uuid NOT NULL
|
||||||
|
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
|
||||||
|
role varchar(32) NOT NULL,
|
||||||
|
duty_role varchar(16),
|
||||||
|
segment varchar(24),
|
||||||
|
crewing_case varchar(8),
|
||||||
|
layover_start_at timestamptz,
|
||||||
|
layover_end_at timestamptz,
|
||||||
|
duty_start_at timestamptz,
|
||||||
|
duty_end_at timestamptz,
|
||||||
|
status varchar(16) NOT NULL DEFAULT 'PLANNED',
|
||||||
|
notes text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT chk_crew_assignment_duty_role CHECK (
|
||||||
|
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_segment CHECK (
|
||||||
|
segment IS NULL OR segment IN
|
||||||
|
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_case CHECK (
|
||||||
|
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||||
|
),
|
||||||
|
CONSTRAINT chk_crew_assignment_status CHECK (
|
||||||
|
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
|
||||||
|
ON freight.train_crew_assignments (crew_member_id)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
|
||||||
|
ON freight.train_crew_assignments (status)
|
||||||
|
`);
|
||||||
|
// Nobody holds two seats on one run.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
`);
|
||||||
|
// One Primary (and one Assistant) per segment — drivers only.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||||
|
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
// Monthly overtime rollups scan a member's duty spans (§3.1).
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
|
||||||
|
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
|
||||||
|
WHERE duty_start_at IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the DJF toggle to `manual_payment_settings`, alongside the existing
|
||||||
|
* `etb_enabled`/`usd_enabled` columns. Defaults to `true` — like USD, DJF
|
||||||
|
* invoices are bank-transfer-settleable from day one.
|
||||||
|
*/
|
||||||
|
export class AddDjfManualPaymentSetting3870000000000 implements MigrationInterface {
|
||||||
|
name = 'AddDjfManualPaymentSetting3870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings
|
||||||
|
ADD COLUMN IF NOT EXISTS djf_enabled boolean NOT NULL DEFAULT true;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.manual_payment_settings DROP COLUMN IF EXISTS djf_enabled;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop `crewing_case` from train crew assignments.
|
||||||
|
*
|
||||||
|
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
|
||||||
|
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
|
||||||
|
* its own need instead — any number of drivers, each carrying their own segment
|
||||||
|
* and duty role — so the case has nothing left to select and the column no
|
||||||
|
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
|
||||||
|
* per-driver rule and still enforced.
|
||||||
|
*/
|
||||||
|
export class DropCrewingCase3870000000000 implements MigrationInterface {
|
||||||
|
name = 'DropCrewingCase3870000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP COLUMN IF EXISTS crewing_case
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD CONSTRAINT chk_crew_assignment_case CHECK (
|
||||||
|
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver legs become yard-to-yard instead of three fixed corridor segments.
|
||||||
|
*
|
||||||
|
* The old `segment` enum could only express Indode–Dire Dawa, Dire Dawa–Nagad
|
||||||
|
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
|
||||||
|
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
|
||||||
|
* places), so a leg is now any two yards on the schedule's route.
|
||||||
|
*
|
||||||
|
* `segment` is kept, nullable, so rows written before this still read back; it
|
||||||
|
* is never populated again. The §1.1 territorial boundary is unaffected — it
|
||||||
|
* now derives from yard position rather than the segment name, so a Djibouti
|
||||||
|
* driver is still confined to Dire Dawa and eastward.
|
||||||
|
*
|
||||||
|
* The driver-slot unique index moves with it: one Primary per leg, where a leg
|
||||||
|
* is the (from, to) pair rather than a segment name.
|
||||||
|
*/
|
||||||
|
export class CrewLegYards3880000000000 implements MigrationInterface {
|
||||||
|
name = 'CrewLegYards3880000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
|
||||||
|
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill the three legacy segments onto real yards so historic rows keep
|
||||||
|
// a usable leg. Matched by code; a deployment missing one simply leaves
|
||||||
|
// those rows with a null leg, which the validator reports as incomplete.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'INDODE_DIRE_DAWA'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'DIRE_DAWA_NAGAD'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.train_crew_assignments a
|
||||||
|
SET from_yard_id = f.id, to_yard_id = t.id
|
||||||
|
FROM freight.yards f, freight.yards t
|
||||||
|
WHERE a.segment = 'FULL_CORRIDOR'
|
||||||
|
AND a.from_yard_id IS NULL
|
||||||
|
AND f.code = 'KALITY' AND t.code = 'NAGAD'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// One Primary per leg replaces one Primary per segment.
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
|
||||||
|
ON freight.train_crew_assignments
|
||||||
|
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND from_yard_id IS NOT NULL
|
||||||
|
AND to_yard_id IS NOT NULL
|
||||||
|
AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
|
||||||
|
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
|
||||||
|
WHERE from_yard_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||||
|
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||||
|
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_crew_assignments
|
||||||
|
DROP COLUMN IF EXISTS from_yard_id,
|
||||||
|
DROP COLUMN IF EXISTS to_yard_id
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `exchange_settings` was a single-row table holding the USD→ETB fallback
|
||||||
|
* only. Restructures it to one row per currency so DJF (and any future
|
||||||
|
* currency) gets its own fallback rate, source and sync timestamp instead of
|
||||||
|
* a parallel column per currency.
|
||||||
|
*/
|
||||||
|
export class ExchangeSettingsPerCurrency3880000000000 implements MigrationInterface {
|
||||||
|
name = 'ExchangeSettingsPerCurrency3880000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.exchange_settings ADD COLUMN IF NOT EXISTS currency varchar(5);
|
||||||
|
`);
|
||||||
|
// The single pre-existing row was always the USD→ETB fallback.
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.exchange_settings SET currency = 'USD' WHERE currency IS NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.exchange_settings ALTER COLUMN currency SET NOT NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_exchange_settings_currency
|
||||||
|
ON freight.exchange_settings (currency) WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
// Seed the DJF row at the CBE-quoted DJF→ETB rate observed 2026-09-04, so
|
||||||
|
// pricing has a usable fallback before the first successful CBE fetch.
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.exchange_settings (id, currency, fallback_rate, fallback_source, created_at, updated_at)
|
||||||
|
SELECT uuid_generate_v4(), 'DJF', 0.9203, 'AUTO', now(), now()
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.exchange_settings WHERE currency = 'DJF');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DELETE FROM freight.exchange_settings WHERE currency = 'DJF'`);
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_exchange_settings_currency`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.exchange_settings ALTER COLUMN currency DROP NOT NULL`);
|
||||||
|
await queryRunner.query(`ALTER TABLE freight.exchange_settings DROP COLUMN IF EXISTS currency`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty container import is base rail freight for equipment carrying no cargo,
|
||||||
|
* so it is sold per lane exactly like laden container freight.
|
||||||
|
*
|
||||||
|
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
|
||||||
|
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
|
||||||
|
* Drop-and-recreate is the established shape for this constraint — see
|
||||||
|
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
|
||||||
|
*/
|
||||||
|
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
|
||||||
|
name = 'EmptyContainerRateScope3890000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||||
|
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||||
|
CASE
|
||||||
|
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||||
|
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||||
|
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||||
|
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a booking moves cargo or bare equipment.
|
||||||
|
*
|
||||||
|
* EMPTY is container freight carrying nothing — the box itself is the shipment,
|
||||||
|
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
|
||||||
|
* separate column rather than a third `freight_type`: an empty booking is still
|
||||||
|
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
|
||||||
|
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
|
||||||
|
* places whose else-arm means "container".
|
||||||
|
*
|
||||||
|
* Every existing row is LADEN, which the default supplies — no backfill needed.
|
||||||
|
*/
|
||||||
|
export class BookingCargoCondition3900000000000 implements MigrationInterface {
|
||||||
|
name = 'BookingCargoCondition3900000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
|
||||||
|
`);
|
||||||
|
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
|
||||||
|
// freight. Enforced here so no API path can file the combination.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
|
||||||
|
cargo_condition IN ('LADEN', 'EMPTY')
|
||||||
|
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// The booking queues filter empties out of (and into) the laden lists.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
|
||||||
|
ON freight.bookings (cargo_condition)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract paper for empty container import.
|
||||||
|
*
|
||||||
|
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
|
||||||
|
* contract can commit to moving bare equipment.
|
||||||
|
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
|
||||||
|
* resolves for those contracts. It carries no customs variant: an empty box
|
||||||
|
* has no declaration to clear, the same reason intercity is unsuffixed.
|
||||||
|
*/
|
||||||
|
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
|
||||||
|
|
||||||
|
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
|
||||||
|
name = 'EmptyContainerContractTemplate3910000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
|
||||||
|
cargo_condition IN ('LADEN', 'EMPTY')
|
||||||
|
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const code of SEEDED_CODES) {
|
||||||
|
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||||
|
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO freight.contract_templates
|
||||||
|
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||||
|
is_active, is_system, created_at, updated_at)
|
||||||
|
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||||
|
true, true, now(), now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.contract_templates
|
||||||
|
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||||
|
)`,
|
||||||
|
[
|
||||||
|
seed.code,
|
||||||
|
seed.name,
|
||||||
|
seed.description,
|
||||||
|
seed.documentTitle,
|
||||||
|
JSON.stringify(seed.whereasClauses),
|
||||||
|
JSON.stringify(
|
||||||
|
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
|
||||||
|
[[...SEEDED_CODES]],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-requested validity extension of an EXPIRED contract.
|
||||||
|
*
|
||||||
|
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
|
||||||
|
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
|
||||||
|
* add days on the backoffice detail page and the contract returns to the status
|
||||||
|
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
|
||||||
|
* read) now stash that status in `status_before_expiry`, mirroring
|
||||||
|
* `status_before_suspension`; rows expired before this column existed fall
|
||||||
|
* back to the kind's resting status on extension.
|
||||||
|
*
|
||||||
|
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
|
||||||
|
* resolves every registry key against `iam.permissions` at boot and throws on
|
||||||
|
* a missing row, so the catalog row must exist wherever the registry ships.
|
||||||
|
* The grant is copied from whoever already holds `contracts:suspend` — the
|
||||||
|
* registry places both keys on the same desk (marketing), and the position
|
||||||
|
* seeder only re-syncs presets when SEED_EDR_ORG is set.
|
||||||
|
*/
|
||||||
|
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
|
||||||
|
name = 'ContractExtensionRequest3920000000000';
|
||||||
|
|
||||||
|
private static readonly KEY = 'edr_freight_app:contracts:extend';
|
||||||
|
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
|
||||||
|
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.contracts
|
||||||
|
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||||
|
SELECT $2::uuid,
|
||||||
|
$1::varchar,
|
||||||
|
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
|
||||||
|
a.id
|
||||||
|
FROM iam.application a
|
||||||
|
WHERE a.key = 'edr_freight_app'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant wherever suspend is already granted (positions and roles alike).
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.position_permissions (position_id, permission_id)
|
||||||
|
SELECT pp.position_id, np.id
|
||||||
|
FROM iam.position_permissions pp
|
||||||
|
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
|
||||||
|
JOIN iam.permissions np ON np.key = $1::varchar
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM iam.position_permissions x
|
||||||
|
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
|
||||||
|
)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO iam.role_permissions (role_id, permission_id)
|
||||||
|
SELECT rp.role_id, np.id
|
||||||
|
FROM iam.role_permissions rp
|
||||||
|
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
|
||||||
|
JOIN iam.permissions np ON np.key = $1::varchar
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM iam.role_permissions x
|
||||||
|
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
|
||||||
|
)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grants go first, or the delete trips the permission foreign keys. */
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.position_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DELETE FROM iam.role_permissions
|
||||||
|
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||||
|
[ContractExtensionRequest3920000000000.KEY],
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||||
|
ContractExtensionRequest3920000000000.KEY,
|
||||||
|
]);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ export class FilterInvoiceDto {
|
|||||||
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
@ApiPropertyOptional({ enum: ["USD", "ETB"] })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
@Transform(({ value }: { value: unknown }) => String(value).toUpperCase())
|
||||||
@IsIn(["USD", "ETB"])
|
@IsIn(["ETB", "USD", "DJF"])
|
||||||
currency?: "USD" | "ETB";
|
currency?: "USD" | "ETB";
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
|
@ApiPropertyOptional({ description: "Issued at or after this instant (ISO)." })
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { DataSource, EntityManager } from 'typeorm';
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
@@ -291,20 +291,24 @@ export class AdditionalChargeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Amount converted to the other of ETB/USD, via the existing shared
|
* Amount converted to a second reference currency, via the existing shared
|
||||||
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
|
* `ExchangeService` (CBE rate, falls back to the stored `exchange_settings`
|
||||||
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
|
* rate) — same mechanism `booking-wagon-cancellation.service.ts` and
|
||||||
* warehouse fee pricing already use. Null on anything but ETB/USD, or if
|
* warehouse fee pricing already use. ETB converts to USD and vice versa
|
||||||
|
* (unchanged behaviour); any other supported currency (DJF) converts to
|
||||||
|
* USD, the system's pivot currency. Null on an unsupported currency, or if
|
||||||
* the rate feed is down — this is a display convenience, not the payable
|
* the rate feed is down — this is a display convenience, not the payable
|
||||||
* amount, so a failure here must never break the charge list.
|
* amount, so a failure here must never break the charge list.
|
||||||
*/
|
*/
|
||||||
private async convertAmount(
|
private async convertAmount(
|
||||||
charge: AdditionalCharge,
|
charge: AdditionalCharge,
|
||||||
): Promise<{ amount: number; currency: string } | null> {
|
): Promise<{ amount: number; currency: string } | null> {
|
||||||
if (charge.currency !== 'ETB' && charge.currency !== 'USD') return null;
|
const from = charge.currency?.toUpperCase();
|
||||||
const target = charge.currency === 'ETB' ? 'USD' : 'ETB';
|
if (!(CURRENCY_CODES as readonly string[]).includes(from ?? '')) return null;
|
||||||
|
const source = from as CurrencyCode;
|
||||||
|
const target: CurrencyCode = source === 'ETB' ? 'USD' : source === 'USD' ? 'ETB' : 'USD';
|
||||||
try {
|
try {
|
||||||
const amount = await this.exchangeService.convert(Number(charge.amount), charge.currency, target);
|
const amount = await this.exchangeService.convert(Number(charge.amount), source, target);
|
||||||
return { amount: Math.round(amount * 100) / 100, currency: target };
|
return { amount: Math.round(amount * 100) / 100, currency: target };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
|
this.logger.warn(`Rate conversion failed for charge ${charge.id}: ${(err as Error).message}`);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
import { CARGO_CONDITIONS, CargoCondition, FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
||||||
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
|
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
|
||||||
|
|
||||||
/** Normalize and validate booking freight shape (used on create and after update merge). */
|
/** Normalize and validate booking freight shape (used on create and after update merge). */
|
||||||
@@ -12,10 +12,25 @@ export function assertFreightShape(input: BookingFreightShapeInput): void {
|
|||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|
||||||
|
const condition = input.cargoCondition ?? 'LADEN';
|
||||||
|
if (!CARGO_CONDITIONS.includes(condition as CargoCondition)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`cargoCondition must be one of: ${CARGO_CONDITIONS.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const containers = input.containers ?? [];
|
const containers = input.containers ?? [];
|
||||||
const hasContainers = containers.length > 0;
|
const hasContainers = containers.length > 0;
|
||||||
const hasCargoType = Boolean(input.cargoTypeId);
|
const hasCargoType = Boolean(input.cargoTypeId);
|
||||||
|
|
||||||
|
// Empty means bare equipment: there is no commodity to name, and bulk has no
|
||||||
|
// equipment of its own to move, so EMPTY only ever rides CONTAINER freight.
|
||||||
|
if (condition === 'EMPTY' && input.freightType !== 'CONTAINER') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An empty booking must be CONTAINER freight — bulk carries no equipment',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.freightType === 'BULK') {
|
if (input.freightType === 'BULK') {
|
||||||
if (hasContainers) {
|
if (hasContainers) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
let service: BookingPricingService;
|
let service: BookingPricingService;
|
||||||
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
let bookingsRepository: { calculateWagonCount: jest.Mock };
|
||||||
let ratesService: { findLiveRates: jest.Mock };
|
let ratesService: { findLiveRates: jest.Mock };
|
||||||
let exchangeService: { getRate: jest.Mock };
|
let exchangeService: { getRate: jest.Mock; getRateTable: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
|
||||||
@@ -47,6 +47,13 @@ describe('BookingPricingService — domestic corridor', () => {
|
|||||||
};
|
};
|
||||||
exchangeService = {
|
exchangeService = {
|
||||||
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||||
|
// Delegates to `getRate` so a test that reassigns
|
||||||
|
// `exchangeService.getRate.mockResolvedValue(...)` gets a consistent
|
||||||
|
// rate table without also having to touch this mock.
|
||||||
|
getRateTable: jest.fn(async (target: string) => {
|
||||||
|
const rate = await exchangeService.getRate('USD', target);
|
||||||
|
return { ETB: rate, USD: rate, DJF: rate };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new BookingPricingService(
|
service = new BookingPricingService(
|
||||||
@@ -324,7 +331,7 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
|||||||
})),
|
})),
|
||||||
} as never,
|
} as never,
|
||||||
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
{ findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never,
|
||||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{
|
{
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
@@ -572,7 +579,7 @@ describe('BookingPricingService — bulk base freight units', () => {
|
|||||||
} as never,
|
} as never,
|
||||||
{ findById: jest.fn() } as never,
|
{ findById: jest.fn() } as never,
|
||||||
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
||||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{
|
{
|
||||||
findById: jest.fn().mockResolvedValue({
|
findById: jest.fn().mockResolvedValue({
|
||||||
@@ -707,7 +714,7 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
|||||||
})),
|
})),
|
||||||
} as never,
|
} as never,
|
||||||
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
|
{ findLiveRates: jest.fn().mockResolvedValue([perWagon20, perContainer40]) } as never,
|
||||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: MOCK_CBE_RATE, DJF: MOCK_CBE_RATE }) } as never,
|
||||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
{ findById: jest.fn() } as never,
|
{ findById: jest.fn() } as never,
|
||||||
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
@@ -772,3 +779,124 @@ describe('BookingPricingService — PER_WAGON container freight', () => {
|
|||||||
expect(line.amount).toBe(3 * 1690);
|
expect(line.amount).toBe(3 * 1690);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty container import is bare equipment moved as freight in its own right.
|
||||||
|
* It has to price off EMPTY_CONTAINER_IMPORT, never the laden CONTAINER_IMPORT
|
||||||
|
* rate for the same lane and box — the two are separate tariffs, and
|
||||||
|
* UQ_rates_pattern only lets both exist because the rateType differs.
|
||||||
|
*/
|
||||||
|
describe('BookingPricingService — empty container import', () => {
|
||||||
|
const DJIBOUTI = 'yard-djibouti';
|
||||||
|
const CT40 = 'ct-40ft';
|
||||||
|
|
||||||
|
const ladenImport40: Rate = {
|
||||||
|
id: 'rate-container-import-40',
|
||||||
|
rateType: 'CONTAINER_IMPORT',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 900,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: CT40,
|
||||||
|
originYardId: DJIBOUTI,
|
||||||
|
destinationYardId: MOJO,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
const emptyImport40: Rate = {
|
||||||
|
id: 'rate-empty-container-import-40',
|
||||||
|
rateType: 'EMPTY_CONTAINER_IMPORT',
|
||||||
|
currency: 'USD',
|
||||||
|
rateValue: 250,
|
||||||
|
rateUnit: 'PER_CONTAINER',
|
||||||
|
status: 'LIVE',
|
||||||
|
containerTypeId: CT40,
|
||||||
|
originYardId: DJIBOUTI,
|
||||||
|
destinationYardId: MOJO,
|
||||||
|
} as Rate;
|
||||||
|
|
||||||
|
let service: BookingPricingService;
|
||||||
|
|
||||||
|
const priceLines = (booking: Booking) =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
computeBaseRailLinesWithRates: (
|
||||||
|
b: Booking,
|
||||||
|
input: { containers: Array<{ containerTypeId: string; quantity: number; wagonsPerUnit: number }> },
|
||||||
|
) => Promise<{
|
||||||
|
lineItems: Array<{ code: string; amount: number; description: string }>;
|
||||||
|
blocked: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
).computeBaseRailLinesWithRates(booking, {
|
||||||
|
containers: [{ containerTypeId: CT40, quantity: 4, wagonsPerUnit: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingWith = (cargoCondition: string) =>
|
||||||
|
({
|
||||||
|
id: 'b-empty-1',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoCondition,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
// Bare equipment declares no VGM — the service zeroes it at create.
|
||||||
|
cargoTotalWeightVgm: 0,
|
||||||
|
originYardId: DJIBOUTI,
|
||||||
|
destinationYardId: MOJO,
|
||||||
|
bookingContainers: [],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const exchangeService = {
|
||||||
|
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
|
||||||
|
getRateTable: jest.fn().mockResolvedValue({ ETB: MOCK_CBE_RATE, USD: 1, DJF: 1 }),
|
||||||
|
};
|
||||||
|
service = new BookingPricingService(
|
||||||
|
{ calculateWagonCount: jest.fn().mockResolvedValue(4) } as never,
|
||||||
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ sizeFt: 40, label: '40ft' }) } as never,
|
||||||
|
{ findLiveRates: jest.fn().mockResolvedValue([ladenImport40, emptyImport40]) } as never,
|
||||||
|
exchangeService as never,
|
||||||
|
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||||
|
{} as never,
|
||||||
|
{ findById: jest.fn().mockResolvedValue({ includesEthiopianCustomsOnly: false }) } as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prices an empty booking off the empty tariff, not the laden one', async () => {
|
||||||
|
const result = await priceLines(bookingWith('EMPTY'));
|
||||||
|
|
||||||
|
expect(result.lineItems).toHaveLength(1);
|
||||||
|
expect(result.lineItems[0].code).toBe('EMPTY_CONTAINER_IMPORT');
|
||||||
|
expect(result.lineItems[0].amount).toBe(250 * 4);
|
||||||
|
expect(result.lineItems[0].description).toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves laden bookings on the laden tariff', async () => {
|
||||||
|
const result = await priceLines(bookingWith('LADEN'));
|
||||||
|
|
||||||
|
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
|
||||||
|
expect(result.lineItems[0].amount).toBe(900 * 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a booking with no condition set as laden', async () => {
|
||||||
|
const booking = bookingWith('LADEN');
|
||||||
|
delete (booking as unknown as Record<string, unknown>).cargoCondition;
|
||||||
|
|
||||||
|
const result = await priceLines(booking);
|
||||||
|
|
||||||
|
expect(result.lineItems[0].code).toBe('CONTAINER_IMPORT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hard-blocks an empty booking on a lane with no empty rate configured', async () => {
|
||||||
|
(
|
||||||
|
service as unknown as { ratesService: { findLiveRates: jest.Mock } }
|
||||||
|
).ratesService.findLiveRates.mockResolvedValue([ladenImport40]);
|
||||||
|
|
||||||
|
const result = await priceLines(bookingWith('EMPTY'));
|
||||||
|
|
||||||
|
// Never silently fall through to the laden rate — that would bill an empty
|
||||||
|
// repositioning move at 900/box instead of 250.
|
||||||
|
expect(result.lineItems).toHaveLength(0);
|
||||||
|
expect(result.blocked[0]).toContain('EMPTY_CONTAINER_IMPORT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Rate } from '../rule-engine/entities/rate.entity';
|
|||||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||||
import { round2 } from '../billing/invoice-settlement.util';
|
import { round2 } from '../billing/invoice-settlement.util';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||||
import {
|
import {
|
||||||
AppliedCargoModifier,
|
AppliedCargoModifier,
|
||||||
BookingEvaluationInput,
|
BookingEvaluationInput,
|
||||||
@@ -143,8 +143,9 @@ export class BookingPricingService {
|
|||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency !== 'USD';
|
||||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||||
|
const usdToEtb = fx['USD'];
|
||||||
|
|
||||||
// H15: a booking created under a contract prices from that contract's FROZEN
|
// H15: a booking created under a contract prices from that contract's FROZEN
|
||||||
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
// rate snapshots (the agreed rates), not the live rate of the day. Loaded
|
||||||
@@ -213,7 +214,7 @@ export class BookingPricingService {
|
|||||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||||
const frozen = isDerived
|
const frozen = isDerived
|
||||||
? null
|
? null
|
||||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, fx);
|
||||||
const unitAmount = frozen
|
const unitAmount = frozen
|
||||||
? Number(frozen.unitPrice)
|
? Number(frozen.unitPrice)
|
||||||
: isEtbBooking
|
: isEtbBooking
|
||||||
@@ -248,8 +249,10 @@ export class BookingPricingService {
|
|||||||
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
// box or per wagon), bulk bookings the route's bulk fee (per ton or per
|
||||||
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
// wagon). Frozen contract snapshots win over live rates; a customs booking
|
||||||
// with nothing configured hard-blocks — clearance never ships for free.
|
// with nothing configured hard-blocks — clearance never ships for free.
|
||||||
|
// An empty box carries no declaration and no duty, so there is no clearance
|
||||||
|
// to sell even if a customs-bundled service type was somehow selected.
|
||||||
const clearanceBlocked: string[] = [];
|
const clearanceBlocked: string[] = [];
|
||||||
if (booking.customsClearingEnabled) {
|
if (booking.customsClearingEnabled && booking.cargoCondition !== 'EMPTY') {
|
||||||
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates);
|
||||||
for (const line of clearance.lineItems) {
|
for (const line of clearance.lineItems) {
|
||||||
lineItems.push(line);
|
lineItems.push(line);
|
||||||
@@ -570,12 +573,21 @@ export class BookingPricingService {
|
|||||||
}> {
|
}> {
|
||||||
const liveRates = await this.liveRatesForBooking(booking);
|
const liveRates = await this.liveRatesForBooking(booking);
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency !== 'USD';
|
||||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||||
|
const usdToEtb = fx['USD'];
|
||||||
const isBulk = booking.freightType === 'BULK';
|
const isBulk = booking.freightType === 'BULK';
|
||||||
|
// Bare equipment prices off its own tariff. It has to be a distinct
|
||||||
|
// rateType, not a cheaper CONTAINER_IMPORT row: UQ_rates_pattern keys on
|
||||||
|
// rate_type without applies_to, so an empty 40ft rate on a lane would
|
||||||
|
// collide with the laden 40ft rate for that same lane.
|
||||||
|
const isEmpty = booking.cargoCondition === 'EMPTY';
|
||||||
|
|
||||||
const rateType =
|
const rateType = isEmpty
|
||||||
booking.tradeDirection === 'IMPORT'
|
? booking.tradeDirection === 'EXPORT'
|
||||||
|
? 'EMPTY_CONTAINER_EXPORT'
|
||||||
|
: 'EMPTY_CONTAINER_IMPORT'
|
||||||
|
: booking.tradeDirection === 'IMPORT'
|
||||||
? isBulk
|
? isBulk
|
||||||
? 'BULK_IMPORT'
|
? 'BULK_IMPORT'
|
||||||
: 'CONTAINER_IMPORT'
|
: 'CONTAINER_IMPORT'
|
||||||
@@ -608,7 +620,7 @@ export class BookingPricingService {
|
|||||||
frozenRates,
|
frozenRates,
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
usdToEtb,
|
fx,
|
||||||
);
|
);
|
||||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||||
if (!rate && !frozen) {
|
if (!rate && !frozen) {
|
||||||
@@ -649,7 +661,7 @@ export class BookingPricingService {
|
|||||||
if (rate) usedRatesMap.set(rate.id, rate);
|
if (rate) usedRatesMap.set(rate.id, rate);
|
||||||
lines.push({
|
lines.push({
|
||||||
code: rateType,
|
code: rateType,
|
||||||
description: `${label} rail freight`,
|
description: isEmpty ? `${label} empty rail freight` : `${label} rail freight`,
|
||||||
amount,
|
amount,
|
||||||
unitAmount,
|
unitAmount,
|
||||||
unit: rateUnit,
|
unit: rateUnit,
|
||||||
@@ -698,7 +710,7 @@ export class BookingPricingService {
|
|||||||
const unitUsd = Number(fallback.rateValue);
|
const unitUsd = Number(fallback.rateValue);
|
||||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||||
const frozen = isBulk
|
const frozen = isBulk
|
||||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, fx)
|
||||||
: null;
|
: null;
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -771,8 +783,9 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
const liveRates = await this.liveRatesForBooking(booking);
|
const liveRates = await this.liveRatesForBooking(booking);
|
||||||
const paymentCurrency = booking.paymentCurrency;
|
const paymentCurrency = booking.paymentCurrency;
|
||||||
const isEtbBooking = paymentCurrency === 'ETB';
|
const isEtbBooking = paymentCurrency !== 'USD';
|
||||||
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const fx = await this.exchangeService.getRateTable(paymentCurrency as CurrencyCode);
|
||||||
|
const usdToEtb = fx['USD'];
|
||||||
|
|
||||||
const containerCount = evalInput.containers.reduce(
|
const containerCount = evalInput.containers.reduce(
|
||||||
(sum, c) => sum + Number(c.quantity || 0),
|
(sum, c) => sum + Number(c.quantity || 0),
|
||||||
@@ -824,7 +837,7 @@ export class BookingPricingService {
|
|||||||
frozenRates,
|
frozenRates,
|
||||||
leg.rateType,
|
leg.rateType,
|
||||||
paymentCurrency,
|
paymentCurrency,
|
||||||
usdToEtb,
|
fx,
|
||||||
);
|
);
|
||||||
let amount: number;
|
let amount: number;
|
||||||
let unitAmount: number;
|
let unitAmount: number;
|
||||||
@@ -1021,13 +1034,18 @@ export class BookingPricingService {
|
|||||||
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
||||||
* reason.
|
* reason.
|
||||||
*
|
*
|
||||||
|
* `fx` is a rate table converting FROM each source currency INTO the
|
||||||
|
* booking's currency (see `ExchangeService.getRateTable`) — a snapshot can
|
||||||
|
* be frozen in USD or (grandfathered) ETB, and the booking can be paid in
|
||||||
|
* any supported currency, so a scalar USD→ETB rate is no longer enough.
|
||||||
|
*
|
||||||
* Returns null only when there is no snapshot or its price is unusable.
|
* Returns null only when there is no snapshot or its price is unusable.
|
||||||
*/
|
*/
|
||||||
private frozenRateByCode(
|
private frozenRateByCode(
|
||||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
code: string,
|
code: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
usdToEtb: number,
|
fx: Record<string, number>,
|
||||||
): ContractRateSnapshot | null {
|
): ContractRateSnapshot | null {
|
||||||
const snap = frozenRates?.get(code);
|
const snap = frozenRates?.get(code);
|
||||||
if (!snap) return null;
|
if (!snap) return null;
|
||||||
@@ -1035,15 +1053,11 @@ export class BookingPricingService {
|
|||||||
if (!(unitPrice >= 0)) return null;
|
if (!(unitPrice >= 0)) return null;
|
||||||
if (snap.currency === bookingCurrency) return snap;
|
if (snap.currency === bookingCurrency) return snap;
|
||||||
|
|
||||||
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
|
// A rate of 0/NaN (an unpriced or unsupported source currency) would
|
||||||
if (!(usdToEtb > 0)) return null;
|
// silently zero the price.
|
||||||
const converted =
|
const rate = fx[snap.currency];
|
||||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
if (!(rate > 0)) return null;
|
||||||
? round2(unitPrice * usdToEtb)
|
const converted = round2(unitPrice * rate);
|
||||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
|
||||||
? unitPrice / usdToEtb
|
|
||||||
: null;
|
|
||||||
if (converted == null) return null;
|
|
||||||
|
|
||||||
// A copy — the snapshot rows are shared across the pricing pass.
|
// A copy — the snapshot rows are shared across the pricing pass.
|
||||||
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
||||||
@@ -1061,7 +1075,7 @@ export class BookingPricingService {
|
|||||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||||
containerTypeId: string,
|
containerTypeId: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
usdToEtb: number,
|
fx: Record<string, number>,
|
||||||
): Promise<ContractRateSnapshot | null> {
|
): Promise<ContractRateSnapshot | null> {
|
||||||
if (!frozenRates) return null;
|
if (!frozenRates) return null;
|
||||||
let sizeFt: number | null = null;
|
let sizeFt: number | null = null;
|
||||||
@@ -1071,7 +1085,7 @@ export class BookingPricingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!sizeFt) return null;
|
if (!sizeFt) return null;
|
||||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
|
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, fx);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1093,9 +1107,9 @@ export class BookingPricingService {
|
|||||||
const usedRates: Rate[] = [];
|
const usedRates: Rate[] = [];
|
||||||
const blocked: string[] = [];
|
const blocked: string[] = [];
|
||||||
const currency = booking.paymentCurrency;
|
const currency = booking.paymentCurrency;
|
||||||
const isEtb = currency === 'ETB';
|
const fx = await this.exchangeService.getRateTable(currency as CurrencyCode);
|
||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
const usdToEtb = fx['USD'];
|
||||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToEtb));
|
||||||
|
|
||||||
// An Ethiopian-side-only customs service prices off its own rate; the
|
// An Ethiopian-side-only customs service prices off its own rate; the
|
||||||
// contract froze its snapshots under the matching code prefix. Resolved by
|
// contract froze its snapshots under the matching code prefix. Resolved by
|
||||||
@@ -1132,7 +1146,7 @@ export class BookingPricingService {
|
|||||||
const hasPerSizeSnapshot =
|
const hasPerSizeSnapshot =
|
||||||
frozenRates?.has(`${customsType}_20FT`) ||
|
frozenRates?.has(`${customsType}_20FT`) ||
|
||||||
frozenRates?.has(`${customsType}_40FT`);
|
frozenRates?.has(`${customsType}_40FT`);
|
||||||
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
const legacyFlat = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||||
const amount = Number(legacyFlat.unitPrice);
|
const amount = Number(legacyFlat.unitPrice);
|
||||||
if (amount > 0) {
|
if (amount > 0) {
|
||||||
@@ -1161,7 +1175,7 @@ export class BookingPricingService {
|
|||||||
// unknown type — falls through to the live per-type lookup below
|
// unknown type — falls through to the live per-type lookup below
|
||||||
}
|
}
|
||||||
const frozen = sizeFt
|
const frozen = sizeFt
|
||||||
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, usdToEtb)
|
? this.frozenRateByCode(frozenRates, `${customsType}_${sizeFt}FT`, currency, fx)
|
||||||
: null;
|
: null;
|
||||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||||
if (!frozen && !live) {
|
if (!frozen && !live) {
|
||||||
@@ -1196,7 +1210,7 @@ export class BookingPricingService {
|
|||||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||||
// commodity-less rate (legacy) is the catch-all fallback.
|
// commodity-less rate (legacy) is the catch-all fallback.
|
||||||
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, usdToEtb);
|
const frozen = this.frozenRateByCode(frozenRates, customsType, currency, fx);
|
||||||
const live =
|
const live =
|
||||||
(booking.cargoTypeId
|
(booking.cargoTypeId
|
||||||
? onLeg.find(
|
? onLeg.find(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||||
|
|
||||||
@@ -114,6 +114,15 @@ interface PricedFee {
|
|||||||
* The cycle is repeatable by construction: the rebooked booking is a normal
|
* The cycle is repeatable by construction: the rebooked booking is a normal
|
||||||
* PAID booking, so it can itself be partially cancelled again.
|
* PAID booking, so it can itself be partially cancelled again.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Validates a stored currency string against the supported set, defaulting to USD. */
|
||||||
|
function toCurrencyCode(currency?: string | null): CurrencyCode {
|
||||||
|
const code = currency?.toUpperCase();
|
||||||
|
return (CURRENCY_CODES as readonly string[]).includes(code ?? '')
|
||||||
|
? (code as CurrencyCode)
|
||||||
|
: 'USD';
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingWagonCancellationService {
|
export class BookingWagonCancellationService {
|
||||||
private readonly logger = new Logger(BookingWagonCancellationService.name);
|
private readonly logger = new Logger(BookingWagonCancellationService.name);
|
||||||
@@ -1697,10 +1706,10 @@ export class BookingWagonCancellationService {
|
|||||||
*/
|
*/
|
||||||
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
|
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
|
||||||
const raw = await this.priceFeeInRateCurrency(booking, cut);
|
const raw = await this.priceFeeInRateCurrency(booking, cut);
|
||||||
// Bill in the booking's own currency (rates are configured in USD; ETB
|
// Bill in the booking's own currency (rates are configured in USD; a
|
||||||
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
|
// non-USD booking converts) — same conversion booking pricing applies.
|
||||||
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
const target = toCurrencyCode(booking.paymentCurrency);
|
||||||
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
|
const from = toCurrencyCode(raw.currency);
|
||||||
if (from === target) return raw;
|
if (from === target) return raw;
|
||||||
const fx = await this.exchangeService.getRate(from, target);
|
const fx = await this.exchangeService.getRate(from, target);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1106,11 +1106,13 @@ ${footer}
|
|||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
containerLines.map(async (c) => {
|
containerLines.map(async (c) => {
|
||||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
// Optional on the DTO — an empty booking states no VGM at all.
|
||||||
|
const vgmPerUnitTons = Number(c.vgmPerUnitTons ?? 0);
|
||||||
|
const totalVgmTons = c.quantity * vgmPerUnitTons;
|
||||||
return {
|
return {
|
||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
vgmPerUnitTons,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
isReefer: ct.isReefer,
|
isReefer: ct.isReefer,
|
||||||
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
|
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
|
||||||
@@ -1375,13 +1377,25 @@ ${footer}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const containers = dto.containers ?? [];
|
const cargoCondition = dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN';
|
||||||
|
const isEmpty = cargoCondition === 'EMPTY';
|
||||||
assertFreightShape({
|
assertFreightShape({
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
|
cargoCondition,
|
||||||
cargoTypeId: dto.cargoTypeId,
|
cargoTypeId: dto.cargoTypeId,
|
||||||
containers,
|
containers: dto.containers ?? [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bare equipment declares no VGM. Zero the lines HERE, before the rule
|
||||||
|
// engine sees them, so weight-limit and overweight evaluation, the wagon
|
||||||
|
// estimate, the persisted rows and every tonnage aggregate downstream all
|
||||||
|
// read the same figure — a stray VGM on an empty line would otherwise price
|
||||||
|
// an overweight surcharge on a box with nothing in it.
|
||||||
|
const containers = (dto.containers ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
const tradeDirection = await this.resolveTradeDirectionForBooking(
|
||||||
dto.originYardId,
|
dto.originYardId,
|
||||||
dto.destinationYardId,
|
dto.destinationYardId,
|
||||||
@@ -1506,10 +1520,11 @@ ${footer}
|
|||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
tradeDirection,
|
tradeDirection,
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
|
cargoCondition,
|
||||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||||
cargoFreeText: dto.cargoFreeText,
|
cargoFreeText: dto.cargoFreeText,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
cargoTotalWeightVgm: isEmpty ? 0 : dto.cargoTotalWeightVgm,
|
||||||
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
|
// Break-bulk actual tonnage (PER_ITEM cargo); meaningless outside BULK.
|
||||||
bulkTotalWeightTons:
|
bulkTotalWeightTons:
|
||||||
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
|
dto.freightType === 'BULK' ? (dto.bulkTotalWeightTons ?? null) : null,
|
||||||
@@ -1647,6 +1662,11 @@ ${footer}
|
|||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||||
|
// A draft may be switched between laden and empty; an untouched draft keeps
|
||||||
|
// whatever it was created as.
|
||||||
|
const cargoCondition =
|
||||||
|
(dto.cargoCondition ?? existing.cargoCondition) === 'EMPTY' ? 'EMPTY' : 'LADEN';
|
||||||
|
const isEmpty = cargoCondition === 'EMPTY';
|
||||||
let containers =
|
let containers =
|
||||||
dto.containers ??
|
dto.containers ??
|
||||||
(existing.bookingContainers ?? [])
|
(existing.bookingContainers ?? [])
|
||||||
@@ -1672,7 +1692,14 @@ ${footer}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assertFreightShape({ freightType, cargoTypeId, containers });
|
// Same normalisation as create: zero the VGM of an empty booking before the
|
||||||
|
// rule engine, the wagon estimate or the persisted rows ever read it.
|
||||||
|
containers = containers.map((c) => ({
|
||||||
|
...c,
|
||||||
|
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertFreightShape({ freightType, cargoCondition, cargoTypeId, containers });
|
||||||
|
|
||||||
const originYardId = dto.originYardId ?? existing.originYardId;
|
const originYardId = dto.originYardId ?? existing.originYardId;
|
||||||
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
|
||||||
@@ -1719,6 +1746,9 @@ ${footer}
|
|||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
...dto,
|
...dto,
|
||||||
freightType,
|
freightType,
|
||||||
|
cargoCondition,
|
||||||
|
// Bare equipment declares no VGM, whichever way the draft was edited.
|
||||||
|
cargoTotalWeightVgm: isEmpty ? 0 : cargoAmount,
|
||||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||||
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
|
// Break-bulk actual tonnage; cleared when the booking leaves BULK.
|
||||||
bulkTotalWeightTons:
|
bulkTotalWeightTons:
|
||||||
@@ -1825,10 +1855,12 @@ ${footer}
|
|||||||
await this.bookingsRepository.deleteContainers(id);
|
await this.bookingsRepository.deleteContainers(id);
|
||||||
await this.bookingsRepository.createContainers(
|
await this.bookingsRepository.createContainers(
|
||||||
id,
|
id,
|
||||||
|
// Index-aligned with ruleResult, which evaluated these same lines.
|
||||||
dto.containers.map((c, i) => ({
|
dto.containers.map((c, i) => ({
|
||||||
containerTypeId: c.containerTypeId,
|
containerTypeId: c.containerTypeId,
|
||||||
quantity: c.quantity,
|
quantity: c.quantity,
|
||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
// Bare equipment declares no VGM — same normalisation the rule engine saw.
|
||||||
|
vgmPerUnitTons: isEmpty ? 0 : (c.vgmPerUnitTons ?? 0),
|
||||||
hazardousQuantity: c.hazardousQuantity,
|
hazardousQuantity: c.hazardousQuantity,
|
||||||
reeferQuantity: c.reeferQuantity,
|
reeferQuantity: c.reeferQuantity,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
|
import {
|
||||||
|
BOOKING_STATUSES,
|
||||||
|
BOOKING_TYPES,
|
||||||
|
CARGO_CONDITIONS,
|
||||||
|
FREIGHT_TYPES,
|
||||||
|
} from '../entities/booking.entity';
|
||||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||||
|
|
||||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||||
@@ -47,11 +52,20 @@ export class CreateBookingContainerDto {
|
|||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@ApiProperty({ description: 'VGM per container in tons', minimum: 0 })
|
/**
|
||||||
|
* Omitted on an empty booking — bare equipment has no verified gross mass to
|
||||||
|
* declare, and the service zeroes the line rather than trusting a stray value.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'VGM per container in tons. Omit for an EMPTY booking',
|
||||||
|
minimum: 0,
|
||||||
|
default: 0,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value ?? 0))
|
||||||
vgmPerUnitTons!: number;
|
vgmPerUnitTons?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'How many of this line are hazardous (0..quantity)',
|
description: 'How many of this line are hazardous (0..quantity)',
|
||||||
@@ -312,6 +326,20 @@ export class CreateBookingDto {
|
|||||||
@IsIn([...FREIGHT_TYPES])
|
@IsIn([...FREIGHT_TYPES])
|
||||||
freightType!: string;
|
freightType!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LADEN (default) or EMPTY. EMPTY is container freight carrying nothing —
|
||||||
|
* the box itself is the shipment, priced per size and lane off an
|
||||||
|
* EMPTY_CONTAINER_IMPORT rate.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: CARGO_CONDITIONS,
|
||||||
|
default: 'LADEN',
|
||||||
|
description: 'EMPTY moves bare equipment; requires CONTAINER freight',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CARGO_CONDITIONS])
|
||||||
|
cargoCondition?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description: 'Required for BULK; must be omitted for CONTAINER',
|
description: 'Required for BULK; must be omitted for CONTAINER',
|
||||||
@@ -330,10 +358,14 @@ export class CreateBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
shippingLineId?: string;
|
shippingLineId?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'Total cargo weight VGM in tons', minimum: 0 })
|
@ApiProperty({
|
||||||
|
description: 'Total cargo weight VGM in tons. Omit for an EMPTY booking',
|
||||||
|
minimum: 0,
|
||||||
|
})
|
||||||
|
@ValidateIf((o) => o.cargoCondition !== 'EMPTY')
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value ?? 0))
|
||||||
cargoTotalWeightVgm!: number;
|
cargoTotalWeightVgm!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
|
|||||||
|
|
||||||
export interface BookingFreightShapeInput {
|
export interface BookingFreightShapeInput {
|
||||||
freightType?: string;
|
freightType?: string;
|
||||||
|
/** LADEN (default) or EMPTY — see CARGO_CONDITIONS on the Booking entity. */
|
||||||
|
cargoCondition?: string | null;
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
containers?: Array<{ containerTypeId?: string }> | null;
|
containers?: Array<{ containerTypeId?: string }> | null;
|
||||||
}
|
}
|
||||||
@@ -20,6 +22,13 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk carries no equipment of its own, so an empty booking is always
|
||||||
|
// container freight. Rejected here as well as in assertFreightShape so the
|
||||||
|
// 400 names the field instead of surfacing from the service layer.
|
||||||
|
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const containers = dto.containers ?? [];
|
const containers = dto.containers ?? [];
|
||||||
const hasContainers = containers.length > 0;
|
const hasContainers = containers.length > 0;
|
||||||
const hasCargoType =
|
const hasCargoType =
|
||||||
@@ -49,6 +58,9 @@ export class BookingFreightShapeConstraint implements ValidatorConstraintInterfa
|
|||||||
|
|
||||||
defaultMessage(args: ValidationArguments): string {
|
defaultMessage(args: ValidationArguments): string {
|
||||||
const dto = args.object as BookingFreightShapeInput;
|
const dto = args.object as BookingFreightShapeInput;
|
||||||
|
if (dto.cargoCondition === 'EMPTY' && dto.freightType !== 'CONTAINER') {
|
||||||
|
return 'An empty booking must be CONTAINER freight — bulk carries no equipment';
|
||||||
|
}
|
||||||
if (dto.freightType === 'BULK') {
|
if (dto.freightType === 'BULK') {
|
||||||
return 'BULK freight requires cargoTypeId and must not include container lines';
|
return 'BULK freight requires cargoTypeId and must not include container lines';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,20 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
|||||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the booking moves cargo or bare equipment. EMPTY is container
|
||||||
|
* freight with nothing inside: the box IS the shipment, priced per size and
|
||||||
|
* lane off an EMPTY_CONTAINER_IMPORT rate.
|
||||||
|
*
|
||||||
|
* This is deliberately NOT a third `freightType`. An empty booking is still
|
||||||
|
* CONTAINER freight everywhere it matters physically — wagon footprint, yard
|
||||||
|
* and warehouse allocation, train scheduling, marshalling, gate passes — and
|
||||||
|
* `freightType` is read in ~880 places whose else-arm means "container". Only
|
||||||
|
* pricing, documents, customs and the contract template branch on condition.
|
||||||
|
*/
|
||||||
|
export const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
|
||||||
|
export type CargoCondition = (typeof CARGO_CONDITIONS)[number];
|
||||||
|
|
||||||
export const SCHEDULING_STATUSES = [
|
export const SCHEDULING_STATUSES = [
|
||||||
SchedulingStatus.NotScheduled,
|
SchedulingStatus.NotScheduled,
|
||||||
SchedulingStatus.Holding,
|
SchedulingStatus.Holding,
|
||||||
@@ -388,6 +402,13 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'freight_type', type: 'varchar', length: 20, nullable: true })
|
||||||
freightType!: string;
|
freightType!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LADEN (the default, and every pre-existing row) or EMPTY. Only ever EMPTY
|
||||||
|
* on CONTAINER freight — bulk has no equipment to move on its own.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
|
||||||
|
cargoCondition!: string;
|
||||||
|
|
||||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
cargoTypeId?: string | null;
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -53,24 +53,60 @@ describe('contractTemplateCodeFor', () => {
|
|||||||
it('only ever resolves to a code that exists', () => {
|
it('only ever resolves to a code that exists', () => {
|
||||||
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
||||||
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
||||||
|
const conditions = ['LADEN', 'EMPTY', null, undefined];
|
||||||
for (const d of directions) {
|
for (const d of directions) {
|
||||||
for (const f of freights) {
|
for (const f of freights) {
|
||||||
for (const c of [true, false]) {
|
for (const c of [true, false]) {
|
||||||
for (const e of [true, false, undefined]) {
|
for (const e of [true, false, undefined]) {
|
||||||
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
for (const cond of conditions) {
|
||||||
contractTemplateCodeFor(d, f, c, e),
|
expect(CONTRACT_TEMPLATE_CODES).toContain(
|
||||||
);
|
contractTemplateCodeFor(d, f, c, e, cond),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Empty equipment is a carriage agreement, not a cargo contract: no cargo
|
||||||
|
// liability, no VGM declaration, no commercial documents, no customs leg.
|
||||||
|
it('gives empty container import its own customs-free paper', () => {
|
||||||
|
for (const customs of [true, false]) {
|
||||||
|
for (const ethiopian of [true, false, undefined]) {
|
||||||
|
expect(
|
||||||
|
contractTemplateCodeFor('IMPORT', 'CONTAINER', customs, ethiopian, 'EMPTY'),
|
||||||
|
).toBe('IMPORT_EMPTY_CONTAINER');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves laden contracts on the laden codes', () => {
|
||||||
|
expect(
|
||||||
|
contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false, 'LADEN'),
|
||||||
|
).toBe('IMPORT_CONTAINER_NO_CUSTOMS');
|
||||||
|
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', false, false)).toBe(
|
||||||
|
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Empty rates and empty bookings are import-only, so a stray EMPTY on any
|
||||||
|
// other direction must fall through rather than resolve a template that
|
||||||
|
// describes a Djibouti-to-Ethiopia movement.
|
||||||
|
it('ignores the empty condition outside import', () => {
|
||||||
|
expect(
|
||||||
|
contractTemplateCodeFor('EXPORT', 'CONTAINER', false, false, 'EMPTY'),
|
||||||
|
).toBe('EXPORT_CONTAINER_NO_CUSTOMS');
|
||||||
|
expect(
|
||||||
|
contractTemplateCodeFor('DOMESTIC', 'CONTAINER', false, false, 'EMPTY'),
|
||||||
|
).toBe('INTERCITY_CONTAINER');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||||
it('seeds exactly the fourteen declared codes, once each', () => {
|
it('seeds exactly the fifteen declared codes, once each', () => {
|
||||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||||
expect(seeded).toHaveLength(14);
|
expect(seeded).toHaveLength(15);
|
||||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
|||||||
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||||
|
// Carriage of the equipment itself — no cargo, no clearing, so it previews
|
||||||
|
// against the transport-only scope like every other non-customs code.
|
||||||
|
IMPORT_EMPTY_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -216,6 +219,7 @@ export class ContractTemplatesService {
|
|||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
cargoTypeId?: string | null,
|
cargoTypeId?: string | null,
|
||||||
ethiopianCustomsOnly?: boolean | null,
|
ethiopianCustomsOnly?: boolean | null,
|
||||||
|
cargoCondition?: string | null,
|
||||||
): Promise<ContractTemplate | null> {
|
): Promise<ContractTemplate | null> {
|
||||||
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
const isBulk = (freightType ?? "").toUpperCase().includes("BULK");
|
||||||
if (isBulk) {
|
if (isBulk) {
|
||||||
@@ -235,6 +239,7 @@ export class ContractTemplatesService {
|
|||||||
freightType,
|
freightType,
|
||||||
customsClearingEnabled,
|
customsClearingEnabled,
|
||||||
ethiopianCustomsOnly,
|
ethiopianCustomsOnly,
|
||||||
|
cargoCondition,
|
||||||
);
|
);
|
||||||
const template = await this.repository.findByCode(code);
|
const template = await this.repository.findByCode(code);
|
||||||
return template?.isActive ? template : null;
|
return template?.isActive ? template : null;
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export const CONTRACT_TEMPLATE_CODES = [
|
|||||||
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
"EXPORT_CONTAINER_ETHIOPIAN_CUSTOMS",
|
||||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||||
"INTERCITY_CONTAINER",
|
"INTERCITY_CONTAINER",
|
||||||
|
/**
|
||||||
|
* Empty container import — bare equipment railed north from Djibouti. No
|
||||||
|
* customs split: an empty box carries no declaration to clear, the same
|
||||||
|
* reason intercity has a single unsuffixed code. Import-only, matching the
|
||||||
|
* rate rule (southbound empties are served by the WITH_RETURN surcharge and
|
||||||
|
* empty_return_requests instead).
|
||||||
|
*/
|
||||||
|
"IMPORT_EMPTY_CONTAINER",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
|
export type ContractTemplateCode = (typeof CONTRACT_TEMPLATE_CODES)[number];
|
||||||
@@ -74,7 +82,14 @@ export function contractTemplateCodeFor(
|
|||||||
freightType?: string | null,
|
freightType?: string | null,
|
||||||
customsClearingEnabled?: boolean | null,
|
customsClearingEnabled?: boolean | null,
|
||||||
ethiopianCustomsOnly?: boolean | null,
|
ethiopianCustomsOnly?: boolean | null,
|
||||||
|
cargoCondition?: string | null,
|
||||||
): ContractTemplateCode {
|
): ContractTemplateCode {
|
||||||
|
// Empty equipment is its own paper: a straight carriage agreement with no
|
||||||
|
// cargo liability, no VGM declaration and no customs leg. Import-only, so
|
||||||
|
// anything else falls through to the laden codes below.
|
||||||
|
if (cargoCondition === "EMPTY" && tradeDirection === "IMPORT") {
|
||||||
|
return "IMPORT_EMPTY_CONTAINER";
|
||||||
|
}
|
||||||
const direction =
|
const direction =
|
||||||
tradeDirection === "IMPORT"
|
tradeDirection === "IMPORT"
|
||||||
? "IMPORT"
|
? "IMPORT"
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { ContractTransitionService } from './contract-transition.service';
|
||||||
|
import type { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lapsed contract comes back only on the customer's say-so: they ask once,
|
||||||
|
* staff add days, and the contract lands back where it was before it expired.
|
||||||
|
* Those three rules are the feature.
|
||||||
|
*/
|
||||||
|
describe('ContractTransitionService — extension request / extend', () => {
|
||||||
|
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||||
|
({
|
||||||
|
id: 'c-1',
|
||||||
|
reference: 'CTR-2026-00042',
|
||||||
|
companyId: 'co-1',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
status: 'EXPIRED',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
contractValidUntil: new Date('2026-01-31T21:00:00Z'),
|
||||||
|
statusBeforeExpiry: 'CONTRACT_ACTIVE',
|
||||||
|
extensionRequestedAt: null,
|
||||||
|
...over,
|
||||||
|
}) as Contract;
|
||||||
|
|
||||||
|
let current: Contract;
|
||||||
|
let repo: { update: jest.Mock; createReviewNote: jest.Mock };
|
||||||
|
let notifier: { extended: jest.Mock; extensionRequestedToStaff: jest.Mock };
|
||||||
|
let service: ContractTransitionService;
|
||||||
|
|
||||||
|
/** A staff user holding the extend key — authorization is tested elsewhere. */
|
||||||
|
const staff = {
|
||||||
|
permissions: [{ key: 'edr_freight_app:contracts:extend' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
current = contract();
|
||||||
|
repo = {
|
||||||
|
update: jest.fn().mockImplementation((_id: string, patch: object) => {
|
||||||
|
current = { ...current, ...patch } as Contract;
|
||||||
|
return Promise.resolve(current);
|
||||||
|
}),
|
||||||
|
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
notifier = { extended: jest.fn(), extensionRequestedToStaff: jest.fn() };
|
||||||
|
service = Object.create(
|
||||||
|
ContractTransitionService.prototype,
|
||||||
|
) as ContractTransitionService;
|
||||||
|
Object.assign(service, {
|
||||||
|
contractsRepository: repo,
|
||||||
|
contractsService: { findById: () => Promise.resolve(current) },
|
||||||
|
notifier,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records the customer request and tells the contract desk', async () => {
|
||||||
|
await service.requestExtension('c-1', ' Two more shipments due ', 'user-1');
|
||||||
|
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
'Two more shipments due',
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
'user-1',
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
expect(repo.update).toHaveBeenCalledWith('c-1', {
|
||||||
|
extensionRequestedAt: expect.any(Date),
|
||||||
|
});
|
||||||
|
expect(notifier.extensionRequestedToStaff).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'c-1' }),
|
||||||
|
'Two more shipments due',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a request on a contract that has not expired', async () => {
|
||||||
|
current = contract({ status: 'CONTRACT_ACTIVE' });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.requestExtension('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/CONTRACT_ACTIVE/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows one pending request at a time', async () => {
|
||||||
|
current = contract({ extensionRequestedAt: new Date() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.requestExtension('c-1', undefined, 'user-1'),
|
||||||
|
).rejects.toThrow(/already awaiting/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to extend before the customer has asked', async () => {
|
||||||
|
await expect(
|
||||||
|
service.extend('c-1', 30, undefined, 'staff-1', staff as never),
|
||||||
|
).rejects.toThrow(/not requested/);
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds days from today on a lapsed contract and restores the pre-expiry status', async () => {
|
||||||
|
current = contract({
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
statusBeforeExpiry: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
});
|
||||||
|
const before = Date.now();
|
||||||
|
|
||||||
|
await service.extend('c-1', 10, 'Approved by desk', 'staff-1', staff as never);
|
||||||
|
|
||||||
|
const patch = repo.update.mock.calls[0][1] as {
|
||||||
|
status: string;
|
||||||
|
statusBeforeExpiry: null;
|
||||||
|
extensionRequestedAt: null;
|
||||||
|
contractValidUntil: Date;
|
||||||
|
};
|
||||||
|
expect(patch.status).toBe('ACTIVE_SHIPMENT_IN_PROGRESS');
|
||||||
|
expect(patch.statusBeforeExpiry).toBeNull();
|
||||||
|
expect(patch.extensionRequestedAt).toBeNull();
|
||||||
|
// The old end (Jan 2026) is in the past, so the ten days count from now.
|
||||||
|
const tenDays = 10 * 86_400_000;
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBeGreaterThanOrEqual(before + tenDays - 1000);
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + tenDays + 3_600_000);
|
||||||
|
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
expect.stringMatching(/^Extended by 10 days to .*\. Approved by desk$/),
|
||||||
|
'EXTENDED',
|
||||||
|
'staff-1',
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
expect(notifier.extended).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ id: 'c-1' }),
|
||||||
|
10,
|
||||||
|
patch.contractValidUntil,
|
||||||
|
'Approved by desk',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends from the current end date when it is still in the future', async () => {
|
||||||
|
const future = new Date(Date.now() + 5 * 86_400_000);
|
||||||
|
current = contract({ extensionRequestedAt: new Date(), contractValidUntil: future });
|
||||||
|
|
||||||
|
await service.extend('c-1', 7, undefined, 'staff-1', staff as never);
|
||||||
|
|
||||||
|
const patch = repo.update.mock.calls[0][1] as { contractValidUntil: Date };
|
||||||
|
const expected = new Date(future);
|
||||||
|
expected.setDate(expected.getDate() + 7);
|
||||||
|
expect(patch.contractValidUntil.getTime()).toBe(expected.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the resting status for rows expired before it was tracked', async () => {
|
||||||
|
current = contract({
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
statusBeforeExpiry: null,
|
||||||
|
contractKind: 'ONE_TIME',
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.extend('c-1', 1, undefined, 'staff-1', staff as never);
|
||||||
|
|
||||||
|
expect(repo.update).toHaveBeenCalledWith(
|
||||||
|
'c-1',
|
||||||
|
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to extend without the extend permission', async () => {
|
||||||
|
current = contract({ extensionRequestedAt: new Date() });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.extend('c-1', 30, undefined, 'staff-1', { permissions: [] } as never),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(repo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,26 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract cancelled', msg);
|
this.inApp(c, 'Contract cancelled', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Staff extended the validity of a lapsed contract — it is live again. */
|
||||||
|
extended(c: Contract, days: number, validUntil: Date, note?: string | null): void {
|
||||||
|
const msg =
|
||||||
|
`Your contract ${c.reference} has been extended by ${days} day${days === 1 ? '' : 's'} ` +
|
||||||
|
`and is now valid until ${validUntil.toLocaleDateString('en-GB')}. ` +
|
||||||
|
`You can book shipments under it again.${note ? ` Note: ${note}` : ''}`;
|
||||||
|
void this.notifyContact(c, msg, 'EXTENDED');
|
||||||
|
this.inApp(c, 'Contract extended', msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer asked for their expired contract to be extended — staff-side record. */
|
||||||
|
extensionRequestedToStaff(c: Contract, note: string | null): void {
|
||||||
|
this.inAppStaff(
|
||||||
|
c,
|
||||||
|
'Contract extension requested',
|
||||||
|
`The customer asked to extend expired contract ${this.ref(c)}.` +
|
||||||
|
`${note ? ` Reason: ${note}` : ''} Open the contract to add validity days.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Customer cancelled their own contract — staff-side record. */
|
/** Customer cancelled their own contract — staff-side record. */
|
||||||
cancelledByCustomer(c: Contract, reason: string): void {
|
cancelledByCustomer(c: Contract, reason: string): void {
|
||||||
this.inAppStaff(
|
this.inAppStaff(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Injectable, UnprocessableEntityException } from '@nestjs/common';
|
|||||||
import { RatesService } from '../rule-engine/services/rates.service';
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { round2 } from '../billing/invoice-settlement.util';
|
import { round2 } from '../billing/invoice-settlement.util';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
import { ContractsRepository } from './contracts.repository';
|
||||||
import { Contract } from './entities/contract.entity';
|
import { Contract } from './entities/contract.entity';
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ export class ContractPricingService {
|
|||||||
(r) => !r.shippingLineCompanyId,
|
(r) => !r.shippingLineCompanyId,
|
||||||
);
|
);
|
||||||
const currency = contract.paymentCurrency;
|
const currency = contract.paymentCurrency;
|
||||||
const isEtb = currency === 'ETB';
|
const usdToTarget =
|
||||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
currency === 'USD' ? 1 : await this.exchangeService.getRate('USD', currency as CurrencyCode);
|
||||||
const convert = (usd: number): number => (isEtb ? round2(usd * usdToEtb) : usd);
|
const convert = (usd: number): number => (currency === 'USD' ? usd : round2(usd * usdToTarget));
|
||||||
|
|
||||||
const lineItems: ContractUnitRateLineItem[] = [];
|
const lineItems: ContractUnitRateLineItem[] = [];
|
||||||
const baseType = this.baseRateType(contract);
|
const baseType = this.baseRateType(contract);
|
||||||
|
|||||||
@@ -430,6 +430,8 @@ export class ContractTransitionService {
|
|||||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||||
|
// An empty-equipment contract resolves to the carriage-only paper.
|
||||||
|
contract.cargoCondition,
|
||||||
);
|
);
|
||||||
if (!active) return null;
|
if (!active) return null;
|
||||||
return {
|
return {
|
||||||
@@ -1500,6 +1502,105 @@ export class ContractTransitionService {
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer asks EDR to extend the validity of their EXPIRED contract. Only
|
||||||
|
* stamps the request and tells the contract desk — nothing on the contract
|
||||||
|
* moves until staff {@link extend} it. One pending request at a time.
|
||||||
|
*/
|
||||||
|
async requestExtension(
|
||||||
|
contractId: string,
|
||||||
|
note: string | undefined,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertContractStatus(contract, ['EXPIRED']);
|
||||||
|
if (contract.extensionRequestedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'An extension request for this contract is already awaiting EDR.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = note?.trim() || null;
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
reason ?? 'Customer requested a validity extension.',
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
userId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
extensionRequestedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.extensionRequestedToStaff(updated, reason);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff add validity days to an EXPIRED contract the customer asked to
|
||||||
|
* extend, and the contract returns to the status it held before it lapsed
|
||||||
|
* (stashed in statusBeforeExpiry by both expiry paths). Days count from
|
||||||
|
* today once the contract has lapsed — adding to a date already in the past
|
||||||
|
* could leave it expired — and from the current end date otherwise.
|
||||||
|
*
|
||||||
|
* Gated on the customer's request: the portal button is the only way to set
|
||||||
|
* extensionRequestedAt, so staff cannot silently revive a contract nobody
|
||||||
|
* asked about.
|
||||||
|
*/
|
||||||
|
async extend(
|
||||||
|
contractId: string,
|
||||||
|
days: number,
|
||||||
|
note: string | undefined,
|
||||||
|
actorId: string,
|
||||||
|
user?: TCurrentUser | null,
|
||||||
|
): Promise<Contract> {
|
||||||
|
const contract = await this.contractsService.findById(contractId);
|
||||||
|
assertFreightPermission(user, FREIGHT_PERMS.contracts.extend);
|
||||||
|
assertContractStatus(contract, ['EXPIRED']);
|
||||||
|
if (!contract.extensionRequestedAt) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'The customer has not requested an extension for this contract. A contract is only extended on customer request.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(days) || days < 1) {
|
||||||
|
throw new BadRequestException('An extension must add at least one day.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const currentEnd = contract.contractValidUntil
|
||||||
|
? new Date(contract.contractValidUntil)
|
||||||
|
: null;
|
||||||
|
const base = currentEnd && currentEnd.getTime() > now.getTime() ? currentEnd : now;
|
||||||
|
const validUntil = new Date(base);
|
||||||
|
validUntil.setDate(validUntil.getDate() + days);
|
||||||
|
|
||||||
|
// Rows that lapsed before statusBeforeExpiry existed have nothing to
|
||||||
|
// restore — fall back to the kind's post-signature resting status, the
|
||||||
|
// same default resume() uses.
|
||||||
|
const restored =
|
||||||
|
contract.statusBeforeExpiry ??
|
||||||
|
(contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED');
|
||||||
|
|
||||||
|
const trimmed = note?.trim() || null;
|
||||||
|
await this.contractsRepository.createReviewNote(
|
||||||
|
contractId,
|
||||||
|
`Extended by ${days} day${days === 1 ? '' : 's'} to ${validUntil.toLocaleDateString('en-GB')}.` +
|
||||||
|
(trimmed ? ` ${trimmed}` : ''),
|
||||||
|
'EXTENDED',
|
||||||
|
actorId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
await this.contractsRepository.update(contractId, {
|
||||||
|
status: restored,
|
||||||
|
statusBeforeExpiry: null,
|
||||||
|
extensionRequestedAt: null,
|
||||||
|
contractValidUntil: validUntil,
|
||||||
|
} as never);
|
||||||
|
const updated = await this.contractsService.findById(contractId);
|
||||||
|
this.notifier.extended(updated, days, validUntil, trimmed);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async renew(contractId: string, userId?: string): Promise<Contract> {
|
async renew(contractId: string, userId?: string): Promise<Contract> {
|
||||||
const source = await this.contractsService.findById(contractId);
|
const source = await this.contractsService.findById(contractId);
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ import {
|
|||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||||
|
import {
|
||||||
|
ExtendContractDto,
|
||||||
|
RequestContractExtensionDto,
|
||||||
|
} from './dto/extend-contract.dto';
|
||||||
import {
|
import {
|
||||||
CompleteConsolidatedPairDto,
|
CompleteConsolidatedPairDto,
|
||||||
CreateBookingUnderContractDto,
|
CreateBookingUnderContractDto,
|
||||||
@@ -528,6 +532,52 @@ export class ContractsController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/extension-request')
|
||||||
|
@PortalCustomer()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Customer asks EDR to extend the validity of their expired contract',
|
||||||
|
})
|
||||||
|
async requestExtension(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RequestContractExtensionDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Same ownership rule as cancel/renew: staff with bookings.view/contracts.view
|
||||||
|
// pass through, everyone else must own the contract's company.
|
||||||
|
const contract = await this.contractsService.findById(id);
|
||||||
|
if (
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.bookings.view) &&
|
||||||
|
!hasFreightPermission(user, FREIGHT_PERMS.contracts.view)
|
||||||
|
) {
|
||||||
|
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
|
||||||
|
}
|
||||||
|
return this.transitionService.requestExtension(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/extend')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.contracts.extend)
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Staff extend an expired contract the customer asked to extend — it returns to its pre-expiry status',
|
||||||
|
})
|
||||||
|
extend(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: ExtendContractDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.transitionService.extend(
|
||||||
|
id,
|
||||||
|
dto.days,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@PortalCustomer()
|
@PortalCustomer()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.update(Contract)
|
||||||
.set({ status: 'EXPIRED' })
|
// SET reads the pre-update row, so status_before_expiry gets the status
|
||||||
|
// being replaced — the value ContractTransitionService.extend restores.
|
||||||
|
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||||
.where('deleted_at IS NULL')
|
.where('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||||
@@ -155,7 +157,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
const result = await this.repository
|
const result = await this.repository
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.update(Contract)
|
.update(Contract)
|
||||||
.set({ status: 'EXPIRED' })
|
.set({ status: 'EXPIRED', statusBeforeExpiry: () => 'status' })
|
||||||
.where('id = :id', { id })
|
.where('id = :id', { id })
|
||||||
.andWhere('deleted_at IS NULL')
|
.andWhere('deleted_at IS NULL')
|
||||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||||
|
|||||||
@@ -433,6 +433,7 @@ export class ContractsService {
|
|||||||
renewalOfId: dto.renewalOfId ?? null,
|
renewalOfId: dto.renewalOfId ?? null,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
freightType: dto.freightType,
|
freightType: dto.freightType,
|
||||||
|
cargoCondition: dto.cargoCondition === 'EMPTY' ? 'EMPTY' : 'LADEN',
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
// A contract is always QUOTED in USD — the billing currency is chosen per
|
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||||
// booking (or on the shipment request when GL books for the customer), so
|
// booking (or on the shipment request when GL books for the customer), so
|
||||||
@@ -952,6 +953,20 @@ export class ContractsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Why the customer wants more time — shown on the staff detail page while
|
||||||
|
// the extension request is pending.
|
||||||
|
if (contract.status === 'EXPIRED' && contract.extensionRequestedAt) {
|
||||||
|
try {
|
||||||
|
const note = await this.contractsRepository.findLatestReviewNote(
|
||||||
|
contract.id,
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
);
|
||||||
|
contract.latestExtensionRequestNote = note?.body ?? null;
|
||||||
|
} catch {
|
||||||
|
contract.latestExtensionRequestNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lets the portal disable "Cancel contract" instead of letting the customer
|
// Lets the portal disable "Cancel contract" instead of letting the customer
|
||||||
// click it and read a 400. The API re-checks on cancel regardless.
|
// click it and read a 400. The API re-checks on cancel regardless.
|
||||||
contract.activeBookingCount =
|
contract.activeBookingCount =
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export class CreateBookingRequestDto {
|
|||||||
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['ETB', 'USD'])
|
@IsIn(['ETB', 'USD', 'DJF'])
|
||||||
paymentCurrency?: string;
|
paymentCurrency?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CONTRACT_KINDS } from '../entities/contract.entity';
|
|||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
|
const CARGO_CONDITIONS = ['LADEN', 'EMPTY'] as const;
|
||||||
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||||
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
// Canonical UPPERCASE — everything downstream (booking gating, pricing
|
||||||
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
// surcharge, GL/portal booking forms) compares contract.equipmentReturn
|
||||||
@@ -154,6 +155,15 @@ export class CreateContractDto {
|
|||||||
@IsIn([...FREIGHT_TYPES])
|
@IsIn([...FREIGHT_TYPES])
|
||||||
freightType!: string;
|
freightType!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LADEN (default) or EMPTY. EMPTY commits to moving bare equipment and is
|
||||||
|
* container freight only.
|
||||||
|
*/
|
||||||
|
@ApiPropertyOptional({ enum: CARGO_CONDITIONS, default: 'LADEN' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...CARGO_CONDITIONS])
|
||||||
|
cargoCondition?: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
@ApiProperty({ format: 'uuid', description: 'FK to service_types.id' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/** Customer asks EDR to extend the validity of their EXPIRED contract. */
|
||||||
|
export class RequestContractExtensionDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Why the customer needs the contract extended' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Staff extend an EXPIRED contract that the customer asked to extend. */
|
||||||
|
export class ExtendContractDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description:
|
||||||
|
'Days to add. Counted from today when the contract has already lapsed, otherwise from its current end date.',
|
||||||
|
minimum: 1,
|
||||||
|
maximum: 3650,
|
||||||
|
})
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(3650)
|
||||||
|
days!: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Optional note recorded with the extension and shown to the customer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
|||||||
'SUSPENSION_LIFTED',
|
'SUSPENSION_LIFTED',
|
||||||
/** Customer cancelled their own contract; body is their reason. */
|
/** Customer cancelled their own contract; body is their reason. */
|
||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
|
/** Customer asked for an EXPIRED contract's validity to be extended. */
|
||||||
|
'EXTENSION_REQUESTED',
|
||||||
|
/** Staff extended the validity; body records the days added and the new end. */
|
||||||
|
'EXTENDED',
|
||||||
] as const;
|
] as const;
|
||||||
export type ContractReviewNoteType =
|
export type ContractReviewNoteType =
|
||||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||||
|
|||||||
@@ -150,6 +150,14 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||||
freightType!: string;
|
freightType!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LADEN (the default, and every pre-existing row) or EMPTY. An EMPTY contract
|
||||||
|
* commits to moving bare equipment and resolves the IMPORT_EMPTY_CONTAINER
|
||||||
|
* template — a straight carriage agreement with no cargo or customs articles.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'cargo_condition', type: 'varchar', length: 10, default: 'LADEN' })
|
||||||
|
cargoCondition!: string;
|
||||||
|
|
||||||
@Column({ name: 'service_type_id', type: 'uuid' })
|
@Column({ name: 'service_type_id', type: 'uuid' })
|
||||||
serviceTypeId!: string;
|
serviceTypeId!: string;
|
||||||
|
|
||||||
@@ -231,6 +239,23 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
@Column({ name: 'status_before_suspension', type: 'varchar', length: 40, nullable: true })
|
||||||
statusBeforeSuspension?: string | null;
|
statusBeforeSuspension?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status the contract held when it lapsed to EXPIRED (stamped by both the
|
||||||
|
* nightly sweep and the lazy flip on read), restored when staff extend the
|
||||||
|
* validity. Null on rows that expired before the column existed — extension
|
||||||
|
* then falls back to the kind's post-signature resting status.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'status_before_expiry', type: 'varchar', length: 40, nullable: true })
|
||||||
|
statusBeforeExpiry?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the customer asked for the validity of this EXPIRED contract to be
|
||||||
|
* extended. Set by the portal request, cleared when staff extend. Staff
|
||||||
|
* cannot extend a contract the customer has not asked about.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'extension_requested_at', type: 'timestamptz', nullable: true })
|
||||||
|
extensionRequestedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
@Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' })
|
||||||
clearanceStatus!: string;
|
clearanceStatus!: string;
|
||||||
|
|
||||||
@@ -365,6 +390,13 @@ export class Contract extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
latestSuspensionNote?: string | null;
|
latestSuspensionNote?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body of the most recent EXTENSION_REQUESTED review note, attached by
|
||||||
|
* ContractsService.findById while an extension request is pending so staff
|
||||||
|
* see why the customer wants the contract extended. Not a column.
|
||||||
|
*/
|
||||||
|
latestExtensionRequestNote?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count of this contract's non-terminal bookings, attached by
|
* Count of this contract's non-terminal bookings, attached by
|
||||||
* ContractsService.findById. The portal disables customer cancellation while
|
* ContractsService.findById. The portal disables customer cancellation while
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
|||||||
const frozenByCode = (
|
const frozenByCode = (
|
||||||
snap: ContractRateSnapshot | null,
|
snap: ContractRateSnapshot | null,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
usdToEtb: number,
|
fx: Record<string, number>,
|
||||||
): ContractRateSnapshot | null =>
|
): ContractRateSnapshot | null =>
|
||||||
(
|
(
|
||||||
BookingPricingService.prototype as unknown as {
|
BookingPricingService.prototype as unknown as {
|
||||||
@@ -28,14 +28,14 @@ const frozenByCode = (
|
|||||||
m: Map<string, ContractRateSnapshot> | null,
|
m: Map<string, ContractRateSnapshot> | null,
|
||||||
code: string,
|
code: string,
|
||||||
bookingCurrency: string,
|
bookingCurrency: string,
|
||||||
usdToEtb: number,
|
fx: Record<string, number>,
|
||||||
) => ContractRateSnapshot | null;
|
) => ContractRateSnapshot | null;
|
||||||
}
|
}
|
||||||
).frozenRateByCode(
|
).frozenRateByCode(
|
||||||
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||||
'CONTAINER_20FT',
|
'CONTAINER_20FT',
|
||||||
bookingCurrency,
|
bookingCurrency,
|
||||||
usdToEtb,
|
fx,
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('per-shipment billing currency', () => {
|
describe('per-shipment billing currency', () => {
|
||||||
@@ -61,25 +61,34 @@ describe('frozen contract rate in the booking currency', () => {
|
|||||||
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||||
// The old behaviour returned null here, which silently re-priced the
|
// The old behaviour returned null here, which silently re-priced the
|
||||||
// booking at live rates and lost the agreed contract price.
|
// booking at live rates and lost the agreed contract price.
|
||||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 150 })?.unitPrice).toBe(60_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', { ETB: 1 / 150 })?.unitPrice).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts a USD snapshot for a DJF booking via the USD->DJF rate', () => {
|
||||||
|
// 177.6 ETB/DJF pivot: USD->DJF = usdToEtb / djfToEtb = 150 / 0.845.
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'DJF', { USD: 177.6 })?.unitPrice).toBe(71_040);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('passes a matching-currency snapshot through untouched', () => {
|
it('passes a matching-currency snapshot through untouched', () => {
|
||||||
const snap = snapshot('USD', 400);
|
const snap = snapshot('USD', 400);
|
||||||
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
expect(frozenByCode(snap, 'USD', { USD: 1 })).toBe(snap);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('refuses to price off an unusable exchange rate', () => {
|
it('refuses to price off an unusable exchange rate', () => {
|
||||||
// Converting with 0 would zero the whole line.
|
// Converting with 0 would zero the whole line.
|
||||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: 0 })).toBeNull();
|
||||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
expect(frozenByCode(snapshot('USD', 400), 'ETB', { USD: Number.NaN })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to price off a currency the rate table has no entry for', () => {
|
||||||
|
expect(frozenByCode(snapshot('USD', 400), 'DJF', {})).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when there is no snapshot', () => {
|
it('returns null when there is no snapshot', () => {
|
||||||
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
expect(frozenByCode(null, 'ETB', { USD: 150 })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { IsNumber, Max, Min } from "class-validator";
|
import { IsNumber, Min } from "class-validator";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operator-set USD→ETB fallback. Bounded well outside any plausible published
|
* Operator-set X→ETB fallback for one currency. The upper bound is enforced
|
||||||
* rate but far short of a fat-fingered magnitude error — this value multiplies
|
* per currency in the controller (see `RATE_BOUNDS`) rather than here, since
|
||||||
* real invoice amounts whenever CBE is unreachable.
|
* USD's plausible range (~100-300) and DJF's (~0.5-2) differ by two orders of
|
||||||
|
* magnitude — this value multiplies real invoice amounts whenever CBE is
|
||||||
|
* unreachable.
|
||||||
*/
|
*/
|
||||||
export class UpdateExchangeSettingDto {
|
export class UpdateExchangeSettingDto {
|
||||||
@IsNumber({ maxDecimalPlaces: 6 })
|
@IsNumber({ maxDecimalPlaces: 6 })
|
||||||
@Min(1)
|
@Min(0.000001)
|
||||||
@Max(10_000)
|
|
||||||
fallbackRate!: number;
|
fallbackRate!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,18 @@ import { Column, Entity } from "typeorm";
|
|||||||
export type ExchangeFallbackSource = "AUTO" | "MANUAL";
|
export type ExchangeFallbackSource = "AUTO" | "MANUAL";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single-row table holding the USD→ETB fallback used when the CBE endpoint is
|
* One row per foreign currency, holding the X→ETB fallback used when the CBE
|
||||||
* unreachable. The live CBE rate always wins; this is only consulted on
|
* endpoint is unreachable for that currency. The live CBE rate always wins;
|
||||||
* failure, and is overwritten by every successful fetch so it tracks the last
|
* this is only consulted on failure, and is overwritten by every successful
|
||||||
* known good rate.
|
* fetch so it tracks the last known good rate.
|
||||||
*/
|
*/
|
||||||
@Entity({ schema: "freight", name: "exchange_settings" })
|
@Entity({ schema: "freight", name: "exchange_settings" })
|
||||||
export class ExchangeSetting extends BaseEntity {
|
export class ExchangeSetting extends BaseEntity {
|
||||||
/** USD→ETB rate served while the CBE endpoint is failing. */
|
/** The foreign currency this row's fallback applies to, e.g. `USD`, `DJF`. */
|
||||||
|
@Column({ name: "currency", type: "varchar", length: 5 })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
/** currency→ETB rate served while the CBE endpoint is failing for it. */
|
||||||
@Column({
|
@Column({
|
||||||
name: "fallback_rate",
|
name: "fallback_rate",
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ExchangeSettingsService } from "./exchange-settings.service";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's single `ExchangeModule` registration shape: CBE endpoint config
|
* The app's single `ExchangeModule` registration shape: CBE endpoint config
|
||||||
* from `app.cbeExchange`, with the DB-backed fallback wired in.
|
* from `app.cbeExchange`, with the DB-backed per-currency fallback wired in.
|
||||||
*
|
*
|
||||||
* `ExchangeModule` is registered per-feature-module (bookings, contracts,
|
* `ExchangeModule` is registered per-feature-module (bookings, contracts,
|
||||||
* warehouses), so this keeps the three call sites identical rather than
|
* warehouses), so this keeps the three call sites identical rather than
|
||||||
@@ -20,8 +20,8 @@ export function registerExchangeModule(): DynamicModule {
|
|||||||
settings: ExchangeSettingsService,
|
settings: ExchangeSettingsService,
|
||||||
): ExchangeOptions => ({
|
): ExchangeOptions => ({
|
||||||
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
|
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||||
loadFallbackRate: () => settings.loadFallbackRate(),
|
loadFallbackRate: (code) => settings.loadFallbackRate(code),
|
||||||
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
saveFallbackRate: (code, rate) => settings.saveFallbackRate(code, rate),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { CbeExchangeProvider, ExchangeService } from '@edr/api-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CBE feed quotes every currency it publishes against ETB in one fetch —
|
||||||
|
* this is a fixture of that shape (trimmed to USD + DJF, the two the app
|
||||||
|
* actually reads). Verified live against the real feed on 2026-09-04.
|
||||||
|
*/
|
||||||
|
const CBE_FIXTURE = [
|
||||||
|
{
|
||||||
|
Date: '2026-09-04',
|
||||||
|
ExchangeRate: [
|
||||||
|
{
|
||||||
|
transactionalSelling: 163.4365,
|
||||||
|
transactionalBuying: 160.2319,
|
||||||
|
currency: { CurrencyCode: 'USD' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transactionalSelling: 0.9203,
|
||||||
|
transactionalBuying: 0.9022,
|
||||||
|
currency: { CurrencyCode: 'DJF' },
|
||||||
|
},
|
||||||
|
// CBE publishes 0 for a currency it isn't quoting cash-selling that
|
||||||
|
// day — must not be picked up as a usable rate.
|
||||||
|
{ transactionalSelling: 0, currency: { CurrencyCode: 'ZZZ' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockFetchOnce(payload: unknown): jest.Mock {
|
||||||
|
const fn = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(payload),
|
||||||
|
});
|
||||||
|
(global as unknown as { fetch: typeof fetch }).fetch = fn as never;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CbeExchangeProvider — multi-currency', () => {
|
||||||
|
it('parses every quoted currency out of one fetch, not just USD', async () => {
|
||||||
|
const fetchMock = mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const provider = new CbeExchangeProvider({});
|
||||||
|
|
||||||
|
const usdToEtb = await provider.getBaseRate({ from: 'USD', to: 'ETB' });
|
||||||
|
const djfToEtb = await provider.getBaseRate({ from: 'DJF', to: 'ETB' });
|
||||||
|
|
||||||
|
expect(usdToEtb).toBeCloseTo(163.4365);
|
||||||
|
expect(djfToEtb).toBeCloseTo(0.9203);
|
||||||
|
// Both rates came from the SAME cached fetch — one HTTP call serves
|
||||||
|
// every currency, not one per currency.
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a currency CBE reports as 0 (unquoted that day) — throws with no fallback configured', async () => {
|
||||||
|
mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const provider = new CbeExchangeProvider({});
|
||||||
|
|
||||||
|
await expect(provider.getBaseRate({ from: 'ZZZ' as never, to: 'ETB' })).rejects.toThrow(
|
||||||
|
/No CBE rate available for ZZZ/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only ever answers for X→ETB — everything else is derived upstream', async () => {
|
||||||
|
mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const provider = new CbeExchangeProvider({});
|
||||||
|
|
||||||
|
await expect(provider.getBaseRate({ from: 'ETB', to: 'USD' })).resolves.toBeNull();
|
||||||
|
await expect(provider.getBaseRate({ from: 'USD', to: 'DJF' })).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ExchangeService — USD↔DJF pivot', () => {
|
||||||
|
it('derives USD→DJF by pivoting through ETB, the provider’s base currency', async () => {
|
||||||
|
mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const service = new ExchangeService({});
|
||||||
|
|
||||||
|
const rate = await service.getRate('USD', 'DJF');
|
||||||
|
|
||||||
|
// 163.4365 / 0.9203 — same arithmetic as converting via ETB by hand.
|
||||||
|
expect(rate).toBeCloseTo(163.4365 / 0.9203, 4);
|
||||||
|
expect(rate).toBeCloseTo(177.59, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives the inverse, DJF→USD, from the same pivot', async () => {
|
||||||
|
mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const service = new ExchangeService({});
|
||||||
|
|
||||||
|
const rate = await service.getRate('DJF', 'USD');
|
||||||
|
|
||||||
|
expect(rate).toBeCloseTo(0.9203 / 163.4365, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRateTable resolves every supported currency into the target in one call', async () => {
|
||||||
|
mockFetchOnce(CBE_FIXTURE);
|
||||||
|
const service = new ExchangeService({});
|
||||||
|
|
||||||
|
const fx = await service.getRateTable('DJF');
|
||||||
|
|
||||||
|
expect(fx.DJF).toBe(1);
|
||||||
|
expect(fx.USD).toBeCloseTo(163.4365 / 0.9203, 4);
|
||||||
|
expect(fx.ETB).toBeCloseTo(1 / 0.9203, 4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
import { BadRequestException, Body, Controller, Get, Param, Patch } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CURRENCY_CODES, CurrencyCode, CurrentUser } from "@edr/api-common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
@@ -8,6 +8,31 @@ import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
|||||||
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
|
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
|
||||||
import { ExchangeSettingsService } from "./exchange-settings.service";
|
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sane manual-rate ceiling per currency — bounded well outside any plausible
|
||||||
|
* published rate but far short of a fat-fingered magnitude error. USD trades
|
||||||
|
* in the hundreds (ETB per USD); DJF trades under 2 (ETB per DJF, since DJF
|
||||||
|
* itself is worth roughly 1/177th of a USD).
|
||||||
|
*/
|
||||||
|
const RATE_BOUNDS: Record<CurrencyCode, number> = {
|
||||||
|
ETB: 1,
|
||||||
|
USD: 10_000,
|
||||||
|
DJF: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FOREIGN_CURRENCIES = CURRENCY_CODES.filter((c) => c !== "ETB");
|
||||||
|
|
||||||
|
function assertSupportedCurrency(currency: string): (typeof FOREIGN_CURRENCIES)[number] {
|
||||||
|
const code = currency?.toUpperCase();
|
||||||
|
const match = FOREIGN_CURRENCIES.find((c) => c === code);
|
||||||
|
if (!match) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unsupported currency "${currency}" — must be one of ${FOREIGN_CURRENCIES.join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
@ApiTags("exchange-settings")
|
@ApiTags("exchange-settings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("exchange-settings")
|
@Controller("exchange-settings")
|
||||||
@@ -17,37 +42,51 @@ export class ExchangeSettingsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Current USD→ETB fallback rate and CBE feed health",
|
summary: "Current X→ETB fallback rates and CBE feed health, one entry per currency",
|
||||||
})
|
})
|
||||||
async get() {
|
async list() {
|
||||||
const setting = await this.service.get();
|
const settings = await this.service.list();
|
||||||
const status = this.service.getFeedStatus();
|
const byCurrency = new Map(settings.map((s) => [s.currency, s]));
|
||||||
|
|
||||||
return {
|
return FOREIGN_CURRENCIES.map((code) => {
|
||||||
fallbackRate: setting.fallbackRate,
|
const setting = byCurrency.get(code);
|
||||||
fallbackSource: setting.fallbackSource,
|
return {
|
||||||
lastSyncedAt: setting.lastSyncedAt,
|
currency: code,
|
||||||
updatedById: setting.updatedById,
|
fallbackRate: setting?.fallbackRate ?? null,
|
||||||
feed: status,
|
fallbackSource: setting?.fallbackSource ?? null,
|
||||||
};
|
lastSyncedAt: setting?.lastSyncedAt ?? null,
|
||||||
|
updatedById: setting?.updatedById ?? null,
|
||||||
|
feed: this.service.getFeedStatus(code),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch()
|
@Patch(":currency")
|
||||||
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
|
@BookingStaff([FREIGHT_PERMS.settings.exchangeRate.manage, FREIGHT_PERMS.admin])
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)",
|
"Set a currency's X→ETB fallback by hand (used only while CBE is unreachable)",
|
||||||
})
|
})
|
||||||
async update(
|
async update(
|
||||||
|
@Param("currency") currency: string,
|
||||||
@Body() dto: UpdateExchangeSettingDto,
|
@Body() dto: UpdateExchangeSettingDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
|
const code = assertSupportedCurrency(currency);
|
||||||
|
if (dto.fallbackRate > RATE_BOUNDS[code]) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Fallback rate ${dto.fallbackRate} is outside the accepted range for ${code} (max ${RATE_BOUNDS[code]})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await this.service.setManualRate(
|
const updated = await this.service.setManualRate(
|
||||||
|
code,
|
||||||
dto.fallbackRate,
|
dto.fallbackRate,
|
||||||
user?.id ?? null,
|
user?.id ?? null,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
currency: updated.currency,
|
||||||
fallbackRate: updated.fallbackRate,
|
fallbackRate: updated.fallbackRate,
|
||||||
fallbackSource: updated.fallbackSource,
|
fallbackSource: updated.fallbackSource,
|
||||||
lastSyncedAt: updated.lastSyncedAt,
|
lastSyncedAt: updated.lastSyncedAt,
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { CurrencyCode } from "@edr/api-common";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rate used before the row exists and before the first successful CBE fetch —
|
* Rate used before a currency's row exists and before its first successful
|
||||||
* the CBE USD transactional selling rate on 2026-08-04.
|
* CBE fetch. USD is the CBE transactional selling rate on 2026-08-04; DJF is
|
||||||
|
* the CBE transactional selling rate on 2026-09-04 (CBE started being read
|
||||||
|
* for DJF then).
|
||||||
*/
|
*/
|
||||||
const SEED_FALLBACK_RATE = 162.4165;
|
const SEED_FALLBACK_RATES: Partial<Record<CurrencyCode, number>> = {
|
||||||
|
USD: 162.4165,
|
||||||
|
DJF: 0.9203,
|
||||||
|
};
|
||||||
|
|
||||||
/** Health of the CBE feed, as surfaced to the backoffice. */
|
/** Health of the CBE feed for one currency, as surfaced to the backoffice. */
|
||||||
export interface ExchangeFeedStatus {
|
export interface ExchangeFeedStatus {
|
||||||
/** Rate most recently observed, whatever its source. */
|
/** Rate most recently observed, whatever its source. */
|
||||||
rate: number | null;
|
rate: number | null;
|
||||||
@@ -22,9 +28,17 @@ export interface ExchangeFeedStatus {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_FEED_STATUS: ExchangeFeedStatus = {
|
||||||
|
rate: null,
|
||||||
|
source: null,
|
||||||
|
lastSuccessAt: null,
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
* Owns the `exchange_settings` rows — one per foreign currency (USD, DJF) —
|
||||||
* CBE endpoint is unreachable.
|
* each holding the currency→ETB fallback used when the CBE endpoint is
|
||||||
|
* unreachable for it.
|
||||||
*
|
*
|
||||||
* The live CBE rate is always preferred. This value is only read on failure,
|
* The live CBE rate is always preferred. This value is only read on failure,
|
||||||
* and every successful fetch overwrites it, so it tracks the last known good
|
* and every successful fetch overwrites it, so it tracks the last known good
|
||||||
@@ -35,107 +49,113 @@ export class ExchangeSettingsService {
|
|||||||
private readonly logger = new Logger(ExchangeSettingsService.name);
|
private readonly logger = new Logger(ExchangeSettingsService.name);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feed health, recorded from the exchange provider's callbacks rather than
|
* Feed health per currency, recorded from the exchange provider's
|
||||||
* read off an injected `ExchangeService`. The provider is registered several
|
* callbacks rather than read off an injected `ExchangeService`. The
|
||||||
* times (bookings, contracts, warehouses), so no single instance sees every
|
* provider is registered several times (bookings, contracts, warehouses),
|
||||||
* fetch — and injecting one here would be circular, since those
|
* so no single instance sees every fetch — and injecting one here would be
|
||||||
* registrations inject *this* service.
|
* circular, since those registrations inject *this* service.
|
||||||
*/
|
*/
|
||||||
private feed: ExchangeFeedStatus = {
|
private feed = new Map<string, ExchangeFeedStatus>();
|
||||||
rate: null,
|
|
||||||
source: null,
|
|
||||||
lastSuccessAt: null,
|
|
||||||
lastError: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ExchangeSetting)
|
@InjectRepository(ExchangeSetting)
|
||||||
private readonly repository: Repository<ExchangeSetting>,
|
private readonly repository: Repository<ExchangeSetting>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Health of the CBE feed as last observed by any provider instance. */
|
/** Health of the CBE feed for `code` as last observed by any provider instance. */
|
||||||
getFeedStatus(): ExchangeFeedStatus {
|
getFeedStatus(code: CurrencyCode): ExchangeFeedStatus {
|
||||||
return { ...this.feed };
|
return { ...(this.feed.get(code) ?? EMPTY_FEED_STATUS) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The settings row, created at the seed rate on first access. */
|
/** The settings row for `code`, created at the seed rate on first access. */
|
||||||
async get(): Promise<ExchangeSetting> {
|
async get(code: CurrencyCode): Promise<ExchangeSetting> {
|
||||||
const existing = await this.repository.findOne({ where: {} });
|
const existing = await this.repository.findOne({ where: { currency: code } });
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
return this.repository.save(
|
return this.repository.save(
|
||||||
this.repository.create({
|
this.repository.create({
|
||||||
fallbackRate: SEED_FALLBACK_RATE,
|
currency: code,
|
||||||
|
fallbackRate: SEED_FALLBACK_RATES[code] ?? 1,
|
||||||
fallbackSource: "AUTO",
|
fallbackSource: "AUTO",
|
||||||
lastSyncedAt: null,
|
lastSyncedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every currency's settings row, for the backoffice settings list. */
|
||||||
|
async list(): Promise<ExchangeSetting[]> {
|
||||||
|
return this.repository.find({ order: { currency: "ASC" } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the stored fallback for the exchange provider. Returns `null` on any
|
* Reads the stored fallback for `code`, for the exchange provider. Returns
|
||||||
* failure so the provider falls through to its own static default rather
|
* `null` on any failure so the provider falls through to its own static
|
||||||
* than propagating a database error into a pricing call.
|
* default rather than propagating a database error into a pricing call.
|
||||||
*/
|
*/
|
||||||
async loadFallbackRate(): Promise<number | null> {
|
async loadFallbackRate(code: CurrencyCode): Promise<number | null> {
|
||||||
// Only reached when the live fetch failed, so this call is itself the
|
// Only reached when the live fetch failed, so this call is itself the
|
||||||
// signal that the feed is down.
|
// signal that the feed is down for this currency.
|
||||||
try {
|
try {
|
||||||
const { fallbackRate } = await this.get();
|
const { fallbackRate } = await this.get(code);
|
||||||
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
const usable = Number.isFinite(fallbackRate) && fallbackRate > 0;
|
||||||
this.feed = {
|
const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
|
||||||
...this.feed,
|
this.feed.set(code, {
|
||||||
rate: usable ? fallbackRate : this.feed.rate,
|
...previous,
|
||||||
|
rate: usable ? fallbackRate : previous.rate,
|
||||||
source: "stored",
|
source: "stored",
|
||||||
lastError: this.feed.lastError ?? "CBE endpoint unreachable",
|
lastError: previous.lastError ?? "CBE endpoint unreachable",
|
||||||
};
|
});
|
||||||
return usable ? fallbackRate : null;
|
return usable ? fallbackRate : null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
this.feed = { ...this.feed, source: "stored", lastError: message };
|
const previous = this.feed.get(code) ?? EMPTY_FEED_STATUS;
|
||||||
this.logger.warn(`Could not read stored exchange fallback: ${message}`);
|
this.feed.set(code, { ...previous, source: "stored", lastError: message });
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not read stored exchange fallback for ${code}: ${message}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records a freshly fetched live rate as the new fallback. Marked `AUTO`,
|
* Records a freshly fetched live rate as the new fallback for `code`.
|
||||||
* overwriting a manual entry — a manual rate is a stopgap for while CBE is
|
* Marked `AUTO`, overwriting a manual entry — a manual rate is a stopgap
|
||||||
* down, so a working CBE feed takes precedence again.
|
* for while CBE is down, so a working CBE feed takes precedence again.
|
||||||
*/
|
*/
|
||||||
async saveFallbackRate(rate: number): Promise<void> {
|
async saveFallbackRate(code: CurrencyCode, rate: number): Promise<void> {
|
||||||
// Only called after a successful fetch, so the feed is confirmed healthy.
|
// Only called after a successful fetch, so the feed is confirmed healthy.
|
||||||
this.feed = {
|
this.feed.set(code, {
|
||||||
rate,
|
rate,
|
||||||
source: "live",
|
source: "live",
|
||||||
lastSuccessAt: new Date().toISOString(),
|
lastSuccessAt: new Date().toISOString(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
};
|
});
|
||||||
|
|
||||||
const current = await this.get();
|
const current = await this.get(code);
|
||||||
await this.repository.update(current.id, {
|
await this.repository.update(current.id, {
|
||||||
fallbackRate: rate,
|
fallbackRate: rate,
|
||||||
fallbackSource: "AUTO",
|
fallbackSource: "AUTO",
|
||||||
lastSyncedAt: new Date(),
|
lastSyncedAt: new Date(),
|
||||||
updatedById: null,
|
updatedById: null,
|
||||||
});
|
});
|
||||||
this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`);
|
this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/${code}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
|
/** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
|
||||||
async setManualRate(
|
async setManualRate(
|
||||||
|
code: CurrencyCode,
|
||||||
rate: number,
|
rate: number,
|
||||||
updatedById?: string | null,
|
updatedById?: string | null,
|
||||||
): Promise<ExchangeSetting> {
|
): Promise<ExchangeSetting> {
|
||||||
const current = await this.get();
|
const current = await this.get(code);
|
||||||
await this.repository.update(current.id, {
|
await this.repository.update(current.id, {
|
||||||
fallbackRate: rate,
|
fallbackRate: rate,
|
||||||
fallbackSource: "MANUAL",
|
fallbackSource: "MANUAL",
|
||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`,
|
`Exchange fallback for ${code} set manually to ${rate} ETB/${code} by ${updatedById ?? "unknown user"}`,
|
||||||
);
|
);
|
||||||
return this.get();
|
return this.get(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ export const contractsDataset: ExportDataset = {
|
|||||||
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
|
{ key: 'paymentCurrency', label: 'Currency', type: 'select', options: [
|
||||||
{ value: 'ETB', label: 'ETB' },
|
{ value: 'ETB', label: 'ETB' },
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
|
{ value: 'DJF', label: 'DJF' },
|
||||||
] },
|
] },
|
||||||
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
|
{ key: 'serviceTypeId', label: 'Service type', type: 'text' },
|
||||||
// Routes are one-to-many on contract_routes, so these filter via EXISTS
|
// Routes are one-to-many on contract_routes, so these filter via EXISTS
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ export const invoicesDataset: ExportDataset = {
|
|||||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||||
{ value: 'ETB', label: 'ETB' },
|
{ value: 'ETB', label: 'ETB' },
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
|
{ value: 'DJF', label: 'DJF' },
|
||||||
] },
|
] },
|
||||||
{ key: 'minAmount', label: 'Min total', type: 'text' },
|
{ key: 'minAmount', label: 'Min total', type: 'text' },
|
||||||
{ key: 'maxAmount', label: 'Max total', type: 'text' },
|
{ key: 'maxAmount', label: 'Max total', type: 'text' },
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export const paymentsDataset: ExportDataset = {
|
|||||||
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
{ key: 'currency', label: 'Currency', type: 'select', options: [
|
||||||
{ value: 'ETB', label: 'ETB' },
|
{ value: 'ETB', label: 'ETB' },
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
|
{ value: 'DJF', label: 'DJF' },
|
||||||
] },
|
] },
|
||||||
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
|
{ key: 'search', label: 'Search order or transaction ID', type: 'text' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export class OverviewCustomerKpisDto {
|
|||||||
export class OverviewBillingKpisDto {
|
export class OverviewBillingKpisDto {
|
||||||
@ApiProperty() revenueMtdEtb!: number;
|
@ApiProperty() revenueMtdEtb!: number;
|
||||||
@ApiProperty() revenueMtdUsd!: number;
|
@ApiProperty() revenueMtdUsd!: number;
|
||||||
|
@ApiProperty() revenueMtdDjf!: number;
|
||||||
@ApiProperty() pendingPayments!: number;
|
@ApiProperty() pendingPayments!: number;
|
||||||
@ApiProperty() successfulPaymentsMtd!: number;
|
@ApiProperty() successfulPaymentsMtd!: number;
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,7 @@ export class OverviewPaymentTrendPointDto {
|
|||||||
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
@ApiProperty({ example: '2026-06-01' }) date!: string;
|
||||||
@ApiProperty() amountEtb!: number;
|
@ApiProperty() amountEtb!: number;
|
||||||
@ApiProperty() amountUsd!: number;
|
@ApiProperty() amountUsd!: number;
|
||||||
|
@ApiProperty() amountDjf!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverviewRecentBookingDto {
|
export class OverviewRecentBookingDto {
|
||||||
@@ -113,6 +115,7 @@ export class OverviewPeriodTotalsDto {
|
|||||||
@ApiProperty() bookingsCreated!: number;
|
@ApiProperty() bookingsCreated!: number;
|
||||||
@ApiProperty() revenueEtb!: number;
|
@ApiProperty() revenueEtb!: number;
|
||||||
@ApiProperty() revenueUsd!: number;
|
@ApiProperty() revenueUsd!: number;
|
||||||
|
@ApiProperty() revenueDjf!: number;
|
||||||
@ApiProperty() tons!: number;
|
@ApiProperty() tons!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ export class OverviewRevenueSliceDto {
|
|||||||
@ApiProperty() label!: string;
|
@ApiProperty() label!: string;
|
||||||
@ApiProperty() amountEtb!: number;
|
@ApiProperty() amountEtb!: number;
|
||||||
@ApiProperty() amountUsd!: number;
|
@ApiProperty() amountUsd!: number;
|
||||||
|
@ApiProperty() amountDjf!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverviewTonsTrendPointDto {
|
export class OverviewTonsTrendPointDto {
|
||||||
@@ -132,6 +136,7 @@ export class OverviewRevenueFlowDto {
|
|||||||
@ApiProperty() freightType!: string;
|
@ApiProperty() freightType!: string;
|
||||||
@ApiProperty() amountEtb!: number;
|
@ApiProperty() amountEtb!: number;
|
||||||
@ApiProperty() amountUsd!: number;
|
@ApiProperty() amountUsd!: number;
|
||||||
|
@ApiProperty() amountDjf!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OverviewHeatmapCellDto {
|
export class OverviewHeatmapCellDto {
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ export class OverviewRepository {
|
|||||||
async getBillingKpis(dirs?: string[]): Promise<{
|
async getBillingKpis(dirs?: string[]): Promise<{
|
||||||
revenueMtdEtb: number;
|
revenueMtdEtb: number;
|
||||||
revenueMtdUsd: number;
|
revenueMtdUsd: number;
|
||||||
|
revenueMtdDjf: number;
|
||||||
pendingPayments: number;
|
pendingPayments: number;
|
||||||
successfulPaymentsMtd: number;
|
successfulPaymentsMtd: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -279,6 +280,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"revenueMtdUsd",
|
"revenueMtdUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"revenueMtdDjf",
|
||||||
|
)
|
||||||
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
.addSelect(`COUNT(*)::int`, "successfulPaymentsMtd")
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
@@ -298,6 +303,7 @@ export class OverviewRepository {
|
|||||||
return {
|
return {
|
||||||
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
revenueMtdEtb: Number(revenueRow?.revenueMtdEtb ?? 0),
|
||||||
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
revenueMtdUsd: Number(revenueRow?.revenueMtdUsd ?? 0),
|
||||||
|
revenueMtdDjf: Number(revenueRow?.revenueMtdDjf ?? 0),
|
||||||
pendingPayments,
|
pendingPayments,
|
||||||
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
successfulPaymentsMtd: Number(revenueRow?.successfulPaymentsMtd ?? 0),
|
||||||
};
|
};
|
||||||
@@ -370,7 +376,7 @@ export class OverviewRepository {
|
|||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
offsetDays = 0,
|
offsetDays = 0,
|
||||||
): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ date: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -386,6 +392,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`,
|
||||||
@@ -394,12 +404,13 @@ export class OverviewRepository {
|
|||||||
.andWhere(scope.sql, scope.params)
|
.andWhere(scope.sql, scope.params)
|
||||||
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
.groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`)
|
||||||
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
.orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC")
|
||||||
.getRawMany<{ date: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ date: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,7 +521,7 @@ export class OverviewRepository {
|
|||||||
async getPaymentsByMethod(
|
async getPaymentsByMethod(
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
): Promise<
|
): Promise<
|
||||||
{ method: string; count: number; amountEtb: number; amountUsd: number }[]
|
{ method: string; count: number; amountEtb: number; amountUsd: number; amountDjf: number }[]
|
||||||
> {
|
> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
@@ -525,6 +536,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF' AND payment.status = 'success'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where(scope.sql, scope.params)
|
.where(scope.sql, scope.params)
|
||||||
.groupBy("payment.method")
|
.groupBy("payment.method")
|
||||||
.orderBy("count", "DESC")
|
.orderBy("count", "DESC")
|
||||||
@@ -533,6 +548,7 @@ export class OverviewRepository {
|
|||||||
count: string;
|
count: string;
|
||||||
amountEtb: string;
|
amountEtb: string;
|
||||||
amountUsd: string;
|
amountUsd: string;
|
||||||
|
amountDjf: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -540,6 +556,7 @@ export class OverviewRepository {
|
|||||||
count: Number(row.count),
|
count: Number(row.count),
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +597,7 @@ export class OverviewRepository {
|
|||||||
bookingsCreated: number;
|
bookingsCreated: number;
|
||||||
revenueEtb: number;
|
revenueEtb: number;
|
||||||
revenueUsd: number;
|
revenueUsd: number;
|
||||||
|
revenueDjf: number;
|
||||||
tons: number;
|
tons: number;
|
||||||
}> {
|
}> {
|
||||||
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
const bookingScope = directionScopeSql("booking.trade_direction", dirs);
|
||||||
@@ -605,13 +623,17 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"revenueUsd",
|
"revenueUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"revenueDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
windowSql("COALESCE(payment.paid_at, payment.created_at)"),
|
||||||
{ days, offsetDays },
|
{ days, offsetDays },
|
||||||
)
|
)
|
||||||
.andWhere(paymentScope.sql, paymentScope.params)
|
.andWhere(paymentScope.sql, paymentScope.params)
|
||||||
.getRawOne<{ revenueEtb: string; revenueUsd: string }>(),
|
.getRawOne<{ revenueEtb: string; revenueUsd: string; revenueDjf: string }>(),
|
||||||
this.cargoRepository
|
this.cargoRepository
|
||||||
.createQueryBuilder("cargo")
|
.createQueryBuilder("cargo")
|
||||||
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
.leftJoin(Booking, "booking", "booking.id = cargo.booking_id")
|
||||||
@@ -626,6 +648,7 @@ export class OverviewRepository {
|
|||||||
bookingsCreated,
|
bookingsCreated,
|
||||||
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
revenueEtb: Number(revenueRow?.revenueEtb ?? 0),
|
||||||
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
revenueUsd: Number(revenueRow?.revenueUsd ?? 0),
|
||||||
|
revenueDjf: Number(revenueRow?.revenueDjf ?? 0),
|
||||||
tons: Number(tonsRow?.tons ?? 0),
|
tons: Number(tonsRow?.tons ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -634,7 +657,7 @@ export class OverviewRepository {
|
|||||||
async getRevenueByDirection(
|
async getRevenueByDirection(
|
||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -648,6 +671,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
@@ -656,12 +683,13 @@ export class OverviewRepository {
|
|||||||
.andWhere(scope.sql, scope.params)
|
.andWhere(scope.sql, scope.params)
|
||||||
.andWhere("booking.trade_direction IS NOT NULL")
|
.andWhere("booking.trade_direction IS NOT NULL")
|
||||||
.groupBy("booking.trade_direction")
|
.groupBy("booking.trade_direction")
|
||||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: row.label,
|
label: row.label,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -669,7 +697,7 @@ export class OverviewRepository {
|
|||||||
async getRevenueByFreightType(
|
async getRevenueByFreightType(
|
||||||
days: number,
|
days: number,
|
||||||
dirs?: string[],
|
dirs?: string[],
|
||||||
): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> {
|
): Promise<{ label: string; amountEtb: number; amountUsd: number; amountDjf: number }[]> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
const rows = await this.paymentRepository
|
const rows = await this.paymentRepository
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
@@ -683,6 +711,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
@@ -691,12 +723,13 @@ export class OverviewRepository {
|
|||||||
.andWhere(scope.sql, scope.params)
|
.andWhere(scope.sql, scope.params)
|
||||||
.andWhere("booking.freight_type IS NOT NULL")
|
.andWhere("booking.freight_type IS NOT NULL")
|
||||||
.groupBy("booking.freight_type")
|
.groupBy("booking.freight_type")
|
||||||
.getRawMany<{ label: string; amountEtb: string; amountUsd: string }>();
|
.getRawMany<{ label: string; amountEtb: string; amountUsd: string; amountDjf: string }>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
label: row.label,
|
label: row.label,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,6 +767,7 @@ export class OverviewRepository {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
amountEtb: number;
|
amountEtb: number;
|
||||||
amountUsd: number;
|
amountUsd: number;
|
||||||
|
amountDjf: number;
|
||||||
}[]
|
}[]
|
||||||
> {
|
> {
|
||||||
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
const scope = bookingRefScopeSql("payment.ref_id", dirs);
|
||||||
@@ -750,6 +784,10 @@ export class OverviewRepository {
|
|||||||
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`,
|
||||||
"amountUsd",
|
"amountUsd",
|
||||||
)
|
)
|
||||||
|
.addSelect(
|
||||||
|
`COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'DJF'), 0)`,
|
||||||
|
"amountDjf",
|
||||||
|
)
|
||||||
.where("payment.status = :status", { status: "success" })
|
.where("payment.status = :status", { status: "success" })
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
`COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`,
|
||||||
@@ -765,6 +803,7 @@ export class OverviewRepository {
|
|||||||
freightType: string;
|
freightType: string;
|
||||||
amountEtb: string;
|
amountEtb: string;
|
||||||
amountUsd: string;
|
amountUsd: string;
|
||||||
|
amountDjf: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -772,6 +811,7 @@ export class OverviewRepository {
|
|||||||
freightType: row.freightType,
|
freightType: row.freightType,
|
||||||
amountEtb: Number(row.amountEtb),
|
amountEtb: Number(row.amountEtb),
|
||||||
amountUsd: Number(row.amountUsd),
|
amountUsd: Number(row.amountUsd),
|
||||||
|
amountDjf: Number(row.amountDjf),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,9 @@ export class UpdateManualPaymentSettingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
usdEnabled?: boolean;
|
usdEnabled?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Allow manual settlement of DJF invoices" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
djfEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export class ManualPaymentSetting extends BaseEntity {
|
|||||||
@Column({ name: "usd_enabled", type: "boolean", default: true })
|
@Column({ name: "usd_enabled", type: "boolean", default: true })
|
||||||
usdEnabled!: boolean;
|
usdEnabled!: boolean;
|
||||||
|
|
||||||
|
/** Manual settlement allowed for DJF invoices. */
|
||||||
|
@Column({ name: "djf_enabled", type: "boolean", default: true })
|
||||||
|
djfEnabled!: boolean;
|
||||||
|
|
||||||
/** IAM user id of the last operator to change either toggle. */
|
/** IAM user id of the last operator to change either toggle. */
|
||||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||||
updatedById?: string | null;
|
updatedById?: string | null;
|
||||||
|
|||||||
@@ -4,16 +4,23 @@ import { Repository } from "typeorm";
|
|||||||
|
|
||||||
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity";
|
||||||
|
|
||||||
/** The two currencies an invoice can be settled by hand in. */
|
/** The currencies an invoice can be settled by hand in. */
|
||||||
export type ManualPaymentCurrency = "ETB" | "USD";
|
export type ManualPaymentCurrency = "ETB" | "USD" | "DJF";
|
||||||
|
|
||||||
|
const FIELD_BY_CURRENCY: Record<ManualPaymentCurrency, "etbEnabled" | "usdEnabled" | "djfEnabled"> = {
|
||||||
|
ETB: "etbEnabled",
|
||||||
|
USD: "usdEnabled",
|
||||||
|
DJF: "djfEnabled",
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the single `manual_payment_settings` row: whether Finance may settle
|
* Owns the single `manual_payment_settings` row: whether Finance may settle
|
||||||
* invoices by hand, per currency.
|
* invoices by hand, per currency.
|
||||||
*
|
*
|
||||||
* Defaults mirror how the platform behaved before the toggles existed — USD
|
* Defaults mirror how the platform behaved before the toggles existed — USD
|
||||||
* has always been bank-transfer-only so it starts ON; ETB manual settlement is
|
* and DJF have always been bank-transfer-capable so they start ON; ETB manual
|
||||||
* the new capability and starts OFF, so enabling it is a deliberate act.
|
* settlement is the new capability and starts OFF, so enabling it is a
|
||||||
|
* deliberate act.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ManualPaymentSettingsService {
|
export class ManualPaymentSettingsService {
|
||||||
@@ -30,7 +37,7 @@ export class ManualPaymentSettingsService {
|
|||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
return this.repository.save(
|
return this.repository.save(
|
||||||
this.repository.create({ etbEnabled: false, usdEnabled: true }),
|
this.repository.create({ etbEnabled: false, usdEnabled: true, djfEnabled: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,31 +47,34 @@ export class ManualPaymentSettingsService {
|
|||||||
const enabled: ManualPaymentCurrency[] = [];
|
const enabled: ManualPaymentCurrency[] = [];
|
||||||
if (setting.etbEnabled) enabled.push("ETB");
|
if (setting.etbEnabled) enabled.push("ETB");
|
||||||
if (setting.usdEnabled) enabled.push("USD");
|
if (setting.usdEnabled) enabled.push("USD");
|
||||||
|
if (setting.djfEnabled) enabled.push("DJF");
|
||||||
return enabled;
|
return enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether one currency may be settled by hand right now. */
|
/** Whether one currency may be settled by hand right now. */
|
||||||
async isEnabled(currency: string | null | undefined): Promise<boolean> {
|
async isEnabled(currency: string | null | undefined): Promise<boolean> {
|
||||||
const upper = currency?.toUpperCase();
|
const upper = currency?.toUpperCase();
|
||||||
if (upper !== "ETB" && upper !== "USD") return false;
|
const field = FIELD_BY_CURRENCY[upper as ManualPaymentCurrency];
|
||||||
|
if (!field) return false;
|
||||||
const setting = await this.get();
|
const setting = await this.get();
|
||||||
return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled;
|
return setting[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flip either toggle; an omitted field leaves that currency unchanged. */
|
/** Flip any toggle; an omitted field leaves that currency unchanged. */
|
||||||
async update(
|
async update(
|
||||||
patch: { etbEnabled?: boolean; usdEnabled?: boolean },
|
patch: { etbEnabled?: boolean; usdEnabled?: boolean; djfEnabled?: boolean },
|
||||||
updatedById?: string | null,
|
updatedById?: string | null,
|
||||||
): Promise<ManualPaymentSetting> {
|
): Promise<ManualPaymentSetting> {
|
||||||
const current = await this.get();
|
const current = await this.get();
|
||||||
await this.repository.update(current.id, {
|
await this.repository.update(current.id, {
|
||||||
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }),
|
||||||
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }),
|
||||||
|
...(patch.djfEnabled === undefined ? {} : { djfEnabled: patch.djfEnabled }),
|
||||||
updatedById: updatedById ?? null,
|
updatedById: updatedById ?? null,
|
||||||
});
|
});
|
||||||
const updated = await this.get();
|
const updated = await this.get();
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`,
|
`Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} DJF=${updated.djfEnabled} by ${updatedById ?? "unknown user"}`,
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PaymentRefundEntity } from "./payment-refund.entity";
|
|||||||
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||||
type PaymentType = string
|
type PaymentType = string
|
||||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank" | "cbe-bill"
|
||||||
type Currency = "ETB" | "USD"
|
type Currency = "ETB" | "USD" | "DJF"
|
||||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'payments' })
|
@Entity({ schema: 'freight', name: 'payments' })
|
||||||
@@ -25,7 +25,7 @@ export class PaymentEntity extends BaseEntity {
|
|||||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank", "cbe-bill"] })
|
||||||
method!: PaymentMethod
|
method!: PaymentMethod
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["ETB", "USD"] })
|
@Column({ type: "enum", enum: ["ETB", "USD", "DJF"] })
|
||||||
currency!: Currency
|
currency!: Currency
|
||||||
|
|
||||||
@Column({ type: "numeric" })
|
@Column({ type: "numeric" })
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Transform } from "class-transformer";
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata fields for `POST /publications`, sent alongside the file as
|
||||||
|
* multipart/form-data — every field arrives as a string, so numeric/boolean
|
||||||
|
* fields need an explicit `@Transform` (global `enableImplicitConversion` is
|
||||||
|
* off, see main.ts).
|
||||||
|
*/
|
||||||
|
export class CreatePublicationDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(60)
|
||||||
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Transform(({ value }) => Number(value ?? 0))
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
@Transform(({ value }) => value === undefined || value === "true" || value === true)
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { PartialType } from "@nestjs/mapped-types";
|
||||||
|
|
||||||
|
import { CreatePublicationDto } from "./create-publication.dto";
|
||||||
|
|
||||||
|
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, Index } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One document in the freight portal's public library (/publications) — a
|
||||||
|
* PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and
|
||||||
|
* curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs
|
||||||
|
* edited in place, this is a real table of many rows and each upload is a
|
||||||
|
* whole new file — there is no version-history log here, a re-upload just
|
||||||
|
* replaces the file columns (see `PublicationsService.replaceFile`).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: "freight", name: "publications" })
|
||||||
|
@Index(["published", "sortOrder"])
|
||||||
|
export class Publication extends BaseEntity {
|
||||||
|
@Column({ name: "title", type: "varchar", length: 200 })
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@Column({ name: "description", type: "text", nullable: true })
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: "category", type: "varchar", length: 60, nullable: true })
|
||||||
|
category?: string | null;
|
||||||
|
|
||||||
|
/** MinIO object key. Never a signed URL — those expire; sign on read instead. */
|
||||||
|
@Column({ name: "file_key", type: "varchar", length: 512 })
|
||||||
|
fileKey!: string;
|
||||||
|
|
||||||
|
/** Original filename, used for the download's Content-Disposition. */
|
||||||
|
@Column({ name: "file_name", type: "varchar", length: 255 })
|
||||||
|
fileName!: string;
|
||||||
|
|
||||||
|
@Column({ name: "file_mime_type", type: "varchar", length: 120 })
|
||||||
|
fileMimeType!: string;
|
||||||
|
|
||||||
|
@Column({ name: "file_size_bytes", type: "bigint" })
|
||||||
|
fileSizeBytes!: number;
|
||||||
|
|
||||||
|
/** Manual ordering in the backoffice list and the public grid. */
|
||||||
|
@Column({ name: "sort_order", type: "integer", default: 0 })
|
||||||
|
sortOrder!: number;
|
||||||
|
|
||||||
|
/** Unpublish without deleting — hides it from the public list only. */
|
||||||
|
@Column({ name: "published", type: "boolean", default: true })
|
||||||
|
published!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: "published_at", type: "timestamptz", nullable: true })
|
||||||
|
publishedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: "uploaded_by_id", type: "uuid", nullable: true })
|
||||||
|
uploadedById?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
|
import { PublicationsService } from "./publications.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The portal's /publications page — a public library of PDFs, Markdown
|
||||||
|
* write-ups and PowerPoint decks about the platform. No login required, same
|
||||||
|
* as /help, /faq and the legal pages: prospects reach it before any account
|
||||||
|
* exists.
|
||||||
|
*/
|
||||||
|
@ApiTags("publications")
|
||||||
|
@Public()
|
||||||
|
@Controller("publications")
|
||||||
|
export class PublicPublicationsController {
|
||||||
|
constructor(private readonly service: PublicationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
// Cheap to serve stale for a few minutes; every anonymous page view hits it.
|
||||||
|
@Header("Cache-Control", "public, max-age=300")
|
||||||
|
@ApiOperation({ summary: "List published publications for the public library" })
|
||||||
|
list() {
|
||||||
|
return this.service.listPublic();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":id/file")
|
||||||
|
@ApiQuery({
|
||||||
|
name: "download",
|
||||||
|
required: false,
|
||||||
|
description: "Set to 1/true to force a download instead of inline preview.",
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: "Stream a published publication's file" })
|
||||||
|
async getFile(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Query("download") download: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { stream, record } = await this.service.getPublishedFileStream(id);
|
||||||
|
const forceDownload = download === "1" || download === "true";
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", record.fileMimeType);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`,
|
||||||
|
);
|
||||||
|
res.setHeader("Cache-Control", "public, max-age=300");
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||||
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||||
|
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||||
|
import { PublicationsService } from "./publications.service";
|
||||||
|
|
||||||
|
const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||||
|
const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||||
|
|
||||||
|
@ApiTags("publications")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("publications")
|
||||||
|
export class PublicationsController {
|
||||||
|
constructor(private readonly service: PublicationsService) {}
|
||||||
|
|
||||||
|
@Get("admin")
|
||||||
|
@BookingStaff(READ)
|
||||||
|
@ApiOperation({ summary: "List every publication, published or not" })
|
||||||
|
list() {
|
||||||
|
return this.service.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@BookingStaff(WRITE)
|
||||||
|
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({ summary: "Upload a new publication" })
|
||||||
|
create(
|
||||||
|
@UploadedFile() file: Express.Multer.File,
|
||||||
|
@Body() dto: CreatePublicationDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(file, dto, user?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(":id")
|
||||||
|
@BookingStaff(WRITE)
|
||||||
|
@ApiOperation({ summary: "Update a publication's title, description, category, order or published state" })
|
||||||
|
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) {
|
||||||
|
return this.service.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/file")
|
||||||
|
@BookingStaff(WRITE)
|
||||||
|
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({ summary: "Replace a publication's file" })
|
||||||
|
replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) {
|
||||||
|
return this.service.replaceFile(id, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@BookingStaff(WRITE)
|
||||||
|
@ApiOperation({ summary: "Remove a publication" })
|
||||||
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
return this.service.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
|
import { MinioModule } from "../minio/minio.module";
|
||||||
|
import { Publication } from "./entities/publication.entity";
|
||||||
|
import { PublicationsController } from "./publications.controller";
|
||||||
|
import { PublicationsRepository } from "./publications.repository";
|
||||||
|
import { PublicationsService } from "./publications.service";
|
||||||
|
import { PublicPublicationsController } from "./public-publications.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Publication]), MinioModule],
|
||||||
|
controllers: [PublicPublicationsController, PublicationsController],
|
||||||
|
providers: [PublicationsRepository, PublicationsService],
|
||||||
|
exports: [PublicationsService],
|
||||||
|
})
|
||||||
|
export class PublicationsModule {}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { Publication } from "./entities/publication.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PublicationsRepository extends BaseRepository<Publication> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Publication)
|
||||||
|
repository: Repository<Publication>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public list: published rows only, in display order. */
|
||||||
|
findPublished(): Promise<Publication[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { published: true },
|
||||||
|
order: { sortOrder: "ASC", publishedAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin list: every row, published or not. */
|
||||||
|
override findAll(): Promise<Publication[]> {
|
||||||
|
return this.repository.find({ order: { sortOrder: "ASC" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import {
|
||||||
|
PublicationSummary,
|
||||||
|
PUBLICATION_ALLOWED_MIME_TYPES,
|
||||||
|
PUBLICATION_FILE_PREFIX,
|
||||||
|
} from "@edr/types";
|
||||||
|
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { extname } from "path";
|
||||||
|
import { Readable } from "stream";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
import { MinioService } from "../minio/minio.service";
|
||||||
|
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||||
|
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||||
|
import { Publication } from "./entities/publication.entity";
|
||||||
|
import { PublicationsRepository } from "./publications.repository";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PublicationsService {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: PublicationsRepository,
|
||||||
|
private readonly minio: MinioService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File {
|
||||||
|
if (!file) throw new BadRequestException("No file uploaded");
|
||||||
|
if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
dto: CreatePublicationDto,
|
||||||
|
actorId: string | null,
|
||||||
|
): Promise<Publication> {
|
||||||
|
this.assertAllowedFile(file);
|
||||||
|
|
||||||
|
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||||
|
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||||
|
|
||||||
|
const published = dto.published ?? true;
|
||||||
|
return this.repository.create({
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? null,
|
||||||
|
category: dto.category ?? null,
|
||||||
|
fileKey: key,
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileMimeType: file.mimetype,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
published,
|
||||||
|
publishedAt: published ? new Date() : null,
|
||||||
|
uploadedById: actorId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePublicationDto): Promise<Publication> {
|
||||||
|
const existing = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const patch: Partial<Publication> = {
|
||||||
|
...(dto.title !== undefined && { title: dto.title }),
|
||||||
|
...(dto.description !== undefined && { description: dto.description }),
|
||||||
|
...(dto.category !== undefined && { category: dto.category }),
|
||||||
|
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dto.published !== undefined && dto.published !== existing.published) {
|
||||||
|
patch.published = dto.published;
|
||||||
|
patch.publishedAt = dto.published ? new Date() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.repository.update(id, patch);
|
||||||
|
if (!updated) throw new NotFoundException(`Publication ${id} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */
|
||||||
|
async replaceFile(id: string, file?: Express.Multer.File): Promise<Publication> {
|
||||||
|
this.assertAllowedFile(file);
|
||||||
|
const existing = await this.getByIdOrThrow(id);
|
||||||
|
|
||||||
|
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||||
|
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||||
|
|
||||||
|
const updated = await this.repository.update(id, {
|
||||||
|
fileKey: key,
|
||||||
|
fileName: file.originalname,
|
||||||
|
fileMimeType: file.mimetype,
|
||||||
|
fileSizeBytes: file.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.minio.deleteFile(existing.fileKey);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.getByIdOrThrow(id);
|
||||||
|
await this.repository.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin list — every row, published or not. */
|
||||||
|
list(): Promise<Publication[]> {
|
||||||
|
return this.repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public list — published rows only. No file URL here: a presigned MinIO
|
||||||
|
* URL isn't reachable from the browser (see `fileViewUrl` in the portal's
|
||||||
|
* `apiConfig.ts`); the portal builds each file's URL itself from `id` via
|
||||||
|
* `GET /publications/:id/file`.
|
||||||
|
*/
|
||||||
|
async listPublic(): Promise<PublicationSummary[]> {
|
||||||
|
const rows = await this.repository.findPublished();
|
||||||
|
return rows.map((row) => this.toSummary(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSummary(row: Publication): PublicationSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description ?? null,
|
||||||
|
category: row.category ?? null,
|
||||||
|
fileName: row.fileName,
|
||||||
|
fileMimeType: row.fileMimeType,
|
||||||
|
fileSizeBytes: Number(row.fileSizeBytes),
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the public/staff file route: streams a published row's bytes. */
|
||||||
|
async getPublishedFileStream(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ stream: Readable; record: Publication }> {
|
||||||
|
const record = await this.repository.findById(id);
|
||||||
|
if (!record || !record.published) {
|
||||||
|
throw new NotFoundException(`Publication ${id} not found`);
|
||||||
|
}
|
||||||
|
return { stream: await this.minio.getFileStream(record.fileKey), record };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getByIdOrThrow(id: string): Promise<Publication> {
|
||||||
|
const record = await this.repository.findById(id);
|
||||||
|
if (!record) throw new NotFoundException(`Publication ${id} not found`);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -466,6 +466,7 @@ export const CURRENCY_FILTER: ReportFilterDef = {
|
|||||||
options: [
|
options: [
|
||||||
{ value: 'ETB', label: 'ETB' },
|
{ value: 'ETB', label: 'ETB' },
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
|
{ value: 'DJF', label: 'DJF' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,3 +27,29 @@ describe('deriveRateType — surcharge triggers', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deriveRateType — empty container freight', () => {
|
||||||
|
it('splits empty freight from laden freight by direction', () => {
|
||||||
|
expect(deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS' })).toBe(
|
||||||
|
'EMPTY_CONTAINER_IMPORT',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
deriveRateType({
|
||||||
|
appliesTo: 'EMPTY_CONTAINER',
|
||||||
|
trigger: 'ALWAYS',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
}),
|
||||||
|
).toBe('EMPTY_CONTAINER_EXPORT');
|
||||||
|
});
|
||||||
|
|
||||||
|
// UQ_rates_pattern keys on rate_type but not on applies_to, so an empty rate
|
||||||
|
// sharing CONTAINER_IMPORT would collide with the laden rate for the same
|
||||||
|
// lane and container type. The distinct rateType is what keeps both fileable.
|
||||||
|
it('never resolves to the laden container rate type', () => {
|
||||||
|
for (const tradeDirection of ['IMPORT', 'EXPORT']) {
|
||||||
|
expect(
|
||||||
|
deriveRateType({ appliesTo: 'EMPTY_CONTAINER', trigger: 'ALWAYS', tradeDirection }),
|
||||||
|
).not.toBe(tradeDirection === 'EXPORT' ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export function deriveRateType(input: {
|
|||||||
switch (appliesTo) {
|
switch (appliesTo) {
|
||||||
case 'CONTAINER':
|
case 'CONTAINER':
|
||||||
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
|
return isExport ? 'CONTAINER_EXPORT' : 'CONTAINER_IMPORT';
|
||||||
|
case 'EMPTY_CONTAINER':
|
||||||
|
return isExport ? 'EMPTY_CONTAINER_EXPORT' : 'EMPTY_CONTAINER_IMPORT';
|
||||||
case 'BULK':
|
case 'BULK':
|
||||||
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
|
return isExport ? 'BULK_EXPORT' : 'BULK_IMPORT';
|
||||||
case 'INTERCITY':
|
case 'INTERCITY':
|
||||||
|
|||||||
@@ -84,3 +84,25 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
|||||||
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty equipment carries no cargo, so no weighed unit applies — only the box
|
||||||
|
* and the wagon it rides on.
|
||||||
|
*/
|
||||||
|
describe("allowedRateUnits — empty container freight", () => {
|
||||||
|
it("offers per-container and per-wagon only", () => {
|
||||||
|
expect(
|
||||||
|
allowedRateUnits({ appliesTo: "EMPTY_CONTAINER", trigger: "ALWAYS" }),
|
||||||
|
).toEqual(["PER_CONTAINER", "PER_WAGON"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never offers a weighed unit, even for a per-item commodity scope", () => {
|
||||||
|
expect(
|
||||||
|
allowedRateUnits({
|
||||||
|
appliesTo: "EMPTY_CONTAINER",
|
||||||
|
trigger: "ALWAYS",
|
||||||
|
cargoUnitOfMeasure: "PER_ITEM",
|
||||||
|
}),
|
||||||
|
).not.toContain("PER_ITEM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ function unitsForShape(input: {
|
|||||||
switch (appliesTo) {
|
switch (appliesTo) {
|
||||||
case 'CONTAINER':
|
case 'CONTAINER':
|
||||||
return ['PER_CONTAINER', 'PER_WAGON'];
|
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
|
case 'EMPTY_CONTAINER':
|
||||||
|
// Empty equipment carries no cargo to weigh, so the only bases that mean
|
||||||
|
// anything are the box itself and the wagon it rides on.
|
||||||
|
return ['PER_CONTAINER', 'PER_WAGON'];
|
||||||
case 'BULK':
|
case 'BULK':
|
||||||
return ['PER_TON', 'PER_WAGON'];
|
return ['PER_TON', 'PER_WAGON'];
|
||||||
case 'INTERCITY':
|
case 'INTERCITY':
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import { Yard } from './yard.entity';
|
|||||||
export const RATE_TYPES = [
|
export const RATE_TYPES = [
|
||||||
'CONTAINER_IMPORT',
|
'CONTAINER_IMPORT',
|
||||||
'CONTAINER_EXPORT',
|
'CONTAINER_EXPORT',
|
||||||
|
// Empty equipment moved as freight in its own right — no cargo, priced per
|
||||||
|
// box by size. Distinct from CONTAINER_IMPORT because UQ_rates_pattern keys
|
||||||
|
// on rate_type: an empty 40ft Djibouti->Modjo rate filed as CONTAINER_IMPORT
|
||||||
|
// would collide with the laden 40ft rate for the same lane.
|
||||||
|
'EMPTY_CONTAINER_IMPORT',
|
||||||
|
'EMPTY_CONTAINER_EXPORT',
|
||||||
'BULK_IMPORT',
|
'BULK_IMPORT',
|
||||||
'BULK_EXPORT',
|
'BULK_EXPORT',
|
||||||
'INTERCITY_BULK',
|
'INTERCITY_BULK',
|
||||||
@@ -59,12 +65,14 @@ export type RateUnit = typeof RATE_UNITS[number];
|
|||||||
* lookup and snapshots).
|
* lookup and snapshots).
|
||||||
*
|
*
|
||||||
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
|
* - BULK / CONTAINER / INTERCITY : base rail freight (trigger = ALWAYS)
|
||||||
|
* - EMPTY_CONTAINER : base rail freight for empty equipment
|
||||||
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
|
* - FIRST_MILE / LAST_MILE : pickup / delivery legs
|
||||||
* - OTHER : trigger-based surcharges (hazard, reefer …)
|
* - OTHER : trigger-based surcharges (hazard, reefer …)
|
||||||
*/
|
*/
|
||||||
export const RATE_APPLIES_TO = [
|
export const RATE_APPLIES_TO = [
|
||||||
'BULK',
|
'BULK',
|
||||||
'CONTAINER',
|
'CONTAINER',
|
||||||
|
'EMPTY_CONTAINER',
|
||||||
'INTERCITY',
|
'INTERCITY',
|
||||||
'FIRST_MILE',
|
'FIRST_MILE',
|
||||||
'LAST_MILE',
|
'LAST_MILE',
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.reposito
|
|||||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
|
|
||||||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = [
|
||||||
|
'BULK',
|
||||||
|
'CONTAINER',
|
||||||
|
'EMPTY_CONTAINER',
|
||||||
|
'INTERCITY',
|
||||||
|
];
|
||||||
/**
|
/**
|
||||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||||
* container fee then names its container type and a bulk fee its commodity.
|
* container fee then names its container type and a bulk fee its commodity.
|
||||||
@@ -381,6 +386,30 @@ export class RatesService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appliesTo === 'EMPTY_CONTAINER') {
|
||||||
|
// Northbound repositioning only. Southbound empties are already sold by
|
||||||
|
// the WITH_RETURN surcharge and empty_return_requests; a second path to
|
||||||
|
// the same movement would let the business double-sell it.
|
||||||
|
if (tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An empty container rate is import-only for now.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Size is the entire scope of an empty rate — there is no cargo to narrow
|
||||||
|
// by, so the box type must be named and a commodity must not be.
|
||||||
|
if (!containerTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An empty container rate must name the container type it covers.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cargoTypeId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'An empty container rate cannot be scoped to a bulk cargo type.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
`${appliesTo === 'BULK' ? 'Bulk' : 'Container'} freight must be either IMPORT or EXPORT.`,
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import { CrewDutyRole } from './entities/train-crew-assignment.entity';
|
||||||
|
import { TrainCrewRole } from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
AssignmentFacts,
|
||||||
|
CorridorContext,
|
||||||
|
CorridorYard,
|
||||||
|
CrewDemandInput,
|
||||||
|
legAllowsNationality,
|
||||||
|
overtimeHours,
|
||||||
|
specializedRequirements,
|
||||||
|
technicianRequirement,
|
||||||
|
validateCrewComposition,
|
||||||
|
} from './crew-composition.rules';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slice of the real corridor, using the production display_order values:
|
||||||
|
* GMP 3, Feto 7, Meiso 10, Dire Dawa 12, Nagad 19.
|
||||||
|
*/
|
||||||
|
const YARD: Record<string, CorridorYard> = {
|
||||||
|
GMP: { id: 'y-gmp', label: 'GMP', country: 'Ethiopia', displayOrder: 3 },
|
||||||
|
FETO: { id: 'y-feto', label: 'Feto', country: 'Ethiopia', displayOrder: 7 },
|
||||||
|
MEISO: { id: 'y-meiso', label: 'Meiso', country: 'Ethiopia', displayOrder: 10 },
|
||||||
|
DIRE_DAWA: { id: 'y-dd', label: 'Dire Dawa', country: 'Ethiopia', displayOrder: 12 },
|
||||||
|
NAGAD: { id: 'y-nagad', label: 'Nagad', country: 'Djibouti', displayOrder: 19 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CORRIDOR: CorridorContext = {
|
||||||
|
yards: new Map(Object.values(YARD).map((y) => [y.id, y])),
|
||||||
|
originOrder: YARD.GMP.displayOrder,
|
||||||
|
destinationOrder: YARD.NAGAD.displayOrder,
|
||||||
|
direDawaOrder: YARD.DIRE_DAWA.displayOrder,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NO_DEMAND: CrewDemandInput = {
|
||||||
|
hasBadOrderWagon: false,
|
||||||
|
badOrderWagonLabels: [],
|
||||||
|
hasReeferCargo: false,
|
||||||
|
reeferSources: [],
|
||||||
|
hasHazmatCargo: false,
|
||||||
|
hazmatSources: [],
|
||||||
|
hasBreakBulkCargo: false,
|
||||||
|
breakBulkSources: [],
|
||||||
|
hasLivestockCargo: false,
|
||||||
|
livestockSources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
const driver = (
|
||||||
|
nationality: 'ETHIOPIAN' | 'DJIBOUTIAN',
|
||||||
|
from: CorridorYard,
|
||||||
|
to: CorridorYard,
|
||||||
|
dutyRole: CrewDutyRole,
|
||||||
|
): AssignmentFacts => ({
|
||||||
|
crewMemberId: `driver-${++seq}`,
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
dutyRole,
|
||||||
|
fromYardId: from.id,
|
||||||
|
toYardId: to.id,
|
||||||
|
nationality,
|
||||||
|
memberName: `Driver ${seq}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const crewOfRole = (role: TrainCrewRole, count: number): AssignmentFacts[] =>
|
||||||
|
Array.from({ length: count }, () => ({
|
||||||
|
crewMemberId: `member-${++seq}`,
|
||||||
|
role,
|
||||||
|
nationality: 'ETHIOPIAN',
|
||||||
|
memberName: `Member ${seq}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const codes = (result: { issues: Array<{ code: string }> }) =>
|
||||||
|
result.issues.map((i) => i.code);
|
||||||
|
|
||||||
|
describe('crew composition rules (ITLMS Rolling Stock)', () => {
|
||||||
|
const validate = (
|
||||||
|
assignments: AssignmentFacts[],
|
||||||
|
demand: CrewDemandInput = NO_DEMAND,
|
||||||
|
) => validateCrewComposition(assignments, demand, CORRIDOR);
|
||||||
|
|
||||||
|
/** One Ethiopian Primary over the whole route — the minimum viable crew. */
|
||||||
|
const soloPrimary = () =>
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY);
|
||||||
|
|
||||||
|
describe('free-form crew sizing', () => {
|
||||||
|
it('accepts a single driver working the whole corridor', () => {
|
||||||
|
const result = validate([soloPrimary()]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([1, 3, 4, 6, 8])('accepts a crew of %i drivers on one leg', (count) => {
|
||||||
|
const drivers = [
|
||||||
|
soloPrimary(),
|
||||||
|
...Array.from({ length: count - 1 }, () =>
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
expect(validate(drivers).complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts any number of federal police, including none', () => {
|
||||||
|
for (const count of [0, 1, 4, 9]) {
|
||||||
|
const result = validate([
|
||||||
|
soloPrimary(),
|
||||||
|
...crewOfRole(TrainCrewRole.FEDERAL_POLICE, count),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least one driver', () => {
|
||||||
|
const result = validate(crewOfRole(TrainCrewRole.FEDERAL_POLICE, 4));
|
||||||
|
expect(codes(result)).toContain('DRIVER_COUNT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('yard-to-yard legs', () => {
|
||||||
|
it('lets staff hand over at any intermediate yard', () => {
|
||||||
|
// Three legs the old fixed segments could not express: GMP–Feto,
|
||||||
|
// Feto–Meiso, Meiso–Nagad.
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.FETO, YARD.MEISO, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.MEISO, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a leg with the same yard at both ends', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.FETO, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DRIVER_LEG_EMPTY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a yard outside the schedule route', () => {
|
||||||
|
const outside: CorridorYard = {
|
||||||
|
id: 'y-sebeta',
|
||||||
|
label: 'Sebeta',
|
||||||
|
country: 'Ethiopia',
|
||||||
|
displayOrder: 1, // before the GMP origin
|
||||||
|
};
|
||||||
|
const corridor: CorridorContext = {
|
||||||
|
...CORRIDOR,
|
||||||
|
yards: new Map([...(CORRIDOR.yards ?? []), [outside.id, outside]]),
|
||||||
|
};
|
||||||
|
const result = validateCrewComposition(
|
||||||
|
[driver('ETHIOPIAN', outside, YARD.NAGAD, CrewDutyRole.PRIMARY)],
|
||||||
|
NO_DEMAND,
|
||||||
|
corridor,
|
||||||
|
);
|
||||||
|
expect(codes(result)).toContain('LEG_OUTSIDE_ROUTE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a from-yard, to-yard and duty role on every driver', () => {
|
||||||
|
const result = validate([
|
||||||
|
{
|
||||||
|
crewMemberId: 'd1',
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
nationality: 'ETHIOPIAN',
|
||||||
|
memberName: 'Unslotted Driver',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DRIVER_SLOT_INCOMPLETE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.1 territorial boundary', () => {
|
||||||
|
const dd = YARD.DIRE_DAWA.displayOrder;
|
||||||
|
|
||||||
|
it('lets a Djibouti driver work at or beyond Dire Dawa', () => {
|
||||||
|
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'DJIBOUTIAN', dd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bars a Djibouti driver from any leg west of Dire Dawa', () => {
|
||||||
|
expect(legAllowsNationality(YARD.GMP, YARD.DIRE_DAWA, 'DJIBOUTIAN', dd)).toBe(false);
|
||||||
|
expect(legAllowsNationality(YARD.FETO, YARD.MEISO, 'DJIBOUTIAN', dd)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Ethiopian drivers unrestricted', () => {
|
||||||
|
expect(legAllowsNationality(YARD.GMP, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
|
||||||
|
expect(legAllowsNationality(YARD.DIRE_DAWA, YARD.NAGAD, 'ETHIOPIAN', dd)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags a Djibouti driver placed on a western leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('DJIBOUTIAN', YARD.GMP, YARD.FETO, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('TERRITORIAL_BOUNDARY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the documented split: Ethiopians west, Djiboutians east', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
]);
|
||||||
|
expect(result.issues).toEqual([]);
|
||||||
|
expect(result.runType).toBe('LONG_RUN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('one Primary per leg', () => {
|
||||||
|
it('rejects two Primaries on the same leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('DUPLICATE_PRIMARY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a Primary on each of two different legs', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows many Assistants alongside one Primary', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.BENCH_RELIEF),
|
||||||
|
]);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a Primary on every covered leg', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.NAGAD, CrewDutyRole.ASSISTANT),
|
||||||
|
]);
|
||||||
|
expect(codes(result)).toContain('PRIMARY_MISSING');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.1 run type', () => {
|
||||||
|
it('is a long run when the legs span the whole route', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('ETHIOPIAN', YARD.GMP, YARD.DIRE_DAWA, CrewDutyRole.PRIMARY),
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.runType).toBe('LONG_RUN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a short run when the legs cover only part of the route', () => {
|
||||||
|
const result = validate([
|
||||||
|
driver('DJIBOUTIAN', YARD.DIRE_DAWA, YARD.NAGAD, CrewDutyRole.PRIMARY),
|
||||||
|
]);
|
||||||
|
expect(result.runType).toBe('SHORT_RUN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.2 technical maintenance crew', () => {
|
||||||
|
it('requires no technician when no bad-order wagon is attached', () => {
|
||||||
|
expect(technicianRequirement(NO_DEMAND).min).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces one technician when a bad-order wagon is attached', () => {
|
||||||
|
const demand = {
|
||||||
|
...NO_DEMAND,
|
||||||
|
hasBadOrderWagon: true,
|
||||||
|
badOrderWagonLabels: ['WG-1042'],
|
||||||
|
};
|
||||||
|
expect(technicianRequirement(demand).min).toBe(1);
|
||||||
|
|
||||||
|
const result = validate([soloPrimary()], demand);
|
||||||
|
expect(codes(result)).toContain('TECHNICIAN_REQUIRED');
|
||||||
|
// The wagon that forced it is named, so the demand is explicable.
|
||||||
|
expect(result.issues.find((i) => i.code === 'TECHNICIAN_REQUIRED')?.message)
|
||||||
|
.toContain('WG-1042');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§1.2 specialized cargo crew', () => {
|
||||||
|
it('asks for nothing when no specialized cargo is aboard', () => {
|
||||||
|
expect(specializedRequirements(NO_DEMAND)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a reefer technician only when reefer cargo is aboard', () => {
|
||||||
|
const demand = { ...NO_DEMAND, hasReeferCargo: true, reeferSources: ['BK-1'] };
|
||||||
|
const rules = specializedRequirements(demand);
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].role).toBe(TrainCrewRole.REEFER_TECHNICIAN);
|
||||||
|
expect(rules[0].min).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a hazmat run with no escort assigned', () => {
|
||||||
|
const result = validate([soloPrimary()], {
|
||||||
|
...NO_DEMAND,
|
||||||
|
hasHazmatCargo: true,
|
||||||
|
hazmatSources: ['BK-2024-0891'],
|
||||||
|
});
|
||||||
|
expect(codes(result)).toContain('SPECIALIZED_REQUIRED');
|
||||||
|
expect(result.complete).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes once the escort is assigned, at any count', () => {
|
||||||
|
for (const escorts of [1, 2, 5]) {
|
||||||
|
const result = validate(
|
||||||
|
[soloPrimary(), ...crewOfRole(TrainCrewRole.HAZMAT_ESCORT, escorts)],
|
||||||
|
{ ...NO_DEMAND, hasHazmatCargo: true, hazmatSources: ['BK-2024-0891'] },
|
||||||
|
);
|
||||||
|
expect(result.complete).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duplicate seats', () => {
|
||||||
|
it('flags a member assigned twice on one run', () => {
|
||||||
|
const twice = crewOfRole(TrainCrewRole.FEDERAL_POLICE, 1)[0];
|
||||||
|
const result = validate([soloPrimary(), twice, twice]);
|
||||||
|
expect(codes(result)).toContain('DUPLICATE_MEMBER');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('§3.2 overtime hours', () => {
|
||||||
|
it('reproduces the documented worked example', () => {
|
||||||
|
// PDF: 500h worked against a 240h standard => 260h variance,
|
||||||
|
// split 156h at the 1.5x tier and 104h at the 1.75x tier.
|
||||||
|
expect(overtimeHours(500)).toEqual({
|
||||||
|
variance: 260,
|
||||||
|
tier1Hours: 156,
|
||||||
|
tier2Hours: 104,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports no overtime below the monthly standard', () => {
|
||||||
|
expect(overtimeHours(200)).toEqual({
|
||||||
|
variance: 0,
|
||||||
|
tier1Hours: 0,
|
||||||
|
tier2Hours: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits the variance 60/40 as a flat convention', () => {
|
||||||
|
const { tier1Hours, tier2Hours, variance } = overtimeHours(340);
|
||||||
|
expect(variance).toBe(100);
|
||||||
|
expect(tier1Hours).toBe(60);
|
||||||
|
expect(tier2Hours).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { TrainCrewRole } from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
CrewDutyRole,
|
||||||
|
CrewSegment,
|
||||||
|
} from './entities/train-crew-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ITLMS Rolling Stock §1.2 / §2 composition rules.
|
||||||
|
*
|
||||||
|
* One module, used by BOTH the assignment API and the dispatch guard, so the
|
||||||
|
* wizard and the departure gate can never disagree about whether a crew is
|
||||||
|
* complete. Pure functions over plain data — no repository access — so the
|
||||||
|
* caller decides what to load and this stays unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Security-detail size (§1.2 names 4 federal police).
|
||||||
|
*
|
||||||
|
* Operations asked for free-form crewing, so the document's numbers are treated
|
||||||
|
* as the usual shape rather than a hard limit — any count is accepted and the
|
||||||
|
* typical value is surfaced as a hint in the UI.
|
||||||
|
*/
|
||||||
|
export const FEDERAL_POLICE_TYPICAL = 4;
|
||||||
|
|
||||||
|
/** Government monthly working-hour baseline (§3.1). */
|
||||||
|
export const MONTHLY_STANDARD_HOURS = 240;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §3.2 tier split. The document fixes the day/night division as a flat 60/40 of
|
||||||
|
* the variance regardless of when the hours fell, and that is implemented as
|
||||||
|
* written rather than derived from real clock hours.
|
||||||
|
*/
|
||||||
|
export const OT_TIER_1_SHARE = 0.6;
|
||||||
|
export const OT_TIER_2_SHARE = 0.4;
|
||||||
|
export const OT_TIER_1_FACTOR = 1.5;
|
||||||
|
export const OT_TIER_2_FACTOR = 1.75;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driving-crew size (§1.2 "3 or 4 Drivers").
|
||||||
|
*
|
||||||
|
* Operations asked for a free-form crew rather than the two fixed pairing cases
|
||||||
|
* of §2, so the document's 3-or-4 is treated as the usual shape, not a limit:
|
||||||
|
* any count within these bounds is accepted and each driver carries their own
|
||||||
|
* segment and duty role. MIN stays at 1 so a partially built crew still saves.
|
||||||
|
*/
|
||||||
|
export const DRIVER_COUNT_MIN = 1;
|
||||||
|
|
||||||
|
/** Typical driving-crew size per §1.2 — a hint in the UI, never enforced. */
|
||||||
|
export const DRIVER_COUNT_TYPICAL = [3, 4];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A corridor yard as the rules see it.
|
||||||
|
*
|
||||||
|
* `displayOrder` is the yard's place along the corridor (Sebeta 1 … DCT/SGTD
|
||||||
|
* 22), which is what makes "is this leg inside the schedule's span" and "does
|
||||||
|
* this leg cross into Djibouti" answerable without hard-coding station names.
|
||||||
|
*/
|
||||||
|
export interface CorridorYard {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
country: string;
|
||||||
|
displayOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dire Dawa is the handover point §1.1 draws the territorial line at. */
|
||||||
|
export const DIRE_DAWA_CODE = 'DIRE_DAWA';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corridor a schedule runs on, as the rules need to see it: every yard by
|
||||||
|
* id, where the schedule starts and ends, and where Dire Dawa sits. Supplied by
|
||||||
|
* the caller so these functions stay pure and unit-testable.
|
||||||
|
*/
|
||||||
|
export interface CorridorContext {
|
||||||
|
yards?: Map<string, CorridorYard>;
|
||||||
|
originOrder?: number;
|
||||||
|
destinationOrder?: number;
|
||||||
|
direDawaOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §1.1 territorial boundary: Djiboutian drivers work the Dire Dawa – Nagad
|
||||||
|
* corridor segment exclusively.
|
||||||
|
*
|
||||||
|
* Expressed against yards rather than a fixed segment name: a leg is open to a
|
||||||
|
* Djiboutian driver when it stays at or beyond Dire Dawa, so any handover point
|
||||||
|
* east of it works without naming the pair in code. The restriction is
|
||||||
|
* asymmetric on purpose — the document confines Djiboutian drivers but never
|
||||||
|
* bars Ethiopians from that stretch.
|
||||||
|
*/
|
||||||
|
export const legAllowsNationality = (
|
||||||
|
from: CorridorYard | undefined,
|
||||||
|
to: CorridorYard | undefined,
|
||||||
|
nationality: string,
|
||||||
|
direDawaOrder: number,
|
||||||
|
): boolean => {
|
||||||
|
if (nationality !== 'DJIBOUTIAN') return true;
|
||||||
|
if (!from || !to) return true; // Incomplete leg — a separate rule reports it.
|
||||||
|
// Both ends must sit at or beyond Dire Dawa, whichever way the train runs.
|
||||||
|
return Math.min(from.displayOrder, to.displayOrder) >= direDawaOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Specialized-crew rules (§1.2), each keyed to what the train is carrying. */
|
||||||
|
export interface SpecializedRequirement {
|
||||||
|
role: TrainCrewRole;
|
||||||
|
/** Hard floor — 0 unless the cargo or consist forces someone aboard. */
|
||||||
|
min: number;
|
||||||
|
/** The count §1.2 suggests. A hint for the UI; nothing enforces it. */
|
||||||
|
typical: number;
|
||||||
|
/** Why this is required — surfaced verbatim so the demand is explicable. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the consist and its cargo demand, as detected from the schedule. */
|
||||||
|
export interface CrewDemandInput {
|
||||||
|
/** A defective / bad-order wagon is attached (§1.2 forces 1 technician). */
|
||||||
|
hasBadOrderWagon: boolean;
|
||||||
|
badOrderWagonLabels: string[];
|
||||||
|
hasReeferCargo: boolean;
|
||||||
|
reeferSources: string[];
|
||||||
|
hasHazmatCargo: boolean;
|
||||||
|
hazmatSources: string[];
|
||||||
|
hasBreakBulkCargo: boolean;
|
||||||
|
breakBulkSources: string[];
|
||||||
|
hasLivestockCargo: boolean;
|
||||||
|
livestockSources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const listSources = (sources: string[]): string =>
|
||||||
|
sources.length ? ` (${sources.slice(0, 3).join(', ')}${sources.length > 3 ? '…' : ''})` : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn detected cargo/consist facts into the crew the run must carry.
|
||||||
|
* Only triggered rows appear, so staff are never asked about cargo not aboard.
|
||||||
|
*/
|
||||||
|
export const specializedRequirements = (
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
): SpecializedRequirement[] => {
|
||||||
|
const required: SpecializedRequirement[] = [];
|
||||||
|
if (demand.hasReeferCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.REEFER_TECHNICIAN,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Reefer cargo on board${listSources(demand.reeferSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasHazmatCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.HAZMAT_ESCORT,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Dangerous / flammable cargo on board${listSources(demand.hazmatSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasBreakBulkCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.LASHING_INSPECTOR,
|
||||||
|
min: 1,
|
||||||
|
typical: 2,
|
||||||
|
reason: `Break-bulk cargo requiring lashing inspection${listSources(demand.breakBulkSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (demand.hasLivestockCargo) {
|
||||||
|
required.push({
|
||||||
|
role: TrainCrewRole.LIVESTOCK_HANDLER,
|
||||||
|
min: 1,
|
||||||
|
typical: 3,
|
||||||
|
reason: `Livestock shipment on board${listSources(demand.livestockSources)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return required;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Technician floor: 1 is mandatory only when a bad-order wagon is attached. */
|
||||||
|
export const technicianRequirement = (
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
): SpecializedRequirement => ({
|
||||||
|
role: TrainCrewRole.TECHNICIAN,
|
||||||
|
min: demand.hasBadOrderWagon ? 1 : 0,
|
||||||
|
typical: 3,
|
||||||
|
reason: demand.hasBadOrderWagon
|
||||||
|
? `Defective / bad-order wagon attached${listSources(demand.badOrderWagonLabels)}`
|
||||||
|
: 'Optional technical maintenance crew',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** One assignment, reduced to what the rules actually read. */
|
||||||
|
export interface AssignmentFacts {
|
||||||
|
crewMemberId: string;
|
||||||
|
role: TrainCrewRole;
|
||||||
|
dutyRole?: CrewDutyRole | null;
|
||||||
|
/** The leg this driver works, as two corridor yards. */
|
||||||
|
fromYardId?: string | null;
|
||||||
|
toYardId?: string | null;
|
||||||
|
nationality: string;
|
||||||
|
memberName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewValidationIssue {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrewValidationResult {
|
||||||
|
/** True when every mandatory rule passes — the dispatch gate reads this. */
|
||||||
|
complete: boolean;
|
||||||
|
issues: CrewValidationIssue[];
|
||||||
|
/** Derived, never entered: one segment covered = short run, both = long run (§1.1). */
|
||||||
|
runType: 'SHORT_RUN' | 'LONG_RUN' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a schedule's crew against §1.1 and §1.2.
|
||||||
|
*
|
||||||
|
* Returns issues rather than throwing: the wizard renders them as a live
|
||||||
|
* checklist while a partial crew is still being built, and only the dispatch
|
||||||
|
* guard treats a non-empty list as fatal.
|
||||||
|
*/
|
||||||
|
export const validateCrewComposition = (
|
||||||
|
assignments: AssignmentFacts[],
|
||||||
|
demand: CrewDemandInput,
|
||||||
|
corridor: CorridorContext = {},
|
||||||
|
): CrewValidationResult => {
|
||||||
|
const yards = corridor.yards ?? new Map<string, CorridorYard>();
|
||||||
|
const direDawaOrder = corridor.direDawaOrder ?? Number.POSITIVE_INFINITY;
|
||||||
|
const issues: CrewValidationIssue[] = [];
|
||||||
|
|
||||||
|
const drivers = assignments.filter((a) => a.role === TrainCrewRole.TRAIN_DRIVER);
|
||||||
|
|
||||||
|
if (drivers.length < DRIVER_COUNT_MIN) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_COUNT',
|
||||||
|
message: 'At least one driver must be assigned',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every driver needs a leg and a duty role — without them the run has no
|
||||||
|
// record of who drove which part of the corridor.
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (!driver.fromYardId || !driver.toYardId || !driver.dutyRole) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_SLOT_INCOMPLETE',
|
||||||
|
message: `${driver.memberName} needs a from-yard, a to-yard and a duty role`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (driver.fromYardId === driver.toYardId) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DRIVER_LEG_EMPTY',
|
||||||
|
message: `${driver.memberName} has the same yard at both ends of their leg`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// A leg outside the schedule's own span would put a driver on track this
|
||||||
|
// train never runs.
|
||||||
|
if (corridor.originOrder !== undefined && corridor.destinationOrder !== undefined) {
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const from = yards.get(driver.fromYardId);
|
||||||
|
const to = yards.get(driver.toYardId);
|
||||||
|
for (const yard of [from, to]) {
|
||||||
|
if (yard && (yard.displayOrder < low || yard.displayOrder > high)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'LEG_OUTSIDE_ROUTE',
|
||||||
|
message: `${yard.label} is outside this schedule's route — ${driver.memberName}'s leg must stay between the origin and destination`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A leg cannot have two Primaries — someone must be in charge of each stretch
|
||||||
|
// and only one person can be. Assistants and relief drivers are unconstrained.
|
||||||
|
const legKey = (d: AssignmentFacts) => `${d.fromYardId}>${d.toYardId}`;
|
||||||
|
const legLabel = (d: AssignmentFacts) => {
|
||||||
|
const from = d.fromYardId ? yards.get(d.fromYardId)?.label : undefined;
|
||||||
|
const to = d.toYardId ? yards.get(d.toYardId)?.label : undefined;
|
||||||
|
return from && to ? `${from} – ${to}` : 'this leg';
|
||||||
|
};
|
||||||
|
|
||||||
|
const primariesByLeg = new Map<string, { names: string[]; label: string }>();
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (driver.dutyRole === CrewDutyRole.PRIMARY && driver.fromYardId && driver.toYardId) {
|
||||||
|
const key = legKey(driver);
|
||||||
|
const entry = primariesByLeg.get(key) ?? { names: [], label: legLabel(driver) };
|
||||||
|
entry.names.push(driver.memberName);
|
||||||
|
primariesByLeg.set(key, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [, entry] of primariesByLeg) {
|
||||||
|
if (entry.names.length > 1) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DUPLICATE_PRIMARY',
|
||||||
|
message: `${entry.label} has more than one Primary Driver (${entry.names.join(', ')})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each covered leg needs a Primary — an Assistant alone cannot run it.
|
||||||
|
const coveredLegs = new Map<string, string>();
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (driver.fromYardId && driver.toYardId) {
|
||||||
|
coveredLegs.set(legKey(driver), legLabel(driver));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, label] of coveredLegs) {
|
||||||
|
if (!primariesByLeg.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'PRIMARY_MISSING',
|
||||||
|
message: `${label} has no Primary Driver assigned`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.1 territorial boundary — Djibouti drivers stay at or beyond Dire Dawa.
|
||||||
|
for (const driver of drivers) {
|
||||||
|
const from = driver.fromYardId ? yards.get(driver.fromYardId) : undefined;
|
||||||
|
const to = driver.toYardId ? yards.get(driver.toYardId) : undefined;
|
||||||
|
if (!legAllowsNationality(from, to, driver.nationality, direDawaOrder)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'TERRITORIAL_BOUNDARY',
|
||||||
|
message: `${driver.memberName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.2 names 4 federal police, 1-3 technicians and so on. Those counts are
|
||||||
|
// no longer enforced: operations crew each run to its own need, so any number
|
||||||
|
// of any role is accepted. What still holds is what makes a run coherent —
|
||||||
|
// a driver with a segment and duty role, one Primary per segment, and the
|
||||||
|
// specialized crew the cargo actually demands.
|
||||||
|
|
||||||
|
// §1.2 technical maintenance crew: a bad-order wagon still forces at least
|
||||||
|
// one technician — that rule is about safety, not crew sizing, so it stays.
|
||||||
|
const technicianRule = technicianRequirement(demand);
|
||||||
|
const technicians = assignments.filter((a) => a.role === TrainCrewRole.TECHNICIAN).length;
|
||||||
|
if (technicians < technicianRule.min) {
|
||||||
|
issues.push({
|
||||||
|
code: 'TECHNICIAN_REQUIRED',
|
||||||
|
message: `At least ${technicianRule.min} technician required — ${technicianRule.reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// §1.2 specialized cargo crew: the floor stays (hazmat aboard means an escort
|
||||||
|
// rides along) but the upper bound is gone — how many is operations' call.
|
||||||
|
for (const rule of specializedRequirements(demand)) {
|
||||||
|
const count = assignments.filter((a) => a.role === rule.role).length;
|
||||||
|
if (count < rule.min) {
|
||||||
|
issues.push({
|
||||||
|
code: 'SPECIALIZED_REQUIRED',
|
||||||
|
message: `At least ${rule.min} ${labelRole(rule.role)} required — ${rule.reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nobody may hold two seats on the same run.
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const a of assignments) {
|
||||||
|
if (seen.has(a.crewMemberId)) {
|
||||||
|
issues.push({
|
||||||
|
code: 'DUPLICATE_MEMBER',
|
||||||
|
message: `${a.memberName} is assigned more than once on this run`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seen.add(a.crewMemberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
complete: issues.length === 0,
|
||||||
|
issues,
|
||||||
|
runType: deriveRunType(drivers, corridor, yards),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §1.1 run type. A crew whose legs together span the schedule's whole route is
|
||||||
|
* a long run; anything shorter is a short run.
|
||||||
|
*/
|
||||||
|
const deriveRunType = (
|
||||||
|
drivers: AssignmentFacts[],
|
||||||
|
corridor: CorridorContext,
|
||||||
|
yards: Map<string, CorridorYard>,
|
||||||
|
): 'SHORT_RUN' | 'LONG_RUN' | null => {
|
||||||
|
const orders = drivers
|
||||||
|
.flatMap((d) => [d.fromYardId, d.toYardId])
|
||||||
|
.map((id) => (id ? yards.get(id)?.displayOrder : undefined))
|
||||||
|
.filter((o): o is number => o !== undefined);
|
||||||
|
if (!orders.length) return null;
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return 'SHORT_RUN';
|
||||||
|
}
|
||||||
|
const routeLow = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const routeHigh = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const covered = Math.min(...orders) <= routeLow && Math.max(...orders) >= routeHigh;
|
||||||
|
return covered ? 'LONG_RUN' : 'SHORT_RUN';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const labelSegment = (segment: CrewSegment): string =>
|
||||||
|
({
|
||||||
|
[CrewSegment.INDODE_DIRE_DAWA]: 'Indode/GMP – Dire Dawa',
|
||||||
|
[CrewSegment.DIRE_DAWA_NAGAD]: 'Dire Dawa – Nagad',
|
||||||
|
[CrewSegment.FULL_CORRIDOR]: 'Full corridor',
|
||||||
|
})[segment];
|
||||||
|
|
||||||
|
export const labelDutyRole = (dutyRole: CrewDutyRole): string =>
|
||||||
|
({
|
||||||
|
[CrewDutyRole.PRIMARY]: 'Primary Driver',
|
||||||
|
[CrewDutyRole.ASSISTANT]: 'Assistant Driver',
|
||||||
|
[CrewDutyRole.BENCH_RELIEF]: 'Bench/Relief Driver',
|
||||||
|
})[dutyRole];
|
||||||
|
|
||||||
|
export const labelRole = (role: TrainCrewRole): string =>
|
||||||
|
({
|
||||||
|
[TrainCrewRole.TRAIN_DRIVER]: 'train driver',
|
||||||
|
[TrainCrewRole.FEDERAL_POLICE]: 'federal police',
|
||||||
|
[TrainCrewRole.TECHNICIAN]: 'technician',
|
||||||
|
[TrainCrewRole.REEFER_TECHNICIAN]: 'reefer technician',
|
||||||
|
[TrainCrewRole.HAZMAT_ESCORT]: 'HAZMAT escort',
|
||||||
|
[TrainCrewRole.LASHING_INSPECTOR]: 'lashing inspector',
|
||||||
|
[TrainCrewRole.LIVESTOCK_HANDLER]: 'livestock handler',
|
||||||
|
})[role];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* §3.2 overtime hours for one driver's month.
|
||||||
|
*
|
||||||
|
* Hours only, by design: no salary is stored anywhere in the platform, so the
|
||||||
|
* output stops at the two tier totals and finance applies the rates.
|
||||||
|
*/
|
||||||
|
export const overtimeHours = (
|
||||||
|
workedHours: number,
|
||||||
|
standardHours: number = MONTHLY_STANDARD_HOURS,
|
||||||
|
): { variance: number; tier1Hours: number; tier2Hours: number } => {
|
||||||
|
const variance = Math.max(0, workedHours - standardHours);
|
||||||
|
return {
|
||||||
|
variance,
|
||||||
|
tier1Hours: variance * OT_TIER_1_SHARE,
|
||||||
|
tier2Hours: variance * OT_TIER_2_SHARE,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import {
|
||||||
|
TrainCrewNationality,
|
||||||
|
TrainCrewRole,
|
||||||
|
TrainCrewStatus,
|
||||||
|
} from '../entities/train-crew-member.entity';
|
||||||
|
|
||||||
|
export class CreateTrainCrewMemberDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsEnum(TrainCrewRole)
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
@IsEnum(TrainCrewNationality)
|
||||||
|
nationality!: TrainCrewNationality;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TrainCrewStatus)
|
||||||
|
status?: TrainCrewStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Transform, Type } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsEnum, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
import {
|
||||||
|
TrainCrewNationality,
|
||||||
|
TrainCrewRole,
|
||||||
|
TrainCrewStatus,
|
||||||
|
} from '../entities/train-crew-member.entity';
|
||||||
|
|
||||||
|
/** Sortable columns. Whitelisted: the value is interpolated into ORDER BY. */
|
||||||
|
export const TRAIN_CREW_SORT_FIELDS = [
|
||||||
|
'firstName',
|
||||||
|
'lastName',
|
||||||
|
'role',
|
||||||
|
'nationality',
|
||||||
|
'status',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export class QueryTrainCrewMemberDto {
|
||||||
|
/** Matched against first and last name. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TrainCrewRole)
|
||||||
|
role?: TrainCrewRole;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TrainCrewNationality)
|
||||||
|
nationality?: TrainCrewNationality;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TrainCrewStatus)
|
||||||
|
status?: TrainCrewStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
limit?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TRAIN_CREW_SORT_FIELDS as unknown as string[])
|
||||||
|
sortBy?: (typeof TRAIN_CREW_SORT_FIELDS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CrewDutyRole } from '../entities/train-crew-assignment.entity';
|
||||||
|
import { TrainCrewRole } from '../entities/train-crew-member.entity';
|
||||||
|
|
||||||
|
export class CrewAssignmentRowDto {
|
||||||
|
@IsUUID()
|
||||||
|
crewMemberId!: string;
|
||||||
|
|
||||||
|
@IsEnum(TrainCrewRole)
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
/** Required for drivers, rejected as incomplete without it. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CrewDutyRole)
|
||||||
|
dutyRole?: CrewDutyRole;
|
||||||
|
|
||||||
|
/** The leg this driver works — any two yards on the schedule's route. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
fromYardId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
toYardId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SaveCrewAssignmentsDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => CrewAssignmentRowDto)
|
||||||
|
assignments!: CrewAssignmentRowDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateTrainCrewMemberDto } from './create-train-crew-member.dto';
|
||||||
|
|
||||||
|
export class UpdateTrainCrewMemberDto extends PartialType(CreateTrainCrewMemberDto) {}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { TrainCrewMember, TrainCrewRole } from './train-crew-member.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy fixed corridor segments.
|
||||||
|
*
|
||||||
|
* Kept only so historic rows written before segments became yard-to-yard still
|
||||||
|
* read back. New assignments carry `fromYardId`/`toYardId` instead: staff pick
|
||||||
|
* any two yards on the corridor, so a leg is no longer limited to the three
|
||||||
|
* spans the original design hard-coded.
|
||||||
|
*/
|
||||||
|
export enum CrewSegment {
|
||||||
|
INDODE_DIRE_DAWA = 'INDODE_DIRE_DAWA',
|
||||||
|
DIRE_DAWA_NAGAD = 'DIRE_DAWA_NAGAD',
|
||||||
|
FULL_CORRIDOR = 'FULL_CORRIDOR',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Driver duty role for one run (§2). Null for non-driving crew. */
|
||||||
|
export enum CrewDutyRole {
|
||||||
|
PRIMARY = 'PRIMARY',
|
||||||
|
ASSISTANT = 'ASSISTANT',
|
||||||
|
BENCH_RELIEF = 'BENCH_RELIEF',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CrewAssignmentStatus {
|
||||||
|
PLANNED = 'PLANNED',
|
||||||
|
CONFIRMED = 'CONFIRMED',
|
||||||
|
COMPLETED = 'COMPLETED',
|
||||||
|
REMOVED = 'REMOVED',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One roster member assigned to one train schedule.
|
||||||
|
*
|
||||||
|
* `role` is snapshotted from the roster at assignment time: a member who later
|
||||||
|
* changes role must not silently rewrite the crew of a run that already
|
||||||
|
* departed. `dutyRole` and `segment` live here rather than on the roster
|
||||||
|
* because they are properties of THIS run — a driver who is Primary on one
|
||||||
|
* trip is Assistant on the next. Crew sizes are free-form: operations size each
|
||||||
|
* run to its own need rather than to a fixed pairing case.
|
||||||
|
*
|
||||||
|
* Duty stamps feed the §3 monthly overtime totals. Per the agreed scope the
|
||||||
|
* platform reports OT hours only; no salary is stored anywhere, and the payroll
|
||||||
|
* conversion stays with finance.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'train_crew_assignments' })
|
||||||
|
@Index(['trainScheduleId'])
|
||||||
|
@Index(['crewMemberId'])
|
||||||
|
@Index(['status'])
|
||||||
|
export class TrainCrewAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||||
|
trainScheduleId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'crew_member_id', type: 'uuid' })
|
||||||
|
crewMemberId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => TrainCrewMember, { onDelete: 'RESTRICT' })
|
||||||
|
@JoinColumn({ name: 'crew_member_id' })
|
||||||
|
crewMember?: TrainCrewMember;
|
||||||
|
|
||||||
|
@Column({ name: 'role', type: 'varchar', length: 32 })
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
@Column({ name: 'duty_role', type: 'varchar', length: 16, nullable: true })
|
||||||
|
dutyRole?: CrewDutyRole | null;
|
||||||
|
|
||||||
|
/** Legacy fixed segment — null on every assignment written since yard legs. */
|
||||||
|
@Column({ name: 'segment', type: 'varchar', length: 24, nullable: true })
|
||||||
|
segment?: CrewSegment | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leg this driver works, as two yards on the corridor.
|
||||||
|
*
|
||||||
|
* Free-form on purpose: operations pick any yard as a handover point, so a
|
||||||
|
* crew change at Meiso or Feto is expressible without a code change. The
|
||||||
|
* schedule's own origin and destination bound what staff may choose.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
|
||||||
|
fromYardId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'to_yard_id', type: 'uuid', nullable: true })
|
||||||
|
toYardId?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mandatory off-duty layover at Dire Dawa (§1.3). The document gives ~5 hours
|
||||||
|
* as a typical duration, not a rule, so nothing here enforces a length — the
|
||||||
|
* stamps are recorded and reported.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'layover_start_at', type: 'timestamptz', nullable: true })
|
||||||
|
layoverStartAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'layover_end_at', type: 'timestamptz', nullable: true })
|
||||||
|
layoverEndAt?: Date | null;
|
||||||
|
|
||||||
|
/** Worked span for this run — accumulated monthly for the §3 OT calculation. */
|
||||||
|
@Column({ name: 'duty_start_at', type: 'timestamptz', nullable: true })
|
||||||
|
dutyStartAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'duty_end_at', type: 'timestamptz', nullable: true })
|
||||||
|
dutyEndAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 16,
|
||||||
|
default: CrewAssignmentStatus.PLANNED,
|
||||||
|
})
|
||||||
|
status!: CrewAssignmentStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Column, Entity, Index } from 'typeorm';
|
||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On-board role a crew member is rostered for. Mirrors the crew composition
|
||||||
|
* rules in ITLMS Rolling Stock §1.2: driving crew, the federal police security
|
||||||
|
* detail, technical maintenance, and the four specialized cargo roles.
|
||||||
|
*/
|
||||||
|
export enum TrainCrewRole {
|
||||||
|
TRAIN_DRIVER = 'TRAIN_DRIVER',
|
||||||
|
FEDERAL_POLICE = 'FEDERAL_POLICE',
|
||||||
|
TECHNICIAN = 'TECHNICIAN',
|
||||||
|
REEFER_TECHNICIAN = 'REEFER_TECHNICIAN',
|
||||||
|
HAZMAT_ESCORT = 'HAZMAT_ESCORT',
|
||||||
|
LASHING_INSPECTOR = 'LASHING_INSPECTOR',
|
||||||
|
LIVESTOCK_HANDLER = 'LIVESTOCK_HANDLER',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Employing country. Drives the territorial boundary in §1.1 — Djibouti train
|
||||||
|
* drivers operate only on the Dire Dawa – Nagad segment — and the crewing
|
||||||
|
* cases in §2 (Case 1 pairs 2 Ethiopian with 2 Djiboutian drivers).
|
||||||
|
*/
|
||||||
|
export enum TrainCrewNationality {
|
||||||
|
ETHIOPIAN = 'ETHIOPIAN',
|
||||||
|
DJIBOUTIAN = 'DJIBOUTIAN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TrainCrewStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
INACTIVE = 'INACTIVE',
|
||||||
|
SUSPENDED = 'SUSPENDED',
|
||||||
|
ON_LEAVE = 'ON_LEAVE',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roster of people assignable to a train. Distinct from `freight.drivers`,
|
||||||
|
* which is the road/last-mile truck driver register (licences, vehicle types,
|
||||||
|
* trip counts) — a train driver shares none of those fields.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'train_crew_members' })
|
||||||
|
@Index(['role'])
|
||||||
|
@Index(['nationality'])
|
||||||
|
@Index(['status'])
|
||||||
|
@Index(['isActive'])
|
||||||
|
export class TrainCrewMember extends BaseEntity {
|
||||||
|
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'role', type: 'varchar', length: 32 })
|
||||||
|
role!: TrainCrewRole;
|
||||||
|
|
||||||
|
@Column({ name: 'nationality', type: 'varchar', length: 16 })
|
||||||
|
nationality!: TrainCrewNationality;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: 'status',
|
||||||
|
type: 'varchar',
|
||||||
|
length: 16,
|
||||||
|
default: TrainCrewStatus.ACTIVE,
|
||||||
|
})
|
||||||
|
status!: TrainCrewStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
|
||||||
|
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
|
||||||
|
|
||||||
|
@ApiTags('train-crew-assignments')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('train-schedules/:scheduleId/crew')
|
||||||
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([FREIGHT_PERMS.trainCrew.view, FREIGHT_PERMS.trainCrew.assign])
|
||||||
|
export class TrainCrewAssignmentController {
|
||||||
|
constructor(private readonly service: TrainCrewAssignmentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "A schedule's crew, the cargo-driven requirements, and rule validation",
|
||||||
|
})
|
||||||
|
getCrew(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||||
|
return this.service.getScheduleCrew(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('eligible-drivers')
|
||||||
|
@ApiOperation({ summary: 'Roster drivers eligible for a leg between two yards' })
|
||||||
|
eligibleDrivers(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Query('fromYardId') fromYardId?: string,
|
||||||
|
@Query('toYardId') toYardId?: string,
|
||||||
|
) {
|
||||||
|
return this.service.eligibleDrivers(scheduleId, fromYardId, toYardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('corridor-yards')
|
||||||
|
@ApiOperation({ summary: "Yards a driver leg may use on this schedule's route" })
|
||||||
|
corridorYards(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||||
|
return this.service.corridorYards(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.trainCrew.assign)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Replace a schedule's crew (an incomplete crew saves; dispatch is what blocks)",
|
||||||
|
})
|
||||||
|
save(
|
||||||
|
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||||
|
@Body() dto: SaveCrewAssignmentsDto,
|
||||||
|
) {
|
||||||
|
return this.service.saveAssignments(scheduleId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CrewAssignmentStatus,
|
||||||
|
TrainCrewAssignment,
|
||||||
|
} from './entities/train-crew-assignment.entity';
|
||||||
|
import {
|
||||||
|
TrainCrewMember,
|
||||||
|
TrainCrewRole,
|
||||||
|
TrainCrewStatus,
|
||||||
|
} from './entities/train-crew-member.entity';
|
||||||
|
import {
|
||||||
|
AssignmentFacts,
|
||||||
|
CorridorContext,
|
||||||
|
CorridorYard,
|
||||||
|
CrewDemandInput,
|
||||||
|
CrewValidationResult,
|
||||||
|
DIRE_DAWA_CODE,
|
||||||
|
labelRole,
|
||||||
|
legAllowsNationality,
|
||||||
|
specializedRequirements,
|
||||||
|
technicianRequirement,
|
||||||
|
validateCrewComposition,
|
||||||
|
} from './crew-composition.rules';
|
||||||
|
import { SaveCrewAssignmentsDto } from './dto/save-crew-assignments.dto';
|
||||||
|
|
||||||
|
/** Wagon statuses that mean "defective / bad order" for §1.2. */
|
||||||
|
const BAD_ORDER_WAGON_STATUSES = ['MAINTENANCE', 'DETAINED', 'OUT_OF_SERVICE'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cargo-type name fragments that mark a livestock shipment. Matched on the
|
||||||
|
* cargo type's name because no boolean flag for livestock exists yet — unlike
|
||||||
|
* reefer and hazardous, which bookings carry explicitly.
|
||||||
|
*/
|
||||||
|
const LIVESTOCK_NAME_HINTS = ['livestock', 'cattle', 'animal', 'poultry'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainCrewAssignmentService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainCrewAssignment)
|
||||||
|
private readonly assignmentRepo: Repository<TrainCrewAssignment>,
|
||||||
|
@InjectRepository(TrainCrewMember)
|
||||||
|
private readonly memberRepo: Repository<TrainCrewMember>,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Every assignment on a schedule, with the roster member joined. */
|
||||||
|
async listForSchedule(scheduleId: string): Promise<TrainCrewAssignment[]> {
|
||||||
|
return this.assignmentRepo.find({
|
||||||
|
where: {
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
status: In([
|
||||||
|
CrewAssignmentStatus.PLANNED,
|
||||||
|
CrewAssignmentStatus.CONFIRMED,
|
||||||
|
CrewAssignmentStatus.COMPLETED,
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
relations: { crewMember: true },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this schedule's consist and cargo demand (§1.2).
|
||||||
|
*
|
||||||
|
* Read straight from the train set and its allocations rather than asked of
|
||||||
|
* the user: the wagons and bookings already say whether a bad-order wagon is
|
||||||
|
* attached and whether reefer, hazardous, break-bulk or livestock cargo is
|
||||||
|
* aboard, so the requirement is derived and every row can name its trigger.
|
||||||
|
*/
|
||||||
|
async detectDemand(scheduleId: string): Promise<CrewDemandInput> {
|
||||||
|
const badOrder: Array<{ label: string }> = await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(w.wagon_number, tsw.id::text) AS label
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
|
||||||
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||||
|
WHERE ts.id = $1
|
||||||
|
AND w.status = ANY($2)
|
||||||
|
`,
|
||||||
|
[scheduleId, BAD_ORDER_WAGON_STATUSES],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cargo: Array<{
|
||||||
|
reference: string | null;
|
||||||
|
is_reefer: boolean;
|
||||||
|
is_hazardous: boolean;
|
||||||
|
load_type: string | null;
|
||||||
|
cargo_type_name: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`
|
||||||
|
SELECT DISTINCT
|
||||||
|
b.reference,
|
||||||
|
b.is_reefer,
|
||||||
|
b.is_hazardous,
|
||||||
|
wba.load_type,
|
||||||
|
ct.cargo_type_name
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||||
|
JOIN freight.train_set_wagons tsw ON tsw.train_set_id = tset.id
|
||||||
|
JOIN freight.wagon_booking_allocations wba ON wba.train_set_wagon_id = tsw.id
|
||||||
|
JOIN freight.bookings b ON b.id = wba.booking_id
|
||||||
|
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||||
|
WHERE ts.id = $1
|
||||||
|
`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = (row: { reference: string | null }) => row.reference ?? 'a booking';
|
||||||
|
const isLivestock = (name: string | null) =>
|
||||||
|
Boolean(name) &&
|
||||||
|
LIVESTOCK_NAME_HINTS.some((hint) => name!.toLowerCase().includes(hint));
|
||||||
|
|
||||||
|
const reefer = cargo.filter((c) => c.is_reefer);
|
||||||
|
const hazmat = cargo.filter((c) => c.is_hazardous);
|
||||||
|
// Break-bulk rides as a bulk allocation rather than a container.
|
||||||
|
const breakBulk = cargo.filter((c) => c.load_type === 'BULK');
|
||||||
|
const livestock = cargo.filter((c) => isLivestock(c.cargo_type_name));
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasBadOrderWagon: badOrder.length > 0,
|
||||||
|
badOrderWagonLabels: badOrder.map((w) => w.label),
|
||||||
|
hasReeferCargo: reefer.length > 0,
|
||||||
|
reeferSources: reefer.map(label),
|
||||||
|
hasHazmatCargo: hazmat.length > 0,
|
||||||
|
hazmatSources: hazmat.map(label),
|
||||||
|
hasBreakBulkCargo: breakBulk.length > 0,
|
||||||
|
breakBulkSources: breakBulk.map(label),
|
||||||
|
hasLivestockCargo: livestock.length > 0,
|
||||||
|
livestockSources: livestock.map(label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The corridor this schedule runs on: every active yard by id, plus where the
|
||||||
|
* schedule starts, ends, and where Dire Dawa sits. `display_order` is the
|
||||||
|
* yard's place along the line, which is what lets the rules answer "is this
|
||||||
|
* leg inside the route" and "does it cross the territorial boundary" without
|
||||||
|
* hard-coding station names.
|
||||||
|
*/
|
||||||
|
async loadCorridor(scheduleId: string): Promise<CorridorContext> {
|
||||||
|
const rows: Array<{
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
country: string;
|
||||||
|
display_order: number;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT id, code, label, country, display_order
|
||||||
|
FROM freight.yards
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY display_order ASC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const yards = new Map<string, CorridorYard>(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.id,
|
||||||
|
{
|
||||||
|
id: r.id,
|
||||||
|
label: r.label,
|
||||||
|
country: r.country,
|
||||||
|
displayOrder: Number(r.display_order),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [schedule]: Array<{
|
||||||
|
origin_station_id: string | null;
|
||||||
|
destination_station_id: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT origin_station_id, destination_station_id
|
||||||
|
FROM freight.train_schedules WHERE id = $1`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const orderOf = (id: string | null | undefined) =>
|
||||||
|
id ? yards.get(id)?.displayOrder : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
yards,
|
||||||
|
originOrder: orderOf(schedule?.origin_station_id),
|
||||||
|
destinationOrder: orderOf(schedule?.destination_station_id),
|
||||||
|
direDawaOrder: rows.find((r) => r.code === DIRE_DAWA_CODE)
|
||||||
|
? Number(rows.find((r) => r.code === DIRE_DAWA_CODE)!.display_order)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yards a driver leg may use — every yard between origin and destination. */
|
||||||
|
async corridorYards(scheduleId: string): Promise<CorridorYard[]> {
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const all = [...(corridor.yards?.values() ?? [])].sort(
|
||||||
|
(a, b) => a.displayOrder - b.displayOrder,
|
||||||
|
);
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
return all.filter((y) => y.displayOrder >= low && y.displayOrder <= high);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full picture for one schedule: who is assigned, what the cargo demands, and
|
||||||
|
* which composition rules currently fail. The wizard renders this directly.
|
||||||
|
*/
|
||||||
|
async getScheduleCrew(scheduleId: string) {
|
||||||
|
const [assignments, demand, corridor] = await Promise.all([
|
||||||
|
this.listForSchedule(scheduleId),
|
||||||
|
this.detectDemand(scheduleId),
|
||||||
|
this.loadCorridor(scheduleId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const validation = validateCrewComposition(
|
||||||
|
assignments.map(toFacts),
|
||||||
|
demand,
|
||||||
|
corridor,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scheduleId,
|
||||||
|
assignments,
|
||||||
|
corridorYards: [...(corridor.yards?.values() ?? [])]
|
||||||
|
.filter((y) => {
|
||||||
|
if (corridor.originOrder === undefined || corridor.destinationOrder === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const low = Math.min(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
const high = Math.max(corridor.originOrder, corridor.destinationOrder);
|
||||||
|
return y.displayOrder >= low && y.displayOrder <= high;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.displayOrder - b.displayOrder),
|
||||||
|
demand,
|
||||||
|
requirements: {
|
||||||
|
technician: technicianRequirement(demand),
|
||||||
|
specialized: specializedRequirements(demand),
|
||||||
|
},
|
||||||
|
validation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace a schedule's crew in one transaction.
|
||||||
|
*
|
||||||
|
* A whole-set replace rather than per-row edits: the wizard submits the
|
||||||
|
* finished crew, and composition rules are only meaningful over the complete
|
||||||
|
* set. Saving an INCOMPLETE crew is allowed on purpose — ops build a roster
|
||||||
|
* over days, and §1.2 places the hard gate at departure, not at save time.
|
||||||
|
* Only structural errors (unknown member, wrong role, territorial breach)
|
||||||
|
* reject here; the rest surface as issues and block dispatch.
|
||||||
|
*/
|
||||||
|
async saveAssignments(
|
||||||
|
scheduleId: string,
|
||||||
|
dto: SaveCrewAssignmentsDto,
|
||||||
|
): Promise<CrewValidationResult> {
|
||||||
|
const rows = dto.assignments ?? [];
|
||||||
|
const memberIds = rows.map((r) => r.crewMemberId);
|
||||||
|
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const members = memberIds.length
|
||||||
|
? await this.memberRepo.find({ where: { id: In(memberIds) } })
|
||||||
|
: [];
|
||||||
|
const byId = new Map(members.map((m) => [m.id, m]));
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const member = byId.get(row.crewMemberId);
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException(`Crew member ${row.crewMemberId} not found`);
|
||||||
|
}
|
||||||
|
if (member.status !== TrainCrewStatus.ACTIVE || !member.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is ${member.status} and cannot be assigned`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.role !== member.role) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is a ${labelRole(member.role)}, not a ${labelRole(row.role)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (member.role === TrainCrewRole.TRAIN_DRIVER) {
|
||||||
|
if (!row.fromYardId || !row.toYardId || !row.dutyRole) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Driver ${member.firstName} ${member.lastName} needs a from-yard, a to-yard and a duty role`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// §1.1 territorial boundary is structural — never persist a breach.
|
||||||
|
const from = corridor.yards?.get(row.fromYardId);
|
||||||
|
const to = corridor.yards?.get(row.toYardId);
|
||||||
|
if (
|
||||||
|
!legAllowsNationality(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
member.nationality,
|
||||||
|
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`${member.firstName} ${member.lastName} is a Djibouti driver and may only work legs from Dire Dawa eastward`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(TrainCrewAssignment);
|
||||||
|
await repo.delete({ trainScheduleId: scheduleId });
|
||||||
|
if (rows.length) {
|
||||||
|
await repo.insert(
|
||||||
|
rows.map((row) => ({
|
||||||
|
trainScheduleId: scheduleId,
|
||||||
|
crewMemberId: row.crewMemberId,
|
||||||
|
role: row.role,
|
||||||
|
dutyRole: row.dutyRole ?? null,
|
||||||
|
fromYardId: row.fromYardId ?? null,
|
||||||
|
toYardId: row.toYardId ?? null,
|
||||||
|
status: CrewAssignmentStatus.PLANNED,
|
||||||
|
notes: row.notes ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const demand = await this.detectDemand(scheduleId);
|
||||||
|
const saved = await this.listForSchedule(scheduleId);
|
||||||
|
return validateCrewComposition(saved.map(toFacts), demand, corridor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch gate (§1.2 "prior to departure"). Throws with every unmet rule
|
||||||
|
* listed, so staff see the whole gap at once rather than one error per retry.
|
||||||
|
*/
|
||||||
|
async assertCrewReadyForDispatch(scheduleId: string): Promise<void> {
|
||||||
|
const { validation } = await this.getScheduleCrew(scheduleId);
|
||||||
|
if (!validation.complete) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Train crew is incomplete: ${validation.issues.map((i) => i.message).join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Roster drivers eligible for a leg between two yards (§1.1). */
|
||||||
|
async eligibleDrivers(
|
||||||
|
scheduleId: string,
|
||||||
|
fromYardId?: string,
|
||||||
|
toYardId?: string,
|
||||||
|
): Promise<TrainCrewMember[]> {
|
||||||
|
const drivers = await this.memberRepo.find({
|
||||||
|
where: {
|
||||||
|
role: TrainCrewRole.TRAIN_DRIVER,
|
||||||
|
status: TrainCrewStatus.ACTIVE,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
order: { firstName: 'ASC' },
|
||||||
|
});
|
||||||
|
if (!fromYardId || !toYardId) return drivers;
|
||||||
|
|
||||||
|
const corridor = await this.loadCorridor(scheduleId);
|
||||||
|
const from = corridor.yards?.get(fromYardId);
|
||||||
|
const to = corridor.yards?.get(toYardId);
|
||||||
|
return drivers.filter((d) =>
|
||||||
|
legAllowsNationality(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
d.nationality,
|
||||||
|
corridor.direDawaOrder ?? Number.POSITIVE_INFINITY,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reduce a persisted assignment to the facts the rules read. */
|
||||||
|
const toFacts = (a: TrainCrewAssignment): AssignmentFacts => ({
|
||||||
|
crewMemberId: a.crewMemberId,
|
||||||
|
role: a.role,
|
||||||
|
dutyRole: a.dutyRole,
|
||||||
|
fromYardId: a.fromYardId,
|
||||||
|
toYardId: a.toYardId,
|
||||||
|
nationality: a.crewMember?.nationality ?? '',
|
||||||
|
memberName: a.crewMember
|
||||||
|
? `${a.crewMember.firstName} ${a.crewMember.lastName}`
|
||||||
|
: 'A crew member',
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
|
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
|
||||||
|
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
|
||||||
|
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
|
||||||
|
import { TrainCrewService } from './train-crew.service';
|
||||||
|
|
||||||
|
@ApiTags('train-crew')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('train-crew')
|
||||||
|
// Class gate lists every key its routes use: Nest runs class AND method
|
||||||
|
// guards, so a key missing here would deny before the route's own key runs.
|
||||||
|
@BookingStaff([
|
||||||
|
FREIGHT_PERMS.trainCrew.view,
|
||||||
|
FREIGHT_PERMS.trainCrew.create,
|
||||||
|
FREIGHT_PERMS.trainCrew.update,
|
||||||
|
FREIGHT_PERMS.trainCrew.delete,
|
||||||
|
])
|
||||||
|
export class TrainCrewController {
|
||||||
|
constructor(private readonly trainCrewService: TrainCrewService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@BookingStaff(FREIGHT_PERMS.trainCrew.create)
|
||||||
|
@ApiOperation({ summary: 'Create a train crew member' })
|
||||||
|
create(@Body() dto: CreateTrainCrewMemberDto) {
|
||||||
|
return this.trainCrewService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List train crew members with filters' })
|
||||||
|
findAll(@Query() query: QueryTrainCrewMemberDto) {
|
||||||
|
return this.trainCrewService.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a train crew member by id' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainCrewService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.trainCrew.update)
|
||||||
|
@ApiOperation({ summary: 'Update a train crew member' })
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateTrainCrewMemberDto,
|
||||||
|
) {
|
||||||
|
return this.trainCrewService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.trainCrew.delete)
|
||||||
|
@ApiOperation({ summary: 'Delete a train crew member' })
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.trainCrewService.remove(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
import { TrainCrewAssignment } from './entities/train-crew-assignment.entity';
|
||||||
|
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
||||||
|
import { TrainCrewAssignmentController } from './train-crew-assignment.controller';
|
||||||
|
import { TrainCrewAssignmentService } from './train-crew-assignment.service';
|
||||||
|
import { TrainCrewController } from './train-crew.controller';
|
||||||
|
import { TrainCrewService } from './train-crew.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([TrainCrewMember, TrainCrewAssignment])],
|
||||||
|
providers: [TrainCrewService, TrainCrewAssignmentService],
|
||||||
|
controllers: [TrainCrewController, TrainCrewAssignmentController],
|
||||||
|
exports: [TrainCrewService, TrainCrewAssignmentService],
|
||||||
|
})
|
||||||
|
export class TrainCrewModule {}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { ILike, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { CreateTrainCrewMemberDto } from './dto/create-train-crew-member.dto';
|
||||||
|
import { QueryTrainCrewMemberDto } from './dto/query-train-crew-member.dto';
|
||||||
|
import { UpdateTrainCrewMemberDto } from './dto/update-train-crew-member.dto';
|
||||||
|
import { TrainCrewMember } from './entities/train-crew-member.entity';
|
||||||
|
|
||||||
|
const DEFAULT_LIMIT = 25;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TrainCrewService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(TrainCrewMember)
|
||||||
|
private readonly crewRepo: Repository<TrainCrewMember>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async create(dto: CreateTrainCrewMemberDto): Promise<TrainCrewMember> {
|
||||||
|
await this.assertNoDuplicate(dto.firstName, dto.lastName, dto.role);
|
||||||
|
const member = this.crewRepo.create(dto);
|
||||||
|
return this.crewRepo.save(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(query: QueryTrainCrewMemberDto = {}): Promise<{
|
||||||
|
data: TrainCrewMember[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}> {
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const limit = query.limit ?? DEFAULT_LIMIT;
|
||||||
|
|
||||||
|
const qb = this.crewRepo.createQueryBuilder('c');
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('(c.firstName ILIKE :search OR c.lastName ILIKE :search)', {
|
||||||
|
search: `%${query.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.role) qb.andWhere('c.role = :role', { role: query.role });
|
||||||
|
if (query.nationality) {
|
||||||
|
qb.andWhere('c.nationality = :nationality', { nationality: query.nationality });
|
||||||
|
}
|
||||||
|
if (query.status) qb.andWhere('c.status = :status', { status: query.status });
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('c.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortBy is whitelisted by QueryTrainCrewMemberDto's @IsIn before it lands here.
|
||||||
|
const [data, total] = await qb
|
||||||
|
.orderBy(`c.${query.sortBy ?? 'createdAt'}`, query.sortOrder ?? 'DESC')
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.take(limit)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { data, total, page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<TrainCrewMember> {
|
||||||
|
const member = await this.crewRepo.findOne({ where: { id } });
|
||||||
|
if (!member) {
|
||||||
|
throw new NotFoundException(`Train crew member ${id} not found`);
|
||||||
|
}
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateTrainCrewMemberDto): Promise<TrainCrewMember> {
|
||||||
|
const member = await this.findById(id);
|
||||||
|
|
||||||
|
const firstName = dto.firstName ?? member.firstName;
|
||||||
|
const lastName = dto.lastName ?? member.lastName;
|
||||||
|
const role = dto.role ?? member.role;
|
||||||
|
const identityChanged =
|
||||||
|
firstName !== member.firstName ||
|
||||||
|
lastName !== member.lastName ||
|
||||||
|
role !== member.role;
|
||||||
|
if (identityChanged) {
|
||||||
|
await this.assertNoDuplicate(firstName, lastName, role, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(member, dto);
|
||||||
|
return this.crewRepo.save(member);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await this.findById(id);
|
||||||
|
await this.crewRepo.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The roster carries no employee number yet, so name + role is the only
|
||||||
|
* identity available to catch an accidental re-entry of the same person.
|
||||||
|
* Case-insensitive; `exceptId` skips the row being updated.
|
||||||
|
*/
|
||||||
|
private async assertNoDuplicate(
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
role: string,
|
||||||
|
exceptId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.crewRepo.findOne({
|
||||||
|
where: { firstName: ILike(firstName), lastName: ILike(lastName), role: role as never },
|
||||||
|
});
|
||||||
|
if (existing && existing.id !== exceptId) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Train crew member ${firstName} ${lastName} (${role}) already exists`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -871,7 +871,7 @@ export class TrainSchedulingController {
|
|||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
"Download the schedule's wagon list as an Excel workbook (one row per container: wagon, container, VGM, route, customer)",
|
"Download the schedule's wagon list as an Excel workbook (containers grouped by customer: wagon, container, size, route, company, transitor)",
|
||||||
})
|
})
|
||||||
async scheduleWagonListExport(
|
async scheduleWagonListExport(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Column, Entity } from 'typeorm';
|
|||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
@Entity({ schema: 'freight', name: 'train_scheduling_global_rules' })
|
||||||
export class TrainSchedulingGlobalRules extends BaseEntity {
|
export class TrainSchedulingGlobalRules extends BaseEntity {
|
||||||
|
/**
|
||||||
|
* LEGACY — `max_train_length_meters`, `max_train_weight_tons` and
|
||||||
|
* `max_20ft_container_weight_tons` are no longer read by planning: train
|
||||||
|
* weight/length come from locomotive configuration and per-box ceilings from
|
||||||
|
* the rule engine's weight limit rules (`max_capacity_tons`). Kept only so
|
||||||
|
* existing rows keep loading.
|
||||||
|
*/
|
||||||
@Column({
|
@Column({
|
||||||
name: 'max_train_length_meters',
|
name: 'max_train_length_meters',
|
||||||
type: 'numeric',
|
type: 'numeric',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Wagon } from '../../wagons/entities/wagon.entity';
|
|||||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
|
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
@@ -2211,4 +2212,46 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(written.windowPhase).toBeUndefined();
|
expect(written.windowPhase).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('containerCapacityCeilingsByLine — weight limit rule capacity', () => {
|
||||||
|
const ceilings = (bookings: unknown[]) =>
|
||||||
|
(
|
||||||
|
service as never as {
|
||||||
|
containerCapacityCeilingsByLine: (b: unknown[]) => Promise<Record<string, number>>;
|
||||||
|
}
|
||||||
|
).containerCapacityCeilingsByLine(bookings);
|
||||||
|
|
||||||
|
it('maps each container line to its rule capacity, exact direction winning over BOTH', async () => {
|
||||||
|
const find = jest.fn().mockResolvedValue([
|
||||||
|
{ containerTypeId: 'ct-20', tradeDirection: 'BOTH', maxCapacityTons: '28.000' },
|
||||||
|
{ containerTypeId: 'ct-20', tradeDirection: 'EXPORT', maxCapacityTons: '26.000' },
|
||||||
|
{ containerTypeId: 'ct-40', tradeDirection: 'IMPORT', maxCapacityTons: null },
|
||||||
|
]);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: unknown) => {
|
||||||
|
if (entity === WeightLimitRule) return { find };
|
||||||
|
throw new Error('unexpected repository');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ceilings([
|
||||||
|
{
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
bookingContainers: [
|
||||||
|
{ id: 'line-a', containerTypeId: 'ct-20' },
|
||||||
|
{ id: 'line-b', containerTypeId: 'ct-40' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ tradeDirection: 'IMPORT', bookingContainers: [{ id: 'line-c', containerTypeId: 'ct-20' }] },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toEqual({ 'line-a': 26, 'line-c': 28 });
|
||||||
|
expect(find).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('queries nothing when the bookings carry no container lines', async () => {
|
||||||
|
dataSource.getRepository.mockImplementation(() => {
|
||||||
|
throw new Error('should not be called');
|
||||||
|
});
|
||||||
|
await expect(ceilings([{ tradeDirection: 'EXPORT', bookingContainers: [] }])).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-boo
|
|||||||
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
||||||
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
||||||
|
import { TrainCrewAssignmentService } from '../../train-crew/train-crew-assignment.service';
|
||||||
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
||||||
@@ -74,25 +75,13 @@ import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
|||||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
import { WagonEventInput, WagonHistoryService } from '../../wagon-history/wagon-history.service';
|
||||||
import { TabularExportService } from '../../exports/tabular-export.service';
|
import {
|
||||||
|
buildWagonListWorkbook,
|
||||||
|
groupWagonListLines,
|
||||||
|
WagonListLine,
|
||||||
|
} from '../utils/wagon-list-workbook.util';
|
||||||
|
|
||||||
/** One line of the schedule wagon-list export (raw SQL projection). */
|
/** One line of the schedule wagon-list export (raw SQL projection). */
|
||||||
interface ScheduleWagonListRow {
|
|
||||||
sequenceNo: number | null;
|
|
||||||
wagonNumber: string | null;
|
|
||||||
wagonType: string | null;
|
|
||||||
containerNumber: string | null;
|
|
||||||
containerSizeFt: number | null;
|
|
||||||
loadType: string | null;
|
|
||||||
status: string | null;
|
|
||||||
bulkCargoDescription: string | null;
|
|
||||||
/** numeric columns arrive as strings from pg. */
|
|
||||||
vgmTons: string | null;
|
|
||||||
originLabel: string | null;
|
|
||||||
destinationLabel: string | null;
|
|
||||||
bookingReference: string | null;
|
|
||||||
customerName: string | null;
|
|
||||||
}
|
|
||||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||||
@@ -111,6 +100,7 @@ import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.
|
|||||||
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
|
import { WeightLimitRule } from '../../rule-engine/entities/weight-limit-rule.entity';
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
@@ -159,6 +149,7 @@ import {
|
|||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
MAX_TEU_SLOTS_PER_WAGON,
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
|
type ContainerPlacementRules,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from '../utils/wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import {
|
import {
|
||||||
@@ -189,6 +180,8 @@ import {
|
|||||||
trainSetLocomotiveLimits,
|
trainSetLocomotiveLimits,
|
||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
|
MAX_FALLBACK_LENGTH,
|
||||||
|
MAX_FALLBACK_WEIGHT,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
} from '../train-capacity.util';
|
} from '../train-capacity.util';
|
||||||
import {
|
import {
|
||||||
@@ -378,13 +371,13 @@ export interface UnassignedBookingsResponse {
|
|||||||
bookings: CompositionUnassignedBookingRow[];
|
bookings: CompositionUnassignedBookingRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
/**
|
||||||
maxWeightTons: 3500,
|
* Train weight/length come from locomotive configuration (the assigned set, or
|
||||||
maxLengthMeters: 760,
|
* the strongest in-service locomotive when none is assigned yet); per-box
|
||||||
maxWagonsPerTrain: Math.floor(760 / 14),
|
* container ceilings come from the rule engine's weight limit rules. Only the
|
||||||
max20ftContainerWeightTons: 30,
|
* 20ft pair-imbalance tolerance is a static default.
|
||||||
max20ftPairWeightDiffTons: 10,
|
*/
|
||||||
};
|
const DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS = 10;
|
||||||
|
|
||||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||||
interface BookingWindowRow {
|
interface BookingWindowRow {
|
||||||
@@ -446,9 +439,10 @@ export class TrainSchedulingService {
|
|||||||
// Per-wagon history ledger (global module). @Optional keeps the positional
|
// Per-wagon history ledger (global module). @Optional keeps the positional
|
||||||
// spec constructors working; production always has it.
|
// spec constructors working; production always has it.
|
||||||
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
@Optional() private readonly wagonHistory?: WagonHistoryService,
|
||||||
|
// Crew composition gate (ITLMS Rolling Stock §1.2 "prior to departure").
|
||||||
// Trailing + @Optional so the positional constructors in the existing specs
|
// Trailing + @Optional so the positional constructors in the existing specs
|
||||||
// keep working; production always resolves it from ExportsModule.
|
// keep working; production always resolves it.
|
||||||
@Optional() private readonly tabularExport?: TabularExportService,
|
@Optional() private readonly trainCrewAssignments?: TrainCrewAssignmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
/** Physical wagons behind a set of booking allocations (via their slots), for cargo history rows. */
|
||||||
@@ -809,9 +803,9 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Train length/weight and 20ft weight caps are engine-internal (wagon
|
* Train length/weight and the 20ft weight cap columns are legacy: planning
|
||||||
* planning still reads them off the row); they are no longer exposed or
|
* now takes weight/length from locomotive configuration and per-box ceilings
|
||||||
* editable through the global-rules endpoints.
|
* from weight limit rules. They are neither read nor exposed here.
|
||||||
*/
|
*/
|
||||||
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
private toPublicGlobalRules(row: TrainSchedulingGlobalRules | null) {
|
||||||
if (!row) return row;
|
if (!row) return row;
|
||||||
@@ -3027,6 +3021,11 @@ export class TrainSchedulingService {
|
|||||||
'End the loading window at the origin station before dispatching',
|
'End the loading window at the origin station before dispatching',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// On-board crew must be complete before the train leaves — ITLMS Rolling
|
||||||
|
// Stock §1.2 enforces composition "prior to departure", so an incomplete
|
||||||
|
// crew saves freely on the assignment page but cannot depart. Optional
|
||||||
|
// dependency: the positional spec constructors omit it.
|
||||||
|
await this.trainCrewAssignments?.assertCrewReadyForDispatch(scheduleId);
|
||||||
// Staff may record the departure after the fact — past is fine, future is not.
|
// Staff may record the departure after the fact — past is fine, future is not.
|
||||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||||
this.assertNotFuture(now, 'Departure time');
|
this.assertNotFuture(now, 'Departure time');
|
||||||
@@ -3770,15 +3769,15 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The schedule detail page's wagon-list Excel export.
|
* The schedule detail page's wagon-list Excel export, laid out like the
|
||||||
*
|
* wagon sheet the yard circulates by hand: containers grouped by customer,
|
||||||
* One row per container (a wagon carrying two boxes yields two rows, repeating
|
* one line per container (a two-box wagon repeats its wagon number under one
|
||||||
* the wagon number) so each container's own VGM is present and totals footable.
|
* "No."), a blank line between customers, and the wagon count / company /
|
||||||
* Bulk wagons, having no containers, yield a single row carrying the bulk
|
* transitor merged down each group. See buildWagonListWorkbook.
|
||||||
* description and the allocated tonnage as the VGM figure.
|
|
||||||
*
|
*
|
||||||
* Only wagon slots that actually carry an allocation are listed — empty slots
|
* Only wagon slots that actually carry an allocation are listed — empty slots
|
||||||
* on the consist are omitted.
|
* on the consist are omitted. A bulk wagon yields one line carrying the cargo
|
||||||
|
* description in place of a container number.
|
||||||
*/
|
*/
|
||||||
async scheduleWagonListWorkbook(
|
async scheduleWagonListWorkbook(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
@@ -3787,56 +3786,37 @@ export class TrainSchedulingService {
|
|||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
}
|
}
|
||||||
if (!this.tabularExport) {
|
|
||||||
throw new BadRequestException('Tabular export service is unavailable');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Row grain is the container item; the LEFT JOIN keeps bulk (and any
|
// Row grain is the container item; the LEFT JOIN keeps a bulk (or any
|
||||||
// container-less) allocation as one row. `booking_container_units` is joined
|
// container-less) allocation as one row. The transitor is the customs
|
||||||
// on BOTH container number and its booking_container line — container
|
// clearing agent the customer named on the booking.
|
||||||
// numbers repeat across bookings, so number alone would multiply rows.
|
const lines: WagonListLine[] = await this.dataSource.query(
|
||||||
const rows: ScheduleWagonListRow[] = await this.dataSource.query(
|
|
||||||
`SELECT tsw.sequence_no AS "sequenceNo",
|
`SELECT tsw.sequence_no AS "sequenceNo",
|
||||||
w.wagon_number AS "wagonNumber",
|
w.wagon_number AS "wagonNumber",
|
||||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
|
||||||
ci.container_number AS "containerNumber",
|
ci.container_number AS "containerNumber",
|
||||||
cit.size_ft AS "containerSizeFt",
|
cit.size_ft AS "containerSizeFt",
|
||||||
a.load_type AS "loadType",
|
a.load_type AS "loadType",
|
||||||
a.status AS "status",
|
|
||||||
bl.cargo_description AS "bulkCargoDescription",
|
bl.cargo_description AS "bulkCargoDescription",
|
||||||
COALESCE(
|
|
||||||
ci.gross_weight_tons,
|
|
||||||
bcu.vgm_tons,
|
|
||||||
bc.vgm_per_unit_tons,
|
|
||||||
a.allocated_weight_tons
|
|
||||||
) AS "vgmTons",
|
|
||||||
COALESCE(by_.label, so.label) AS "originLabel",
|
COALESCE(by_.label, so.label) AS "originLabel",
|
||||||
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
COALESCE(ay.label, sd.label) AS "destinationLabel",
|
||||||
b.reference AS "bookingReference",
|
|
||||||
COALESCE(
|
COALESCE(
|
||||||
slc.name,
|
slc.name,
|
||||||
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
CASE WHEN b.is_government THEN NULLIF(TRIM(b.government_institution), '') END,
|
||||||
c.name
|
c.name
|
||||||
) AS "customerName"
|
) AS "customerName",
|
||||||
|
NULLIF(TRIM(b.customs_clearing_agent), '') AS "transitor"
|
||||||
FROM freight.train_schedules s
|
FROM freight.train_schedules s
|
||||||
JOIN freight.train_set_wagons tsw
|
JOIN freight.train_set_wagons tsw
|
||||||
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
ON tsw.train_set_id = s.train_set_id AND tsw.deleted_at IS NULL
|
||||||
JOIN freight.wagon_booking_allocations a
|
JOIN freight.wagon_booking_allocations a
|
||||||
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
ON a.train_set_wagon_id = tsw.id AND a.deleted_at IS NULL
|
||||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
|
||||||
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
LEFT JOIN freight.bookings b ON b.id = a.booking_id
|
||||||
LEFT JOIN freight.companies c ON c.id = b.company_id
|
LEFT JOIN freight.companies c ON c.id = b.company_id
|
||||||
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
LEFT JOIN freight.shipping_line_companies slc ON slc.id = b.shipping_line_company_id
|
||||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||||
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
LEFT JOIN freight.container_types cit ON cit.id = ci.container_type_id
|
||||||
LEFT JOIN freight.booking_container bc
|
|
||||||
ON bc.id = ci.booking_container_id AND bc.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.booking_container_units bcu
|
|
||||||
ON bcu.container_number = ci.container_number
|
|
||||||
AND bcu.booking_container_id = bc.id
|
|
||||||
AND bcu.deleted_at IS NULL
|
|
||||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||||
@@ -3848,47 +3828,13 @@ export class TrainSchedulingService {
|
|||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// "number" is the printed line number of the sheet, not the wagon sequence —
|
const { groups, totalWagons } = groupWagonListLines(lines);
|
||||||
// a two-container wagon occupies two lines, and the reader counts lines.
|
const buffer = await buildWagonListWorkbook({
|
||||||
const sheetRows = rows.map((row, index) => ({
|
trainLabel: schedule.trainNumber ?? schedule.reference ?? schedule.id,
|
||||||
number: index + 1,
|
groups,
|
||||||
wagonNumber: row.wagonNumber ?? '—',
|
totalWagons,
|
||||||
containerNumber:
|
|
||||||
row.containerNumber ??
|
|
||||||
(row.loadType === 'BULK' ? (row.bulkCargoDescription ?? 'Bulk') : '—'),
|
|
||||||
vgmTons: row.vgmTons === null ? null : Number(row.vgmTons),
|
|
||||||
originLabel: row.originLabel ?? '—',
|
|
||||||
destinationLabel: row.destinationLabel ?? '—',
|
|
||||||
customerName: row.customerName ?? '—',
|
|
||||||
}));
|
|
||||||
|
|
||||||
const totalVgm = sheetRows.reduce((sum, r) => sum + (r.vgmTons ?? 0), 0);
|
|
||||||
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
|
||||||
|
|
||||||
const buffer = await this.tabularExport.toXlsx({
|
|
||||||
title: `Wagons ${reference}`.slice(0, 31),
|
|
||||||
description: `Wagon list for train ${reference}`,
|
|
||||||
label: 'train-schedule:wagon-list',
|
|
||||||
kpis: [
|
|
||||||
{ label: 'Lines', value: sheetRows.length },
|
|
||||||
{
|
|
||||||
label: 'Wagons',
|
|
||||||
value: new Set(rows.map((r) => r.sequenceNo)).size,
|
|
||||||
},
|
|
||||||
{ label: 'Total VGM', value: Number(totalVgm.toFixed(3)), unit: 't' },
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{ key: 'number', label: 'No.', type: 'number' },
|
|
||||||
{ key: 'wagonNumber', label: 'Wagon', type: 'string' },
|
|
||||||
{ key: 'containerNumber', label: 'Container number', type: 'string' },
|
|
||||||
{ key: 'vgmTons', label: 'VGM', type: 'tons' },
|
|
||||||
{ key: 'originLabel', label: 'Origin', type: 'string' },
|
|
||||||
{ key: 'destinationLabel', label: 'Destination', type: 'string' },
|
|
||||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
|
||||||
],
|
|
||||||
rows: sheetRows,
|
|
||||||
});
|
});
|
||||||
|
const reference = schedule.reference ?? schedule.trainNumber ?? schedule.id;
|
||||||
return {
|
return {
|
||||||
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
filename: `wagon-list-${this.safeDocumentName(reference)}.xlsx`,
|
||||||
buffer,
|
buffer,
|
||||||
@@ -6708,11 +6654,6 @@ export class TrainSchedulingService {
|
|||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const placementRules = {
|
|
||||||
max20ftContainerWeightTons: trainLimits.max20ftContainerWeightTons,
|
|
||||||
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
|
||||||
};
|
|
||||||
|
|
||||||
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
// With forceAssign, capacity-shaped rules (train limits, total weight,
|
||||||
// locomotive capability) become warnings — staff owns the override. Physical
|
// locomotive capability) become warnings — staff owns the override. Physical
|
||||||
// impossibilities (no wagon of the required type at the yard, wrong route,
|
// impossibilities (no wagon of the required type at the yard, wrong route,
|
||||||
@@ -6745,6 +6686,11 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
if (requireContainerPlacements && resolvedMode !== 'BULK') {
|
||||||
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
|
||||||
|
const placementRules: ContainerPlacementRules = {
|
||||||
|
maxContainerWeightTonsByLineId:
|
||||||
|
await this.containerCapacityCeilingsByLine(containerBookings),
|
||||||
|
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
|
||||||
|
};
|
||||||
violations.push(
|
violations.push(
|
||||||
...validateContainerPlacements(
|
...validateContainerPlacements(
|
||||||
containerBookings,
|
containerBookings,
|
||||||
@@ -6892,6 +6838,70 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard per-box ceiling for every container line of the given bookings, from
|
||||||
|
* the rule engine's weight limit rule (`max_capacity_tons`) matching the
|
||||||
|
* line's container type and the booking's trade direction (a `BOTH` rule
|
||||||
|
* applies to either direction; an exact-direction rule wins over it). Lines
|
||||||
|
* whose rule has no capacity set get no entry — capacity is optional.
|
||||||
|
*/
|
||||||
|
private async containerCapacityCeilingsByLine(
|
||||||
|
bookings: Booking[],
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const lines: Array<{ lineId: string; containerTypeId: string; tradeDirection: string }> = [];
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const direction = String(booking.tradeDirection ?? '').toUpperCase();
|
||||||
|
for (const line of booking.bookingContainers ?? []) {
|
||||||
|
if (!line.containerTypeId) continue;
|
||||||
|
lines.push({ lineId: line.id, containerTypeId: line.containerTypeId, tradeDirection: direction });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lines.length) return {};
|
||||||
|
|
||||||
|
const typeIds = [...new Set(lines.map((l) => l.containerTypeId))];
|
||||||
|
const rules = await this.dataSource
|
||||||
|
.getRepository(WeightLimitRule)
|
||||||
|
.find({ where: { containerTypeId: In(typeIds) } });
|
||||||
|
|
||||||
|
const ceilings: Record<string, number> = {};
|
||||||
|
for (const { lineId, containerTypeId, tradeDirection } of lines) {
|
||||||
|
const candidates = rules.filter(
|
||||||
|
(r) => r.containerTypeId === containerTypeId && r.maxCapacityTons != null,
|
||||||
|
);
|
||||||
|
const rule =
|
||||||
|
candidates.find((r) => r.tradeDirection === tradeDirection) ??
|
||||||
|
candidates.find((r) => r.tradeDirection === 'BOTH');
|
||||||
|
const cap = Number(rule?.maxCapacityTons);
|
||||||
|
if (Number.isFinite(cap) && cap > 0) ceilings[lineId] = cap;
|
||||||
|
}
|
||||||
|
return ceilings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits for a train that has no locomotive assigned yet: the strongest
|
||||||
|
* in-service locomotive on each axis, so planning assumes the most capable
|
||||||
|
* power that could be coupled. Null when no locomotive is configured at all.
|
||||||
|
*/
|
||||||
|
private async strongestFleetLocomotiveLimits(): Promise<LocomotiveLimits | null> {
|
||||||
|
const fleet = await this.locomotivesRepository.findAll({
|
||||||
|
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
|
||||||
|
});
|
||||||
|
const pulls = fleet.map((l) => Number(l.maxPullWeightTons)).filter((v) => v > 0);
|
||||||
|
const lengths = fleet.map((l) => Number(l.maxTrainLengthMeters)).filter((v) => v > 0);
|
||||||
|
if (!pulls.length && !lengths.length) return null;
|
||||||
|
const strongest = (axis: number[], pick: (l: Locomotive) => number) =>
|
||||||
|
fleet.find((l) => pick(l) === Math.max(...axis));
|
||||||
|
return {
|
||||||
|
maxPullWeightTons: pulls.length ? Math.max(...pulls) : Infinity,
|
||||||
|
maxTrainLengthMeters: lengths.length ? Math.max(...lengths) : Infinity,
|
||||||
|
overageToleranceTons:
|
||||||
|
Number(strongest(pulls, (l) => Number(l.maxPullWeightTons))?.overageToleranceTons) || 0,
|
||||||
|
overageToleranceMeters:
|
||||||
|
Number(strongest(lengths, (l) => Number(l.maxTrainLengthMeters))?.overageToleranceMeters) ||
|
||||||
|
0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveTrainLimitConfig(
|
private async resolveTrainLimitConfig(
|
||||||
dto?: {
|
dto?: {
|
||||||
maxTrainWeightTons?: number;
|
maxTrainWeightTons?: number;
|
||||||
@@ -6902,24 +6912,14 @@ export class TrainSchedulingService {
|
|||||||
builtWagonCount?: number,
|
builtWagonCount?: number,
|
||||||
): Promise<Required<TrainLimitConfig>> {
|
): Promise<Required<TrainLimitConfig>> {
|
||||||
const row = await this.loadGlobalRulesRow();
|
const row = await this.loadGlobalRulesRow();
|
||||||
const configured = this.configService?.get<{
|
const configured = this.configService?.get<{ maxWagonsPerTrain?: number }>(
|
||||||
maxTrainWeightTons?: number;
|
'app.trainScheduling',
|
||||||
maxTrainLengthMeters?: number;
|
);
|
||||||
maxWagonsPerTrain?: number;
|
|
||||||
}>('app.trainScheduling');
|
|
||||||
|
|
||||||
const ruleWeightCap =
|
|
||||||
dto?.maxTrainWeightTons ??
|
|
||||||
(row?.maxTrainWeightTons != null
|
|
||||||
? Number(row.maxTrainWeightTons)
|
|
||||||
: configured?.maxTrainWeightTons);
|
|
||||||
const ruleLengthCap =
|
|
||||||
dto?.maxTrainLengthMeters ??
|
|
||||||
(row?.maxTrainLengthMeters != null
|
|
||||||
? Number(row.maxTrainLengthMeters)
|
|
||||||
: configured?.maxTrainLengthMeters);
|
|
||||||
|
|
||||||
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
const wagonTypes = await this.loadSchedulingWagonTypeDimensions();
|
||||||
|
const max20ftPairWeightDiffTons = this.positiveNumber(
|
||||||
|
undefined,
|
||||||
|
Number(row?.max20ftPairWeightDiffTons) || DEFAULT_20FT_PAIR_WEIGHT_DIFF_TONS,
|
||||||
|
);
|
||||||
|
|
||||||
if (locomotive) {
|
if (locomotive) {
|
||||||
// With a locomotive assigned its own limits are the single source of
|
// With a locomotive assigned its own limits are the single source of
|
||||||
@@ -6954,52 +6954,40 @@ export class TrainSchedulingService {
|
|||||||
: builtWagonCount && builtWagonCount > 0
|
: builtWagonCount && builtWagonCount > 0
|
||||||
? builtWagonCount
|
? builtWagonCount
|
||||||
: derived.maxWagonSlots,
|
: derived.maxWagonSlots,
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWeightTons = this.positiveNumber(
|
// No locomotive on the set yet: plan against the strongest in-service
|
||||||
dto?.maxTrainWeightTons,
|
// locomotive's configuration. An explicit dto override still narrows it.
|
||||||
ruleWeightCap ?? DEFAULT_TRAIN_LIMITS.maxWeightTons,
|
const fleet = await this.strongestFleetLocomotiveLimits();
|
||||||
);
|
if (!fleet) {
|
||||||
const maxLengthMeters = this.positiveNumber(
|
this.logger.warn(
|
||||||
dto?.maxTrainLengthMeters,
|
'No in-service locomotive is configured — train weight/length limits fall back to ' +
|
||||||
ruleLengthCap ?? DEFAULT_TRAIN_LIMITS.maxLengthMeters,
|
`${MAX_FALLBACK_WEIGHT}T / ${MAX_FALLBACK_LENGTH}m until a locomotive is added`,
|
||||||
);
|
);
|
||||||
const derivedWithoutLoco = deriveTrainCapacityFromLocomotive(
|
}
|
||||||
{ maxPullWeightTons: maxWeightTons, maxTrainLengthMeters: maxLengthMeters },
|
const derived = deriveTrainCapacityFromLocomotive(
|
||||||
|
fleet ?? { maxPullWeightTons: MAX_FALLBACK_WEIGHT, maxTrainLengthMeters: MAX_FALLBACK_LENGTH },
|
||||||
wagonTypes,
|
wagonTypes,
|
||||||
|
{
|
||||||
|
maxTrainWeightTons: dto?.maxTrainWeightTons,
|
||||||
|
maxTrainLengthMeters: dto?.maxTrainLengthMeters,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
maxWeightTons,
|
maxWeightTons: derived.maxWeightTons,
|
||||||
maxLengthMeters,
|
maxLengthMeters: derived.maxLengthMeters,
|
||||||
maxWagonsPerTrain: Math.floor(
|
maxWagonsPerTrain: Math.floor(
|
||||||
this.positiveNumber(
|
this.positiveNumber(
|
||||||
dto?.maxWagonsPerTrain,
|
dto?.maxWagonsPerTrain,
|
||||||
row?.maxWagonsPerTrain != null
|
row?.maxWagonsPerTrain != null
|
||||||
? Number(row.maxWagonsPerTrain)
|
? Number(row.maxWagonsPerTrain)
|
||||||
: configured?.maxWagonsPerTrain ?? derivedWithoutLoco.maxWagonSlots,
|
: configured?.maxWagonsPerTrain ?? derived.maxWagonSlots,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
max20ftContainerWeightTons: this.positiveNumber(
|
max20ftPairWeightDiffTons,
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftContainerWeightTons) || DEFAULT_TRAIN_LIMITS.max20ftContainerWeightTons,
|
|
||||||
),
|
|
||||||
max20ftPairWeightDiffTons: this.positiveNumber(
|
|
||||||
undefined,
|
|
||||||
Number(row?.max20ftPairWeightDiffTons) ||
|
|
||||||
DEFAULT_TRAIN_LIMITS.max20ftPairWeightDiffTons,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { BillingModule } from '../billing/billing.module';
|
|||||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../container-management/entities/container.entity';
|
||||||
import { ExportsModule } from '../exports/exports.module';
|
|
||||||
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
import { LocomotivesModule } from '../locomotives/locomotives.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FacilityHandlingService } from './facility-handling.service';
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
@@ -17,6 +16,7 @@ import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.
|
|||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||||
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
import { TrainSetsModule } from '../train-sets/train-sets.module';
|
||||||
|
import { TrainCrewModule } from '../train-crew/train-crew.module';
|
||||||
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||||
@@ -68,7 +68,6 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
NotificationInboxModule,
|
NotificationInboxModule,
|
||||||
ExportsModule,
|
|
||||||
LocomotivesModule,
|
LocomotivesModule,
|
||||||
WagonTypesModule,
|
WagonTypesModule,
|
||||||
TrainSetsModule,
|
TrainSetsModule,
|
||||||
@@ -76,6 +75,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
|||||||
forwardRef(() => WarehousesModule),
|
forwardRef(() => WarehousesModule),
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
forwardRef(() => ContractsModule),
|
forwardRef(() => ContractsModule),
|
||||||
|
TrainCrewModule,
|
||||||
],
|
],
|
||||||
controllers: [TrainSchedulingController],
|
controllers: [TrainSchedulingController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildWagonListWorkbook,
|
||||||
|
groupWagonListLines,
|
||||||
|
WAGON_LIST_HEADERS,
|
||||||
|
WagonListLine,
|
||||||
|
wagonListSheetName,
|
||||||
|
} from './wagon-list-workbook.util';
|
||||||
|
|
||||||
|
const line = (overrides: Partial<WagonListLine>): WagonListLine => ({
|
||||||
|
sequenceNo: 1,
|
||||||
|
wagonNumber: 'ER0001',
|
||||||
|
containerNumber: 'CONT0000001',
|
||||||
|
containerSizeFt: 40,
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
bulkCargoDescription: null,
|
||||||
|
originLabel: 'DCT',
|
||||||
|
destinationLabel: 'GMP',
|
||||||
|
customerName: 'ABC transit',
|
||||||
|
transitor: null,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirrors the reference sheet: a 40ft wagon, a wagon carrying two 20ft boxes,
|
||||||
|
// then a second customer's single wagon, and a bulk wagon for a third.
|
||||||
|
const fixture: WagonListLine[] = [
|
||||||
|
line({ sequenceNo: 1, wagonNumber: 'ER0691', containerNumber: 'TLLU4855720' }),
|
||||||
|
line({
|
||||||
|
sequenceNo: 2,
|
||||||
|
wagonNumber: 'ER0693',
|
||||||
|
containerNumber: 'CXDU1833620',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
transitor: 'Semuzu Transit',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 2,
|
||||||
|
wagonNumber: 'ER0693',
|
||||||
|
containerNumber: 'TTNU1328287',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
transitor: 'Semuzu Transit',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 3,
|
||||||
|
wagonNumber: 'ER0444',
|
||||||
|
containerNumber: 'ESLU0720200',
|
||||||
|
containerSizeFt: 20,
|
||||||
|
customerName: 'SYNTRANS LOGISTICS PLC',
|
||||||
|
}),
|
||||||
|
line({
|
||||||
|
sequenceNo: 4,
|
||||||
|
wagonNumber: 'ER0716',
|
||||||
|
containerNumber: null,
|
||||||
|
containerSizeFt: null,
|
||||||
|
loadType: 'BULK',
|
||||||
|
bulkCargoDescription: 'Wheat',
|
||||||
|
customerName: 'Baili food processing',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('groupWagonListLines', () => {
|
||||||
|
it('groups by customer in first-appearance order and counts wagons, not containers', () => {
|
||||||
|
const { groups, totalWagons } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups.map((g) => g.companyName)).toEqual([
|
||||||
|
'ABC transit',
|
||||||
|
'SYNTRANS LOGISTICS PLC',
|
||||||
|
'Baili food processing',
|
||||||
|
]);
|
||||||
|
expect(groups.map((g) => g.wagonCount)).toEqual([2, 1, 1]);
|
||||||
|
expect(totalWagons).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('numbers wagons across the whole sheet, repeating the ordinal for a second container', () => {
|
||||||
|
const { groups } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups[0].lines.map((l) => l.wagonOrdinal)).toEqual([1, 2, 2]);
|
||||||
|
expect(groups[1].lines.map((l) => l.wagonOrdinal)).toEqual([3]);
|
||||||
|
expect(groups[2].lines.map((l) => l.wagonOrdinal)).toEqual([4]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders container size as "NNft", bulk loads by cargo description, and the transitor once per group', () => {
|
||||||
|
const { groups } = groupWagonListLines(fixture);
|
||||||
|
|
||||||
|
expect(groups[0].lines.map((l) => l.containerType)).toEqual(['40ft', '20ft', '20ft']);
|
||||||
|
expect(groups[0].transitor).toBe('Semuzu Transit');
|
||||||
|
expect(groups[2].lines[0]).toMatchObject({
|
||||||
|
containerNumber: 'Wheat',
|
||||||
|
containerType: 'Bulk',
|
||||||
|
});
|
||||||
|
expect(groups[2].transitor).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('files lines with no customer under a placeholder group', () => {
|
||||||
|
const { groups } = groupWagonListLines([line({ customerName: null })]);
|
||||||
|
expect(groups[0].companyName).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wagonListSheetName', () => {
|
||||||
|
it('strips characters Excel forbids and caps at 31 characters', () => {
|
||||||
|
expect(wagonListSheetName('V138U/8502')).toBe('V138U 8502');
|
||||||
|
expect(wagonListSheetName('a'.repeat(40))).toHaveLength(31);
|
||||||
|
expect(wagonListSheetName('///')).toBe('Wagons');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildWagonListWorkbook', () => {
|
||||||
|
let sheet: ExcelJS.Worksheet;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const grouped = groupWagonListLines(fixture);
|
||||||
|
const buffer = await buildWagonListWorkbook({ trainLabel: 'V138U/8502', ...grouped });
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer as unknown as ExcelJS.Buffer);
|
||||||
|
sheet = workbook.worksheets[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
const cell = (address: string) => sheet.getCell(address).value;
|
||||||
|
const merged = (address: string) => sheet.getCell(address).isMerged;
|
||||||
|
|
||||||
|
it('opens with the banner (train + total wagons) merged across every column, then the headers', () => {
|
||||||
|
expect(sheet.name).toBe('V138U 8502');
|
||||||
|
expect(String(cell('A1'))).toMatch(/^V138U\/8502\s+Total wagons= 4$/);
|
||||||
|
expect(merged('I1')).toBe(true);
|
||||||
|
expect(sheet.getRow(2).values).toEqual([undefined, ...WAGON_LIST_HEADERS]);
|
||||||
|
expect(sheet.getCell('A2').font?.bold).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays each customer out as a contiguous block separated by a blank row', () => {
|
||||||
|
// Rows 3-5: ABC transit; row 6 blank; row 7: SYNTRANS; row 8 blank; row 9: Baili.
|
||||||
|
expect([cell('B3'), cell('B4'), cell('B5')]).toEqual(['ER0691', 'ER0693', 'ER0693']);
|
||||||
|
expect(sheet.getRow(6).values).toEqual([]);
|
||||||
|
expect(cell('B7')).toBe('ER0444');
|
||||||
|
expect(sheet.getRow(8).values).toEqual([]);
|
||||||
|
expect(cell('B9')).toBe('ER0716');
|
||||||
|
expect(cell('C9')).toBe('Wheat');
|
||||||
|
expect(cell('G9')).toBe('Bulk');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prints "No." once per wagon, merged down a two-container wagon', () => {
|
||||||
|
expect([cell('A3'), cell('A4'), cell('A5')]).toEqual([1, 2, 2]);
|
||||||
|
expect(merged('A4')).toBe(true);
|
||||||
|
expect(merged('A5')).toBe(true);
|
||||||
|
expect(merged('A3')).toBe(false);
|
||||||
|
expect(cell('A7')).toBe(3);
|
||||||
|
expect(cell('A9')).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges wagon count, company and transitor down the whole customer block', () => {
|
||||||
|
expect(cell('D3')).toBe(2);
|
||||||
|
expect(cell('H3')).toBe('ABC transit');
|
||||||
|
expect(cell('I3')).toBe('Semuzu Transit');
|
||||||
|
for (const col of ['D', 'H', 'I']) {
|
||||||
|
expect(merged(`${col}3`)).toBe(true);
|
||||||
|
expect(merged(`${col}5`)).toBe(true);
|
||||||
|
}
|
||||||
|
expect(sheet.getCell('H3').font?.bold).toBe(true);
|
||||||
|
// A single-line block has nothing to merge.
|
||||||
|
expect(merged('H7')).toBe(false);
|
||||||
|
expect(cell('D7')).toBe(1);
|
||||||
|
expect(cell('I7')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries the route and container size on every line', () => {
|
||||||
|
expect([cell('E3'), cell('F3'), cell('G3')]).toEqual(['DCT', 'GMP', '40ft']);
|
||||||
|
expect([cell('E5'), cell('F5'), cell('G5')]).toEqual(['DCT', 'GMP', '20ft']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One loaded container (or one bulk load) on a wagon of the schedule — the
|
||||||
|
* input grain of the wagon-list workbook. A wagon carrying two boxes arrives
|
||||||
|
* as two lines sharing `sequenceNo`.
|
||||||
|
*/
|
||||||
|
export interface WagonListLine {
|
||||||
|
sequenceNo: number | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
/** 20 / 40 / 45 …; null for bulk or unknown. */
|
||||||
|
containerSizeFt: number | null;
|
||||||
|
loadType: string | null;
|
||||||
|
bulkCargoDescription: string | null;
|
||||||
|
originLabel: string | null;
|
||||||
|
destinationLabel: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
/** The customs clearing / transit agent named on the booking. */
|
||||||
|
transitor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonListGroupLine {
|
||||||
|
/** Sheet-wide wagon counter — printed once per wagon, not once per container. */
|
||||||
|
wagonOrdinal: number;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
wagonNumber: string;
|
||||||
|
containerNumber: string;
|
||||||
|
containerType: string;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All lines of one customer, contiguous on the sheet. */
|
||||||
|
export interface WagonListGroup {
|
||||||
|
companyName: string;
|
||||||
|
transitor: string;
|
||||||
|
/** Distinct wagons in the group — the "Number of Wagons" cell. */
|
||||||
|
wagonCount: number;
|
||||||
|
lines: WagonListGroupLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WagonListWorkbookInput {
|
||||||
|
/** Train number (falls back to the schedule reference) — the banner text. */
|
||||||
|
trainLabel: string;
|
||||||
|
groups: WagonListGroup[];
|
||||||
|
totalWagons: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK = '—';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups the container-grain lines by customer, in order of first appearance,
|
||||||
|
* keeping consist order inside each group. Wagon ordinals run across the whole
|
||||||
|
* sheet so the reader can count wagons down the "No." column.
|
||||||
|
*/
|
||||||
|
export function groupWagonListLines(lines: WagonListLine[]): {
|
||||||
|
groups: WagonListGroup[];
|
||||||
|
totalWagons: number;
|
||||||
|
} {
|
||||||
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
WagonListGroup & { transitors: Set<string>; wagons: Set<string> }
|
||||||
|
>();
|
||||||
|
const ordinalByGroupWagon = new Map<string, number>();
|
||||||
|
let nextOrdinal = 1;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const companyName = line.customerName?.trim() || BLANK;
|
||||||
|
let group = groups.get(companyName);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
companyName,
|
||||||
|
transitor: '',
|
||||||
|
wagonCount: 0,
|
||||||
|
lines: [],
|
||||||
|
transitors: new Set(),
|
||||||
|
wagons: new Set(),
|
||||||
|
};
|
||||||
|
groups.set(companyName, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wagonKey = `${line.sequenceNo ?? ''}|${line.wagonNumber ?? ''}`;
|
||||||
|
const ordinalKey = `${companyName} ${wagonKey}`;
|
||||||
|
let wagonOrdinal = ordinalByGroupWagon.get(ordinalKey);
|
||||||
|
if (wagonOrdinal === undefined) {
|
||||||
|
wagonOrdinal = nextOrdinal++;
|
||||||
|
ordinalByGroupWagon.set(ordinalKey, wagonOrdinal);
|
||||||
|
group.wagons.add(wagonKey);
|
||||||
|
}
|
||||||
|
const transitor = line.transitor?.trim();
|
||||||
|
if (transitor) group.transitors.add(transitor);
|
||||||
|
|
||||||
|
const isBulk = line.loadType === 'BULK' && !line.containerNumber;
|
||||||
|
group.lines.push({
|
||||||
|
wagonOrdinal,
|
||||||
|
sequenceNo: line.sequenceNo,
|
||||||
|
wagonNumber: line.wagonNumber ?? BLANK,
|
||||||
|
containerNumber:
|
||||||
|
line.containerNumber ?? (isBulk ? (line.bulkCargoDescription ?? 'Bulk') : BLANK),
|
||||||
|
containerType: isBulk ? 'Bulk' : line.containerSizeFt ? `${line.containerSizeFt}ft` : BLANK,
|
||||||
|
origin: line.originLabel ?? BLANK,
|
||||||
|
destination: line.destinationLabel ?? BLANK,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...groups.values()].map(({ transitors, wagons, ...group }) => ({
|
||||||
|
...group,
|
||||||
|
transitor: [...transitors].join(', '),
|
||||||
|
wagonCount: wagons.size,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
groups: result,
|
||||||
|
totalWagons: result.reduce((sum, g) => sum + g.wagonCount, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMN_WIDTHS = [3.7, 14.9, 14.9, 17.3, 15, 12.8, 16.2, 27.5, 29.9];
|
||||||
|
export const WAGON_LIST_HEADERS = [
|
||||||
|
'No.',
|
||||||
|
'Wagon',
|
||||||
|
'Container No.',
|
||||||
|
'Number of Wagons',
|
||||||
|
'Origin',
|
||||||
|
'Destination',
|
||||||
|
'Type of Container',
|
||||||
|
'Company Name',
|
||||||
|
'Transitor',
|
||||||
|
];
|
||||||
|
const LAST_COLUMN = WAGON_LIST_HEADERS.length;
|
||||||
|
/** Excel's "Blue-Gray, Text 2, Lighter 60%" — the banner fill of the reference sheet. */
|
||||||
|
const BANNER_FILL: ExcelJS.Fill = {
|
||||||
|
type: 'pattern',
|
||||||
|
pattern: 'solid',
|
||||||
|
fgColor: { argb: 'FFACB9CA' },
|
||||||
|
};
|
||||||
|
const CENTERED: Partial<ExcelJS.Alignment> = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
/** Excel forbids `[]:*?/\` in sheet names and caps them at 31 characters. */
|
||||||
|
export function wagonListSheetName(trainLabel: string): string {
|
||||||
|
const cleaned = trainLabel.replace(/[[\]:*?/\\]+/g, ' ').trim();
|
||||||
|
return (cleaned || 'Wagons').slice(0, 31);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The operations wagon-list sheet, laid out like the hand-made one the yard
|
||||||
|
* circulates: a banner row (train number + total wagons), one header row, then
|
||||||
|
* the containers grouped by customer with a blank row between customers.
|
||||||
|
* Inside a group the wagon number repeats per container while "No." is merged
|
||||||
|
* down the wagon; "Number of Wagons", "Company Name" and "Transitor" are merged
|
||||||
|
* down the whole group.
|
||||||
|
*/
|
||||||
|
export async function buildWagonListWorkbook(input: WagonListWorkbookInput): Promise<Buffer> {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet(wagonListSheetName(input.trainLabel), {
|
||||||
|
views: [{ zoomScale: 85 }],
|
||||||
|
});
|
||||||
|
COLUMN_WIDTHS.forEach((width, i) => {
|
||||||
|
sheet.getColumn(i + 1).width = width;
|
||||||
|
});
|
||||||
|
|
||||||
|
const banner = sheet.addRow([
|
||||||
|
`${input.trainLabel}${' '.repeat(40)}Total wagons= ${input.totalWagons}`,
|
||||||
|
]);
|
||||||
|
sheet.mergeCells(1, 1, 1, LAST_COLUMN);
|
||||||
|
banner.height = 28;
|
||||||
|
const bannerCell = banner.getCell(1);
|
||||||
|
bannerCell.font = { name: 'Calibri', size: 12, bold: true };
|
||||||
|
bannerCell.alignment = CENTERED;
|
||||||
|
bannerCell.fill = BANNER_FILL;
|
||||||
|
|
||||||
|
const header = sheet.addRow(WAGON_LIST_HEADERS);
|
||||||
|
header.eachCell((cell) => {
|
||||||
|
cell.font = { name: 'Calibri', size: 11, bold: true };
|
||||||
|
cell.alignment = CENTERED;
|
||||||
|
});
|
||||||
|
|
||||||
|
input.groups.forEach((group, groupIndex) => {
|
||||||
|
if (groupIndex > 0) sheet.addRow([]);
|
||||||
|
const firstRow = sheet.rowCount + 1;
|
||||||
|
|
||||||
|
let wagonStartRow = firstRow;
|
||||||
|
group.lines.forEach((line, lineIndex) => {
|
||||||
|
const isFirstLine = lineIndex === 0;
|
||||||
|
const newWagon = isFirstLine || group.lines[lineIndex - 1].wagonOrdinal !== line.wagonOrdinal;
|
||||||
|
const row = sheet.addRow([
|
||||||
|
newWagon ? line.wagonOrdinal : null,
|
||||||
|
line.wagonNumber,
|
||||||
|
line.containerNumber,
|
||||||
|
isFirstLine ? group.wagonCount : null,
|
||||||
|
line.origin,
|
||||||
|
line.destination,
|
||||||
|
line.containerType,
|
||||||
|
isFirstLine ? group.companyName : null,
|
||||||
|
isFirstLine ? group.transitor || null : null,
|
||||||
|
]);
|
||||||
|
for (let col = 1; col <= LAST_COLUMN; col++) {
|
||||||
|
const cell = row.getCell(col);
|
||||||
|
cell.font = { name: 'Calibri', size: 11, bold: col === 8 };
|
||||||
|
if (col === 8) cell.alignment = { ...CENTERED, wrapText: true };
|
||||||
|
else if (col !== 2 && col !== 3) cell.alignment = CENTERED;
|
||||||
|
}
|
||||||
|
row.getCell(1).numFmt = '#,##0';
|
||||||
|
|
||||||
|
if (newWagon && !isFirstLine) {
|
||||||
|
if (row.number - 1 > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, row.number - 1, 1);
|
||||||
|
wagonStartRow = row.number;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastRow = sheet.rowCount;
|
||||||
|
if (lastRow > wagonStartRow) sheet.mergeCells(wagonStartRow, 1, lastRow, 1);
|
||||||
|
if (lastRow > firstRow) {
|
||||||
|
for (const col of [4, 8, 9]) sheet.mergeCells(firstRow, col, lastRow, col);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||||
|
}
|
||||||
@@ -171,7 +171,7 @@ describe('wagon-plan.util', () => {
|
|||||||
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
expect(validateContainerPlacements([booking], plan, placements)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects 20ft container over max individual weight', () => {
|
it('rejects a container over its line weight-limit-rule capacity', () => {
|
||||||
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
const booking = makeContainerBooking('c20', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
const units = expandBookingContainerUnits([booking]);
|
const units = expandBookingContainerUnits([booking]);
|
||||||
const placements = units.map((unit, index) => ({
|
const placements = units.map((unit, index) => ({
|
||||||
@@ -182,11 +182,29 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
maxContainerWeightTonsByLineId: { [units[0]!.bookingContainerId]: 30 },
|
||||||
max20ftPairWeightDiffTons: 10,
|
max20ftPairWeightDiffTons: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(violations.some((v) => v.includes('exceeds max 30T'))).toBe(true);
|
expect(violations.filter((v) => v.includes('weight limit rule capacity of 30T'))).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no per-box ceiling to a line without a weight-limit-rule capacity', () => {
|
||||||
|
const booking = makeContainerBooking('c20b', [{ quantity: 2, wagonsRequired: 1, vgmPerUnitTons: 35 }]);
|
||||||
|
const units = expandBookingContainerUnits([booking]);
|
||||||
|
const placements = units.map((unit, index) => ({
|
||||||
|
bookingContainerId: unit.bookingContainerId,
|
||||||
|
unitIndex: unit.unitIndex,
|
||||||
|
sequenceNo: 1,
|
||||||
|
containerNumber: `CNTR-${index + 1}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
|
maxContainerWeightTonsByLineId: {},
|
||||||
|
max20ftPairWeightDiffTons: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
it('rejects 20ft pair when weight difference exceeds limit', () => {
|
||||||
@@ -204,7 +222,6 @@ describe('wagon-plan.util', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const violations = validate20ftContainerRules(units, placements, {
|
const violations = validate20ftContainerRules(units, placements, {
|
||||||
max20ftContainerWeightTons: 30,
|
|
||||||
max20ftPairWeightDiffTons: 10,
|
max20ftPairWeightDiffTons: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,12 +20,17 @@ export type TrainLimitConfig = {
|
|||||||
maxWeightTons?: number;
|
maxWeightTons?: number;
|
||||||
maxLengthMeters?: number;
|
maxLengthMeters?: number;
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
max20ftContainerWeightTons?: number;
|
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContainerPlacementRules = {
|
export type ContainerPlacementRules = {
|
||||||
max20ftContainerWeightTons?: number;
|
/**
|
||||||
|
* Hard per-box weight ceiling keyed by booking container LINE id, resolved
|
||||||
|
* from the rule engine's weight limit rule (`max_capacity_tons`) for the
|
||||||
|
* line's container type and the booking's trade direction. A line with no
|
||||||
|
* entry has no ceiling — the rule's capacity is optional.
|
||||||
|
*/
|
||||||
|
maxContainerWeightTonsByLineId?: Record<string, number>;
|
||||||
max20ftPairWeightDiffTons?: number;
|
max20ftPairWeightDiffTons?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -820,15 +825,21 @@ export function perEdgeConsistUsage(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-box weight rules for a container plan:
|
||||||
|
* - every unit is checked against its line's weight-limit-rule capacity
|
||||||
|
* ceiling (`maxContainerWeightTonsByLineId`, any size);
|
||||||
|
* - 20ft pairs sharing a wagon are checked for weight imbalance.
|
||||||
|
*/
|
||||||
export function validate20ftContainerRules(
|
export function validate20ftContainerRules(
|
||||||
units: ContainerUnitRow[],
|
units: ContainerUnitRow[],
|
||||||
placements: ContainerPlacementInput[],
|
placements: ContainerPlacementInput[],
|
||||||
rules?: ContainerPlacementRules,
|
rules?: ContainerPlacementRules,
|
||||||
): string[] {
|
): string[] {
|
||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const maxEach = rules?.max20ftContainerWeightTons;
|
const capacityByLine = rules?.maxContainerWeightTonsByLineId;
|
||||||
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
const maxDiff = rules?.max20ftPairWeightDiffTons;
|
||||||
if (maxEach == null && maxDiff == null) return violations;
|
if (capacityByLine == null && maxDiff == null) return violations;
|
||||||
|
|
||||||
const placementByUnit = new Map(
|
const placementByUnit = new Map(
|
||||||
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
placements.map((p) => [`${p.bookingContainerId}:${p.unitIndex}`, p]),
|
||||||
@@ -837,15 +848,16 @@ export function validate20ftContainerRules(
|
|||||||
const weightsBySlot = new Map<number, number[]>();
|
const weightsBySlot = new Map<number, number[]>();
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
const maxEach = capacityByLine?.[unit.bookingContainerId];
|
||||||
if (sizeFt >= 40) continue;
|
|
||||||
|
|
||||||
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
if (maxEach != null && unit.grossWeightTons > maxEach) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`${unit.label} weight ${unit.grossWeightTons}T exceeds max ${maxEach}T for 20ft containers`,
|
`${unit.label} weight ${unit.grossWeightTons}T exceeds the weight limit rule capacity of ${maxEach}T for ${unit.containerTypeCode} containers`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sizeFt = unit.sizeFt ?? (unit.containerTypeCode.includes('40') ? 40 : 20);
|
||||||
|
if (sizeFt >= 40) continue;
|
||||||
|
|
||||||
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
const placement = placementByUnit.get(`${unit.bookingContainerId}:${unit.unitIndex}`);
|
||||||
if (!placement?.sequenceNo) continue;
|
if (!placement?.sequenceNo) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GenerateInvoiceDto {
|
|||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
|
@ApiPropertyOptional({ enum: ['ETB', 'USD'], description: 'Currency to bill the generated invoice in.' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(['ETB', 'USD'])
|
@IsIn(['ETB', 'USD', 'DJF'])
|
||||||
billingCurrency?: 'ETB' | 'USD';
|
billingCurrency?: 'ETB' | 'USD';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { ExchangeService } from '@edr/api-common';
|
import { CURRENCY_CODES, CurrencyCode, ExchangeService } from '@edr/api-common';
|
||||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@@ -430,8 +430,11 @@ export class WarehouseFeeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' {
|
private normalizeCurrency(currency?: string | null): CurrencyCode {
|
||||||
return currency === 'ETB' ? 'ETB' : 'USD';
|
const code = currency?.toUpperCase();
|
||||||
|
return (CURRENCY_CODES as readonly string[]).includes(code ?? '')
|
||||||
|
? (code as CurrencyCode)
|
||||||
|
: 'USD';
|
||||||
}
|
}
|
||||||
|
|
||||||
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
|
private async convertAmount(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user