mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into fixes
This commit is contained in:
@@ -46,6 +46,7 @@ import { NotificationInboxModule } from "./modules/notification-inbox/notificati
|
||||
import { SupportChatModule } from "./modules/support-chat/support-chat.module";
|
||||
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
|
||||
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
|
||||
import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
@@ -192,6 +193,7 @@ import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middlewar
|
||||
SupportChatModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
ExchangeSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
|
||||
@@ -24,12 +24,13 @@ export default registerAs("app", () => ({
|
||||
},
|
||||
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
|
||||
cbeExchange: {
|
||||
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
|
||||
/** CBE daily-exchange-rates JSON — USD `transactionalSelling` is used. */
|
||||
scrapeUrl:
|
||||
process.env.CBE_EXCHANGE_SCRAPE_URL ??
|
||||
process.env.CBE_EXCHANGE_API_URL ??
|
||||
"https://ethio.forex/bank/CBET",
|
||||
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
// No fallback env var: the fallback lives in freight.exchange_settings,
|
||||
// maintained by the backoffice and by write-back on every successful fetch.
|
||||
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
const dynamicSource = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
dynamicTemplate = dynamicSource
|
||||
? {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* The codes that gain a customs variant. Intercity is deliberately absent: it
|
||||
* is a domestic Ethiopian movement that crosses no border, so it has no customs
|
||||
* leg and keeps its single unsuffixed template.
|
||||
*/
|
||||
const SPLIT_CODES = [
|
||||
'IMPORT_BULK',
|
||||
'EXPORT_BULK',
|
||||
'IMPORT_CONTAINER',
|
||||
'EXPORT_CONTAINER',
|
||||
];
|
||||
|
||||
/**
|
||||
* Split the four cross-border contract templates into eight — one `_CUSTOMS`
|
||||
* and one `_NO_CUSTOMS` variant each — so the generated contract document
|
||||
* reflects whether EDR clears customs on the Client's behalf. Together with the
|
||||
* two untouched intercity templates the table ends up with ten rows.
|
||||
*
|
||||
* The four existing rows are RENAMED to `<code>_NO_CUSTOMS` rather than
|
||||
* replaced, so any article text staff already edited through the template
|
||||
* editor survives. The four `_CUSTOMS` rows are then inserted from the seed
|
||||
* (the same base pack plus the customs-clearing articles).
|
||||
*
|
||||
* Idempotent: the rename is guarded on the legacy code still existing, and the
|
||||
* insert is ON CONFLICT (code) DO NOTHING.
|
||||
*/
|
||||
export class SplitContractTemplatesByCustoms3230000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1. Carry each legacy row over to its _NO_CUSTOMS code, preserving edits.
|
||||
// Guarded so a re-run (or a DB already holding the new code) is a no-op.
|
||||
for (const legacy of SPLIT_CODES) {
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates
|
||||
SET code = $2, updated_at = now()
|
||||
WHERE code = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates WHERE code = $2
|
||||
);
|
||||
`,
|
||||
[legacy, `${legacy}_NO_CUSTOMS`],
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Seed anything still missing — the six _CUSTOMS rows on an existing DB,
|
||||
// or all twelve on a database that never held the legacy codes.
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const articles = seed.articles.map((article, index) => ({
|
||||
...article,
|
||||
order: index + 1,
|
||||
}));
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.contract_templates
|
||||
(code, name, description, document_title, whereas_clauses, articles)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(articles),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the _CUSTOMS rows and fold the _NO_CUSTOMS rows back onto the legacy
|
||||
* codes, returning the table to six templates. The two intercity rows were
|
||||
* never touched by up(), so they need no reversal.
|
||||
*/
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
for (const legacy of SPLIT_CODES) {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = $1;`,
|
||||
[`${legacy}_CUSTOMS`],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`
|
||||
UPDATE freight.contract_templates
|
||||
SET code = $1, updated_at = now()
|
||||
WHERE code = $2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates WHERE code = $1
|
||||
);
|
||||
`,
|
||||
[legacy, `${legacy}_NO_CUSTOMS`],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Single-row store for the USD→ETB fallback used when the CBE exchange-rate
|
||||
* endpoint is unreachable. The live CBE rate always wins; every successful
|
||||
* fetch overwrites this row, so it holds the last known good rate rather than
|
||||
* a constant that drifts. Operators can also set it by hand during an outage.
|
||||
*
|
||||
* Seeded with the CBE USD transactional selling rate on 2026-08-04, so the
|
||||
* fallback is usable before the first successful fetch.
|
||||
*/
|
||||
export class CreateExchangeSettings3240000000000 implements MigrationInterface {
|
||||
name = 'CreateExchangeSettings3240000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'exchange_settings',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'fallback_rate', type: 'numeric', precision: 18, scale: 6 },
|
||||
// AUTO when written by the CBE sync, MANUAL when set in the backoffice.
|
||||
{ name: 'fallback_source', type: 'varchar', length: '16', default: "'AUTO'" },
|
||||
{ name: 'last_synced_at', type: 'timestamptz', isNullable: true },
|
||||
// IAM user id (iam.users) — no FK, iam schema is externally owned.
|
||||
{ name: 'updated_by_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.exchange_settings (fallback_rate, fallback_source)
|
||||
VALUES (162.416500, 'AUTO')
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.exchange_settings', true);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Module, forwardRef } from "@nestjs/common";
|
||||
import { UserTradeAccessModule } from "../user-trade-access/user-trade-access.module";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
|
||||
import { registerExchangeModule } from "../exchange-settings/exchange-module-options";
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
@@ -86,11 +86,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
CONTRACT_TEMPLATE_CODES,
|
||||
contractTemplateCodeFor,
|
||||
} from './entities/contract-template.entity';
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../../seed/data/contract-template-defaults';
|
||||
|
||||
describe('contractTemplateCodeFor', () => {
|
||||
it('splits import and export by the customs flag', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'BULK', true)).toBe('IMPORT_BULK_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('IMPORT', 'BULK', false)).toBe('IMPORT_BULK_NO_CUSTOMS');
|
||||
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', true)).toBe(
|
||||
'EXPORT_CONTAINER_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('EXPORT', 'CONTAINER', false)).toBe(
|
||||
'EXPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('never gives intercity a customs variant — it crosses no border', () => {
|
||||
for (const flag of [true, false, null, undefined]) {
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'BULK', flag)).toBe('INTERCITY_BULK');
|
||||
expect(contractTemplateCodeFor('DOMESTIC', 'CONTAINER', flag)).toBe(
|
||||
'INTERCITY_CONTAINER',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a missing customs flag as no customs on cross-border contracts', () => {
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', null)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
expect(contractTemplateCodeFor('IMPORT', 'CONTAINER', undefined)).toBe(
|
||||
'IMPORT_CONTAINER_NO_CUSTOMS',
|
||||
);
|
||||
});
|
||||
|
||||
it('only ever resolves to a code that exists', () => {
|
||||
const directions = ['IMPORT', 'EXPORT', 'DOMESTIC', null];
|
||||
const freights = ['BULK', 'CONTAINER', 'BREAK_BULK', null];
|
||||
for (const d of directions) {
|
||||
for (const f of freights) {
|
||||
for (const c of [true, false]) {
|
||||
expect(CONTRACT_TEMPLATE_CODES).toContain(contractTemplateCodeFor(d, f, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONTRACT_TEMPLATE_DEFAULTS', () => {
|
||||
it('seeds exactly the ten declared codes, once each', () => {
|
||||
const seeded = CONTRACT_TEMPLATE_DEFAULTS.map((t) => t.code).sort();
|
||||
expect(seeded).toHaveLength(10);
|
||||
expect(seeded).toEqual([...CONTRACT_TEMPLATE_CODES].sort());
|
||||
});
|
||||
|
||||
it('gives every _CUSTOMS template the customs articles and no other one', () => {
|
||||
for (const seed of CONTRACT_TEMPLATE_DEFAULTS) {
|
||||
const hasCustomsArticle = seed.articles.some((a) => a.id === 'customs-clearing');
|
||||
// Note "_NO_CUSTOMS" also ends with "_CUSTOMS" — exclude it explicitly.
|
||||
const isCustomsVariant =
|
||||
seed.code.endsWith('_CUSTOMS') && !seed.code.endsWith('_NO_CUSTOMS');
|
||||
expect(hasCustomsArticle).toBe(isCustomsVariant);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,11 +25,19 @@ function seededTemplate(code: string): ContractTemplate {
|
||||
}
|
||||
|
||||
describe("contractTemplateCodeFor", () => {
|
||||
it("maps every direction/freight pair to one of the six codes", () => {
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK")).toBe("IMPORT_BULK");
|
||||
expect(contractTemplateCodeFor("EXPORT", "CONTAINER")).toBe("EXPORT_CONTAINER");
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER")).toBe("INTERCITY_CONTAINER");
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "BULK")).toBe("INTERCITY_BULK");
|
||||
it("maps every direction/freight/customs triple to one of the ten codes", () => {
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK", true)).toBe("IMPORT_BULK_CUSTOMS");
|
||||
expect(contractTemplateCodeFor("IMPORT", "BULK", false)).toBe(
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
);
|
||||
expect(contractTemplateCodeFor("EXPORT", "CONTAINER", true)).toBe(
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
);
|
||||
// Intercity is domestic — no border, so no customs variant either way.
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "CONTAINER", true)).toBe(
|
||||
"INTERCITY_CONTAINER",
|
||||
);
|
||||
expect(contractTemplateCodeFor("DOMESTIC", "BULK", false)).toBe("INTERCITY_BULK");
|
||||
expect(contractTemplateCodeFor(null, null)).toBe("INTERCITY_CONTAINER");
|
||||
});
|
||||
});
|
||||
@@ -60,7 +68,7 @@ describe("ContractTemplatesService.preview", () => {
|
||||
);
|
||||
|
||||
it("interpolates {{contractYear}} inside seeded article bodies", async () => {
|
||||
const { html } = await service.preview("IMPORT_BULK");
|
||||
const { html } = await service.preview("IMPORT_BULK_CUSTOMS");
|
||||
expect(html).toContain(`August 31, ${new Date().getFullYear()}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,13 +24,22 @@ import {
|
||||
contractTemplateCodeFor,
|
||||
} from "./entities/contract-template.entity";
|
||||
|
||||
/** Registry keys used to derive labels for the mock preview per template code. */
|
||||
/**
|
||||
* Registry keys used to derive labels for the mock preview per template code.
|
||||
* The registry's FORWARDING scope carries the customs/clearing clause pack, so
|
||||
* the `_CUSTOMS` codes preview against it and `_NO_CUSTOMS` against
|
||||
* TRANSPORT_ONLY.
|
||||
*/
|
||||
const PREVIEW_TEMPLATE_KEYS: Record<ContractTemplateCode, string> = {
|
||||
IMPORT_BULK: "IMP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_BULK_CUSTOMS: "IMP_BULK_USD_FORWARDING",
|
||||
IMPORT_BULK_NO_CUSTOMS: "IMP_BULK_USD_TRANSPORT_ONLY",
|
||||
EXPORT_BULK_CUSTOMS: "EXP_BULK_USD_FORWARDING",
|
||||
EXPORT_BULK_NO_CUSTOMS: "EXP_BULK_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_BULK: "DOM_BULK_USD_TRANSPORT_ONLY",
|
||||
IMPORT_CONTAINER: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER: "EXP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_CUSTOMS: "IMP_CON_USD_FORWARDING",
|
||||
IMPORT_CONTAINER_NO_CUSTOMS: "IMP_CON_USD_TRANSPORT_ONLY",
|
||||
EXPORT_CONTAINER_CUSTOMS: "EXP_CON_USD_FORWARDING",
|
||||
EXPORT_CONTAINER_NO_CUSTOMS: "EXP_CON_USD_TRANSPORT_ONLY",
|
||||
INTERCITY_CONTAINER: "DOM_CON_USD_TRANSPORT_ONLY",
|
||||
};
|
||||
|
||||
@@ -59,14 +68,19 @@ export class ContractTemplatesService {
|
||||
|
||||
/**
|
||||
* The active template used when generating a contract document for the given
|
||||
* direction/freight pair; null when missing or deactivated (the renderer then
|
||||
* falls back to the built-in generic layout).
|
||||
* direction/freight/customs triple; null when missing or deactivated (the
|
||||
* renderer then falls back to the built-in generic layout).
|
||||
*/
|
||||
async findActiveForContract(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
): Promise<ContractTemplate | null> {
|
||||
const code = contractTemplateCodeFor(tradeDirection, freightType);
|
||||
const code = contractTemplateCodeFor(
|
||||
tradeDirection,
|
||||
freightType,
|
||||
customsClearingEnabled,
|
||||
);
|
||||
const template = await this.repository.findByCode(code);
|
||||
return template?.isActive ? template : null;
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@ import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* The six canonical contract document templates, one per
|
||||
* (trade direction × freight type) combination. Contracts store DOMESTIC for
|
||||
* intercity movements; the template layer labels those INTERCITY to match the
|
||||
* commercial vocabulary used on the printed documents.
|
||||
* The ten canonical contract document templates. Import and export split by
|
||||
* customs clearing (× freight type = 8); intercity does not, because it is a
|
||||
* purely domestic Ethiopian movement that crosses no border and therefore has
|
||||
* no customs leg at all (× freight type = 2).
|
||||
*
|
||||
* Contracts store DOMESTIC for intercity movements; the template layer labels
|
||||
* those INTERCITY to match the commercial vocabulary used on the printed
|
||||
* documents.
|
||||
*
|
||||
* The `_CUSTOMS` variant is issued when the contract has customs clearing
|
||||
* enabled (the Service Provider clears in Djibouti/Ethiopia on the Client's
|
||||
* behalf); `_NO_CUSTOMS` is the transport-only paper, where the Client handles
|
||||
* its own declarations.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_CODES = [
|
||||
"IMPORT_BULK",
|
||||
"EXPORT_BULK",
|
||||
"IMPORT_BULK_CUSTOMS",
|
||||
"IMPORT_BULK_NO_CUSTOMS",
|
||||
"EXPORT_BULK_CUSTOMS",
|
||||
"EXPORT_BULK_NO_CUSTOMS",
|
||||
"INTERCITY_BULK",
|
||||
"IMPORT_CONTAINER",
|
||||
"EXPORT_CONTAINER",
|
||||
"IMPORT_CONTAINER_CUSTOMS",
|
||||
"IMPORT_CONTAINER_NO_CUSTOMS",
|
||||
"EXPORT_CONTAINER_CUSTOMS",
|
||||
"EXPORT_CONTAINER_NO_CUSTOMS",
|
||||
"INTERCITY_CONTAINER",
|
||||
] as const;
|
||||
|
||||
@@ -33,10 +46,19 @@ export interface ContractTemplateArticle {
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** Map a contract's stored direction/freight pair onto a template code. */
|
||||
/**
|
||||
* Map a contract's stored direction/freight/customs triple onto a template
|
||||
* code. `customsClearingEnabled` is treated as false when absent so an older
|
||||
* contract row with a null flag still resolves to a real template rather than
|
||||
* falling through to the generic layout.
|
||||
*
|
||||
* Intercity is domestic and has no customs leg, so it resolves to a single
|
||||
* unsuffixed code regardless of the flag.
|
||||
*/
|
||||
export function contractTemplateCodeFor(
|
||||
tradeDirection?: string | null,
|
||||
freightType?: string | null,
|
||||
customsClearingEnabled?: boolean | null,
|
||||
): ContractTemplateCode {
|
||||
const direction =
|
||||
tradeDirection === "IMPORT"
|
||||
@@ -46,7 +68,11 @@ export function contractTemplateCodeFor(
|
||||
: "INTERCITY";
|
||||
const freight =
|
||||
(freightType ?? "").toUpperCase().includes("BULK") ? "BULK" : "CONTAINER";
|
||||
return `${direction}_${freight}` as ContractTemplateCode;
|
||||
if (direction === "INTERCITY") {
|
||||
return `INTERCITY_${freight}` as ContractTemplateCode;
|
||||
}
|
||||
const customs = customsClearingEnabled ? "CUSTOMS" : "NO_CUSTOMS";
|
||||
return `${direction}_${freight}_${customs}` as ContractTemplateCode;
|
||||
}
|
||||
|
||||
@Entity({ schema: "freight", name: "contract_templates" })
|
||||
|
||||
@@ -423,6 +423,7 @@ export class ContractTransitionService {
|
||||
const active = await this.contractTemplates.findActiveForContract(
|
||||
contract.tradeDirection,
|
||||
contract.freightType,
|
||||
contract.customsClearingEnabled,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { UserTradeAccessModule } from '../user-trade-access/user-trade-access.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
|
||||
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
@@ -102,11 +101,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// by ContractBookingService.createUnderContract. forwardRef because
|
||||
// TrainSchedulingModule already imports ContractsModule.
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [ContractsController, GlExchangeController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsNumber, Max, Min } from "class-validator";
|
||||
|
||||
/**
|
||||
* Operator-set USD→ETB fallback. Bounded well outside any plausible published
|
||||
* rate but far short of a fat-fingered magnitude error — this value multiplies
|
||||
* real invoice amounts whenever CBE is unreachable.
|
||||
*/
|
||||
export class UpdateExchangeSettingDto {
|
||||
@IsNumber({ maxDecimalPlaces: 6 })
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
fallbackRate!: number;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
/**
|
||||
* Whether the stored fallback rate was written by the automatic sync (after a
|
||||
* successful CBE fetch) or typed in by an operator in the backoffice.
|
||||
*/
|
||||
export type ExchangeFallbackSource = "AUTO" | "MANUAL";
|
||||
|
||||
/**
|
||||
* Single-row table holding the USD→ETB fallback used when the CBE endpoint is
|
||||
* unreachable. The live CBE rate always wins; this is only consulted on
|
||||
* failure, and is overwritten by every successful fetch so it tracks the last
|
||||
* known good rate.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "exchange_settings" })
|
||||
export class ExchangeSetting extends BaseEntity {
|
||||
/** USD→ETB rate served while the CBE endpoint is failing. */
|
||||
@Column({
|
||||
name: "fallback_rate",
|
||||
type: "numeric",
|
||||
precision: 18,
|
||||
scale: 6,
|
||||
transformer: {
|
||||
to: (value: number) => value,
|
||||
from: (value: string | null) => (value === null ? null : Number(value)),
|
||||
},
|
||||
})
|
||||
fallbackRate!: number;
|
||||
|
||||
/** `AUTO` when written by the sync, `MANUAL` when set in the backoffice. */
|
||||
@Column({
|
||||
name: "fallback_source",
|
||||
type: "varchar",
|
||||
length: 16,
|
||||
default: "AUTO",
|
||||
})
|
||||
fallbackSource!: ExchangeFallbackSource;
|
||||
|
||||
/** When the fallback last changed — i.e. the last successful CBE fetch. */
|
||||
@Column({ name: "last_synced_at", type: "timestamptz", nullable: true })
|
||||
lastSyncedAt?: Date | null;
|
||||
|
||||
/** IAM user id of the last operator to set the rate manually. */
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { DynamicModule } from "@nestjs/common";
|
||||
|
||||
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||
|
||||
/**
|
||||
* The app's single `ExchangeModule` registration shape: CBE endpoint config
|
||||
* from `app.cbeExchange`, with the DB-backed fallback wired in.
|
||||
*
|
||||
* `ExchangeModule` is registered per-feature-module (bookings, contracts,
|
||||
* warehouses), so this keeps the three call sites identical rather than
|
||||
* letting their options drift apart.
|
||||
*/
|
||||
export function registerExchangeModule(): DynamicModule {
|
||||
return ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService, ExchangeSettingsService],
|
||||
useFactory: (
|
||||
config: ConfigService,
|
||||
settings: ExchangeSettingsService,
|
||||
): ExchangeOptions => ({
|
||||
...(config.get<ExchangeOptions>("app.cbeExchange") ?? {}),
|
||||
loadFallbackRate: () => settings.loadFallbackRate(),
|
||||
saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Body, Controller, Get, Patch } from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { CurrentUser, ExchangeService } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { UpdateExchangeSettingDto } from "./dto/update-exchange-setting.dto";
|
||||
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||
|
||||
@ApiTags("exchange-settings")
|
||||
@ApiBearerAuth()
|
||||
@Controller("exchange-settings")
|
||||
export class ExchangeSettingsController {
|
||||
constructor(
|
||||
private readonly service: ExchangeSettingsService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary: "Current USD→ETB fallback rate and CBE feed health",
|
||||
})
|
||||
async get() {
|
||||
const [setting, status] = [
|
||||
await this.service.get(),
|
||||
this.exchangeService.getProviderStatus(),
|
||||
];
|
||||
|
||||
return {
|
||||
fallbackRate: setting.fallbackRate,
|
||||
fallbackSource: setting.fallbackSource,
|
||||
lastSyncedAt: setting.lastSyncedAt,
|
||||
updatedById: setting.updatedById,
|
||||
feed: {
|
||||
rate: status.rate,
|
||||
source: status.source,
|
||||
lastSuccessAt: status.lastSuccessAt
|
||||
? new Date(status.lastSuccessAt).toISOString()
|
||||
: null,
|
||||
lastError: status.lastError,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@FreightAdmin()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Set the USD→ETB fallback by hand (used only while CBE is unreachable)",
|
||||
})
|
||||
async update(
|
||||
@Body() dto: UpdateExchangeSettingDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const updated = await this.service.setManualRate(
|
||||
dto.fallbackRate,
|
||||
user?.id ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
fallbackRate: updated.fallbackRate,
|
||||
fallbackSource: updated.fallbackSource,
|
||||
lastSyncedAt: updated.lastSyncedAt,
|
||||
updatedById: updated.updatedById,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||
import { ExchangeSettingsController } from "./exchange-settings.controller";
|
||||
import { ExchangeSettingsService } from "./exchange-settings.service";
|
||||
|
||||
/**
|
||||
* Global so the several `ExchangeModule.forRootAsync` registrations (bookings,
|
||||
* contracts, warehouses) can inject {@link ExchangeSettingsService} into their
|
||||
* options factory without each importing this module.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ExchangeSetting])],
|
||||
controllers: [ExchangeSettingsController],
|
||||
providers: [ExchangeSettingsService],
|
||||
exports: [ExchangeSettingsService],
|
||||
})
|
||||
export class ExchangeSettingsModule {}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { ExchangeSetting } from "./entities/exchange-setting.entity";
|
||||
|
||||
/**
|
||||
* Rate used before the row exists and before the first successful CBE fetch —
|
||||
* the CBE USD transactional selling rate on 2026-08-04.
|
||||
*/
|
||||
const SEED_FALLBACK_RATE = 162.4165;
|
||||
|
||||
/**
|
||||
* Owns the single `exchange_settings` row: the USD→ETB fallback used when the
|
||||
* CBE endpoint is unreachable.
|
||||
*
|
||||
* 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
|
||||
* rate rather than drifting into a stale constant.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ExchangeSettingsService {
|
||||
private readonly logger = new Logger(ExchangeSettingsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExchangeSetting)
|
||||
private readonly repository: Repository<ExchangeSetting>,
|
||||
) {}
|
||||
|
||||
/** The settings row, created at the seed rate on first access. */
|
||||
async get(): Promise<ExchangeSetting> {
|
||||
const existing = await this.repository.findOne({ where: {} });
|
||||
if (existing) return existing;
|
||||
|
||||
return this.repository.save(
|
||||
this.repository.create({
|
||||
fallbackRate: SEED_FALLBACK_RATE,
|
||||
fallbackSource: "AUTO",
|
||||
lastSyncedAt: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the stored fallback for the exchange provider. Returns `null` on any
|
||||
* failure so the provider falls through to its own static default rather
|
||||
* than propagating a database error into a pricing call.
|
||||
*/
|
||||
async loadFallbackRate(): Promise<number | null> {
|
||||
try {
|
||||
const { fallbackRate } = await this.get();
|
||||
return Number.isFinite(fallbackRate) && fallbackRate > 0
|
||||
? fallbackRate
|
||||
: null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Could not read stored exchange fallback: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a freshly fetched live rate as the new fallback. Marked `AUTO`,
|
||||
* overwriting a manual entry — a manual rate is a stopgap for while CBE is
|
||||
* down, so a working CBE feed takes precedence again.
|
||||
*/
|
||||
async saveFallbackRate(rate: number): Promise<void> {
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, {
|
||||
fallbackRate: rate,
|
||||
fallbackSource: "AUTO",
|
||||
lastSyncedAt: new Date(),
|
||||
updatedById: null,
|
||||
});
|
||||
this.logger.log(`Exchange fallback synced from CBE: ${rate} ETB/USD`);
|
||||
}
|
||||
|
||||
/** Operator sets the fallback by hand, e.g. during a prolonged CBE outage. */
|
||||
async setManualRate(
|
||||
rate: number,
|
||||
updatedById?: string | null,
|
||||
): Promise<ExchangeSetting> {
|
||||
const current = await this.get();
|
||||
await this.repository.update(current.id, {
|
||||
fallbackRate: rate,
|
||||
fallbackSource: "MANUAL",
|
||||
updatedById: updatedById ?? null,
|
||||
});
|
||||
this.logger.warn(
|
||||
`Exchange fallback set manually to ${rate} ETB/USD by ${updatedById ?? "unknown user"}`,
|
||||
);
|
||||
return this.get();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { registerExchangeModule } from '../exchange-settings/exchange-module-options';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { DocumentsModule } from '../billing/documents/documents.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
@@ -78,11 +77,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
NotificationsModule,
|
||||
NotificationInboxModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
registerExchangeModule(),
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { resolve } from 'path';
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppModule } from '../app.module';
|
||||
import { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder';
|
||||
import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder';
|
||||
@@ -14,19 +15,33 @@ import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder';
|
||||
import { PricingDataSeeder } from '../seed/pricing-data.seeder';
|
||||
import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder';
|
||||
|
||||
/** Demo data only — refuse to run against anything but a local dev database. */
|
||||
function assertLocalhost() {
|
||||
const host = process.env.DB_HOST ?? 'localhost';
|
||||
if (host !== 'localhost' && host !== '127.0.0.1') {
|
||||
console.error(`Refusing to seed demo data: DB_HOST is "${host}", not localhost.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertLocalhost();
|
||||
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
await app.get(PricingDataSeeder).run();
|
||||
await app.get(IndodeFacilitySeeder).run();
|
||||
await app.get(Batch14TestDataSeeder).run();
|
||||
await app.get(Batch5TestDataSeeder).run();
|
||||
await app.get(Batch7TestDataSeeder).run();
|
||||
await app.get(Batch8TestDataSeeder).run();
|
||||
await app.get(WarehouseDemoSeeder).run();
|
||||
// Demo seeders are intentionally not AppModule providers (they'd run on every
|
||||
// boot), so construct them against the app's DataSource instead of via DI.
|
||||
const dataSource = app.get(DataSource);
|
||||
await new PricingDataSeeder(dataSource).run();
|
||||
await new IndodeFacilitySeeder(dataSource).run();
|
||||
await new Batch14TestDataSeeder(dataSource).run();
|
||||
await new Batch5TestDataSeeder(dataSource).run();
|
||||
await new Batch7TestDataSeeder(dataSource).run();
|
||||
await new Batch8TestDataSeeder(dataSource).run();
|
||||
await new WarehouseDemoSeeder(dataSource).run();
|
||||
|
||||
console.log('Warehouse demo data seeded.');
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
} from "../../modules/contract-templates/entities/contract-template.entity";
|
||||
|
||||
/**
|
||||
* Default article packs for the six contract templates, transcribed from the
|
||||
* Default article packs for the ten contract templates, transcribed from the
|
||||
* signed EDR contract documents (test/contrat_docs). Article bodies use the
|
||||
* dynamic-article text format: one clause per line, "- " prefix for bullets
|
||||
* nested under the previous clause, single-line body = plain paragraph.
|
||||
@@ -20,6 +20,13 @@ export interface ContractTemplateSeed {
|
||||
articles: Array<Omit<ContractTemplateArticle, "order">>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A base pack keyed by direction/freight only. Each one is transcribed from a
|
||||
* signed EDR contract and is split at the bottom of this file into the
|
||||
* `_CUSTOMS` / `_NO_CUSTOMS` pair the template table actually stores.
|
||||
*/
|
||||
type ContractTemplateBase = Omit<ContractTemplateSeed, "code">;
|
||||
|
||||
const a = (id: string, title: string, body: string): Omit<ContractTemplateArticle, "order"> => ({
|
||||
id,
|
||||
title,
|
||||
@@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit<ContractTemplateArticl
|
||||
|
||||
/* ────────────────────────────── IMPORT / BULK ────────────────────────────── */
|
||||
|
||||
const IMPORT_BULK: ContractTemplateSeed = {
|
||||
code: "IMPORT_BULK",
|
||||
const IMPORT_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Import Contract",
|
||||
description:
|
||||
"Import of bulk cargo (e.g. steel billets) from Djibouti (DMP/Nagad) to Galaan Multipurpose Port with customs clearance and optional last-mile delivery.",
|
||||
@@ -158,8 +164,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ────────────────────────────── EXPORT / BULK ────────────────────────────── */
|
||||
|
||||
const EXPORT_BULK: ContractTemplateSeed = {
|
||||
code: "EXPORT_BULK",
|
||||
const EXPORT_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Export Contract",
|
||||
description:
|
||||
"Export of bulk cargo (e.g. livestock) by railway from Ethiopian loading stations to Nagad railway freight yard, Djibouti.",
|
||||
@@ -294,8 +299,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ──────────────────────────── INTERCITY / BULK ───────────────────────────── */
|
||||
|
||||
const INTERCITY_BULK: ContractTemplateSeed = {
|
||||
code: "INTERCITY_BULK",
|
||||
const INTERCITY_BULK_BASE: ContractTemplateBase = {
|
||||
name: "Bulk Intercity Contract",
|
||||
description:
|
||||
"Domestic (intercity) bulk cargo transportation by railway between Ethiopian freight yards, e.g. Dire Dawa to Sebeta.",
|
||||
@@ -418,8 +422,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
|
||||
/* ──────────────────────────── IMPORT / CONTAINER ─────────────────────────── */
|
||||
|
||||
const IMPORT_CONTAINER: ContractTemplateSeed = {
|
||||
code: "IMPORT_CONTAINER",
|
||||
const IMPORT_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Import Contract",
|
||||
description:
|
||||
"Import container transport by railway from SGTD (Djibouti) to Dire Dawa, Modjo dry port, or Galaan Multipurpose Port, with empty-container return.",
|
||||
@@ -568,8 +571,7 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
|
||||
|
||||
/* ──────────────────────────── EXPORT / CONTAINER ─────────────────────────── */
|
||||
|
||||
const EXPORT_CONTAINER: ContractTemplateSeed = {
|
||||
code: "EXPORT_CONTAINER",
|
||||
const EXPORT_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Export Contract",
|
||||
description:
|
||||
"Export container transport, freight forwarding, and customs clearing from Galaan Multipurpose Port or Modjo dry port to SGTD container freight station (Djibouti).",
|
||||
@@ -717,8 +719,7 @@ The signatories confirm that they are fully authorized to sign and execute this
|
||||
|
||||
/* ─────────────────────────── INTERCITY / CONTAINER ───────────────────────── */
|
||||
|
||||
const INTERCITY_CONTAINER: ContractTemplateSeed = {
|
||||
code: "INTERCITY_CONTAINER",
|
||||
const INTERCITY_CONTAINER_BASE: ContractTemplateBase = {
|
||||
name: "Container Intercity Contract",
|
||||
description:
|
||||
"Domestic (intercity) container transport by railway between Ethiopian terminals — Galaan Multipurpose Port, Modjo dry port, and Dire Dawa — including empty repositioning.",
|
||||
@@ -854,11 +855,67 @@ If unresolved, disputes shall be taken to the Federal Court in Addis Ababa.`,
|
||||
],
|
||||
};
|
||||
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
IMPORT_BULK,
|
||||
EXPORT_BULK,
|
||||
INTERCITY_BULK,
|
||||
IMPORT_CONTAINER,
|
||||
EXPORT_CONTAINER,
|
||||
INTERCITY_CONTAINER,
|
||||
/* ─────────────────────── CUSTOMS / NO-CUSTOMS SPLIT ──────────────────────── */
|
||||
|
||||
/**
|
||||
* Articles appended to the `_CUSTOMS` variant of every base pack. The signed
|
||||
* source documents fold customs duties into the body prose rather than a
|
||||
* dedicated article, so these state the clearing obligations explicitly for the
|
||||
* contracts where EDR clears on the Client's behalf.
|
||||
*/
|
||||
const CUSTOMS_ARTICLES: Array<Omit<ContractTemplateArticle, "order">> = [
|
||||
a(
|
||||
"customs-clearing",
|
||||
"Customs Clearing Services",
|
||||
`The Service Provider shall carry out customs clearing on behalf of the Client for the cargo covered by this Agreement, including declaration, lodgement, and follow-up at the customs stations of Djibouti and Ethiopia as applicable to the agreed corridor.
|
||||
The Service Provider shall act only within the authority granted by the Client and shall not amend a declaration without the Client's written instruction.
|
||||
Customs duties, taxes, and any government charges assessed on the cargo remain payable by the Client and are not included in the freight price; the Service Provider shall settle them on the Client's behalf only where the Client has placed the corresponding funds in advance.
|
||||
The Service Provider shall hand over all customs documents obtained in the course of clearing to the Client upon completion of each shipment.`,
|
||||
),
|
||||
a(
|
||||
"customs-client-duties",
|
||||
"Client Obligations for Customs Clearing",
|
||||
`Grant the Service Provider a duly signed and stamped power of attorney authorising it to act as the Client's customs agent for the duration of this Agreement.
|
||||
Submit every document required for declaration (commercial invoice, packing list, bill of lading or airway bill, permits, certificates of origin, and any authority-specific licence) within one (1) calendar day of the Service Provider's request.
|
||||
Warrant that the declared description, quantity, value, and tariff classification of the cargo are complete and accurate.
|
||||
Bear any penalty, demurrage, storage, or re-inspection cost arising from incorrect, incomplete, or late Client-supplied information or documentation.
|
||||
Settle assessed duties and taxes within the period notified by the Service Provider, failing which the Service Provider may suspend clearing and the cargo shall remain at the Client's risk and cost.`,
|
||||
),
|
||||
];
|
||||
|
||||
/** Build the stored `_CUSTOMS` / `_NO_CUSTOMS` pair for one base pack. */
|
||||
function splitByCustoms(
|
||||
base: ContractTemplateBase,
|
||||
codeStem: string,
|
||||
): ContractTemplateSeed[] {
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
code: `${codeStem}_CUSTOMS` as ContractTemplateCode,
|
||||
name: `${base.name} (with customs clearing)`,
|
||||
description: `${base.description} Customs clearing is performed by the Service Provider.`,
|
||||
articles: [...base.articles, ...CUSTOMS_ARTICLES],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
code: `${codeStem}_NO_CUSTOMS` as ContractTemplateCode,
|
||||
name: `${base.name} (without customs clearing)`,
|
||||
description: `${base.description} Customs clearing is handled by the Client.`,
|
||||
articles: [...base.articles],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ten templates: import and export each split by customs clearing, intercity
|
||||
* not split at all — it is a domestic Ethiopian movement that crosses no
|
||||
* border, so there is no customs leg to contract for.
|
||||
*/
|
||||
export const CONTRACT_TEMPLATE_DEFAULTS: ContractTemplateSeed[] = [
|
||||
...splitByCustoms(IMPORT_BULK_BASE, "IMPORT_BULK"),
|
||||
...splitByCustoms(EXPORT_BULK_BASE, "EXPORT_BULK"),
|
||||
{ ...INTERCITY_BULK_BASE, code: "INTERCITY_BULK" },
|
||||
...splitByCustoms(IMPORT_CONTAINER_BASE, "IMPORT_CONTAINER"),
|
||||
...splitByCustoms(EXPORT_CONTAINER_BASE, "EXPORT_CONTAINER"),
|
||||
{ ...INTERCITY_CONTAINER_BASE, code: "INTERCITY_CONTAINER" },
|
||||
];
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CustomerTruckAssignment } from '../modules/bookings/entities/customer-truck-assignment.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { CompanyProfile } from '../modules/companies/entities/company-profile.entity';
|
||||
import { EmptyContainerReturn } from '../modules/import-operations/entities/empty-container-return.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
@@ -23,6 +25,8 @@ import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.ent
|
||||
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
|
||||
* Import → Unloaded Queue : UNLOADED import inventory
|
||||
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
|
||||
* Import → Import Trucks : a customer self-haul truck assigned to an unloaded booking
|
||||
* Import → Container Returns : empty container returns at two different statuses
|
||||
*
|
||||
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
|
||||
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
|
||||
@@ -166,16 +170,19 @@ export class WarehouseDemoSeeder {
|
||||
}
|
||||
|
||||
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
|
||||
let firstUnloadedBooking: Booking | null = null;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
|
||||
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
|
||||
arrivedAt: ago(90),
|
||||
unloadedAt: ago(45),
|
||||
});
|
||||
firstUnloadedBooking ??= b;
|
||||
created++;
|
||||
}
|
||||
|
||||
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
|
||||
let firstPickupBooking: Booking | null = null;
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
|
||||
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
|
||||
@@ -185,6 +192,50 @@ export class WarehouseDemoSeeder {
|
||||
inspectedAt: ago(120),
|
||||
readyForPickupAt: ago(60),
|
||||
});
|
||||
firstPickupBooking ??= b;
|
||||
created++;
|
||||
}
|
||||
|
||||
// 6) Import Trucks / booking Trucks tab — a customer self-haul truck on the unloaded booking.
|
||||
if (firstUnloadedBooking) {
|
||||
await this.dataSource.getRepository(CustomerTruckAssignment).save(
|
||||
this.dataSource.getRepository(CustomerTruckAssignment).create({
|
||||
bookingId: firstUnloadedBooking.id,
|
||||
plateNumber: 'WH-DEMO-3210',
|
||||
driverName: 'Demo Driver',
|
||||
truckType: 'FLATBED',
|
||||
assignedAt: ago(80),
|
||||
arrivedAt: ago(50),
|
||||
}),
|
||||
);
|
||||
created++;
|
||||
}
|
||||
|
||||
// 7) Container Returns — two empty returns at different stages of the return workflow.
|
||||
if (firstPickupBooking) {
|
||||
const returnRepo = this.dataSource.getRepository(EmptyContainerReturn);
|
||||
await returnRepo.save(
|
||||
returnRepo.create({
|
||||
containerNumber: 'WHDU1234561',
|
||||
bookingId: firstPickupBooking.id,
|
||||
returnDate: ago(20),
|
||||
facility: 'Indode',
|
||||
status: 'RETURNED',
|
||||
returnedBy: 'CUSTOMER',
|
||||
statusHistory: [],
|
||||
}),
|
||||
);
|
||||
await returnRepo.save(
|
||||
returnRepo.create({
|
||||
containerNumber: 'WHDU1234562',
|
||||
bookingId: firstPickupBooking.id,
|
||||
returnDate: ago(90),
|
||||
facility: 'Indode',
|
||||
status: 'DOCUMENTATION_CLEARED',
|
||||
returnedBy: 'CUSTOMER',
|
||||
statusHistory: [],
|
||||
}),
|
||||
);
|
||||
created++;
|
||||
}
|
||||
|
||||
|
||||
@@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({
|
||||
buildInitialValues(fields, initialRecord),
|
||||
);
|
||||
const [position, setPosition] = useState(RULE_ENGINE_POSITION_END);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, initialRecord));
|
||||
setPosition(RULE_ENGINE_POSITION_END);
|
||||
setFieldErrors({});
|
||||
}
|
||||
}, [open, fields, initialRecord]);
|
||||
|
||||
@@ -185,6 +187,9 @@ const RuleEngineFormDialog = ({
|
||||
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
|
||||
|
||||
const setField = (name: string, value: unknown) => {
|
||||
setFieldErrors((current) =>
|
||||
current[name] ? { ...current, [name]: "" } : current,
|
||||
);
|
||||
setValues((current) => {
|
||||
const next = { ...current, [name]: value };
|
||||
// Changing what a rate applies to (or its surcharge trigger) can invalidate
|
||||
@@ -223,6 +228,10 @@ const RuleEngineFormDialog = ({
|
||||
const handleSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const payload: Record<string, unknown> = {};
|
||||
// Required selects that are empty block the submit and mark themselves,
|
||||
// rather than posting an incomplete payload for the API to reject.
|
||||
setFieldErrors({});
|
||||
let blocked = false;
|
||||
|
||||
for (const field of visibleFields) {
|
||||
// Derived fields always submit their computed value — never stale state.
|
||||
@@ -241,7 +250,16 @@ const RuleEngineFormDialog = ({
|
||||
field.type === "select" &&
|
||||
(raw === "" || raw === RULE_ENGINE_SELECT_NONE)
|
||||
) {
|
||||
// A required select left empty must not silently submit nothing — the
|
||||
// API rejects the payload with a message that reads as if the admin
|
||||
// skipped a field they never saw cleared (e.g. yards reset by a trade
|
||||
// direction change). Surface it on the field instead.
|
||||
if (!field.required) continue;
|
||||
setFieldErrors((current) => ({
|
||||
...current,
|
||||
[field.name]: `${field.label} is required.`,
|
||||
}));
|
||||
blocked = true;
|
||||
} else if (raw === "" || raw === undefined) {
|
||||
if (!field.required) continue;
|
||||
payload[field.name] = raw;
|
||||
@@ -254,6 +272,8 @@ const RuleEngineFormDialog = ({
|
||||
payload.code = String(payload.code).toUpperCase();
|
||||
}
|
||||
|
||||
if (blocked) return;
|
||||
|
||||
if (!initialRecord && positionOptions && position !== RULE_ENGINE_POSITION_END) {
|
||||
payload.insertAfterId = position;
|
||||
}
|
||||
@@ -340,10 +360,10 @@ const RuleEngineFormDialog = ({
|
||||
value={resolveSelectValue(field, values)}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
// Native required blocks submit while a mandatory select is empty —
|
||||
// without it the form posts and the API 400s (e.g. a container
|
||||
// customs/lashing rate with no container type picked).
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
error={fieldErrors[field.name] || undefined}
|
||||
data={options
|
||||
.filter((opt) => opt.value !== "")
|
||||
.map((opt) => ({
|
||||
|
||||
@@ -53,6 +53,10 @@ export const URL_CONSTANTS = {
|
||||
NOTIFICATIONS: "/settings/notifications",
|
||||
},
|
||||
|
||||
EXCHANGE_SETTINGS: {
|
||||
BASE: "/exchange-settings",
|
||||
},
|
||||
|
||||
DROPDOWN_SETTINGS: {
|
||||
BASE: "/dropdown-settings",
|
||||
BY_ID: (id: string) => `/api/dropdown-settings/${id}`,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { exchangeSettingsService } from "@/services/exchangeSettings.service";
|
||||
import { useErrorHandler } from "@/shared/hooks/useErrorHandler";
|
||||
|
||||
const QUERY_KEY = ["exchangeSettings"];
|
||||
|
||||
export const useExchangeSettingsQuery = () =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: () => exchangeSettingsService.get(),
|
||||
// Feed health is only interesting while it is being looked at.
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
export const useSetExchangeFallbackRate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { handleError } = useErrorHandler(t);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (rate: number) => exchangeSettingsService.setFallbackRate(rate),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
toast.success(
|
||||
t("exchangeSettings.updated", "Fallback exchange rate updated"),
|
||||
);
|
||||
},
|
||||
onError: handleError,
|
||||
});
|
||||
};
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/common/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import ExchangeRateSettingsCard from "./settings/ExchangeRateSettingsCard";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -77,6 +78,8 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<ExchangeRateSettingsCard />
|
||||
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-semibold">
|
||||
|
||||
@@ -43,8 +43,19 @@ function templateDirection(code: ContractTemplate["code"]): string {
|
||||
return code.split("_")[0];
|
||||
}
|
||||
|
||||
// Codes are DIRECTION_FREIGHT_{CUSTOMS,NO_CUSTOMS}, so the freight segment is
|
||||
// the second one — never the suffix.
|
||||
function isBulk(code: ContractTemplate["code"]): boolean {
|
||||
return code.endsWith("_BULK");
|
||||
return code.split("_")[1] === "BULK";
|
||||
}
|
||||
|
||||
// Intercity is domestic and crosses no border, so it has no customs variant at
|
||||
// all — hence null rather than false, which would wrongly read as a deliberate
|
||||
// "client clears its own customs" choice.
|
||||
function customsVariant(code: ContractTemplate["code"]): boolean | null {
|
||||
if (code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (code.endsWith("_CUSTOMS")) return true;
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
@@ -66,12 +77,12 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The six contract documents generated when a contract is approved — one per trade direction and freight type. Articles are fully editable."
|
||||
subtitle="The ten contract documents generated when a contract is approved — one per trade direction, freight type, and customs-clearing option. Intercity is domestic, so it has no customs variant. Articles are fully editable."
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
|
||||
{isLoading
|
||||
? Array.from({ length: 6 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
? Array.from({ length: 10 }, (_, i) => <TemplateCardSkeleton key={i} />)
|
||||
: (templates ?? []).map((template) => (
|
||||
<TemplateCard
|
||||
key={template.code}
|
||||
@@ -104,6 +115,7 @@ function TemplateCard({
|
||||
}) {
|
||||
const direction = templateDirection(template.code);
|
||||
const bulk = isBulk(template.code);
|
||||
const customs = customsVariant(template.code);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -139,13 +151,29 @@ function TemplateCard({
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{customs !== null && (
|
||||
<Tooltip
|
||||
label={
|
||||
customs
|
||||
? "Used when the contract has customs clearing enabled"
|
||||
: "Used when the client handles its own customs clearing"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
|
||||
{customs ? "With customs" : "No customs"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<Tooltip label="Not used for new contracts" withArrow>
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
Inactive
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Name + description */}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/common/ui/card";
|
||||
import { Input } from "@/shared/common/ui/input";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { AlertTriangle, CheckCircle2, RefreshCw, Save } from "lucide-react";
|
||||
|
||||
import {
|
||||
useExchangeSettingsQuery,
|
||||
useSetExchangeFallbackRate,
|
||||
} from "@/hooks/useExchangeSettings";
|
||||
import type { ExchangeRateSource } from "@/services/exchangeSettings.service";
|
||||
|
||||
/** Feed health, phrased for an operator rather than a developer. */
|
||||
function feedLabel(source: ExchangeRateSource | null): {
|
||||
live: boolean;
|
||||
text: string;
|
||||
} {
|
||||
switch (source) {
|
||||
case "live":
|
||||
return { live: true, text: "CBE reachable — using the live rate" };
|
||||
case "cache":
|
||||
return { live: true, text: "Using the rate cached from CBE" };
|
||||
case "stored":
|
||||
return {
|
||||
live: false,
|
||||
text: "CBE unreachable — using the fallback rate below",
|
||||
};
|
||||
case "default":
|
||||
return {
|
||||
live: false,
|
||||
text: "CBE unreachable and no rate stored — using the built-in default",
|
||||
};
|
||||
default:
|
||||
return { live: true, text: "No rate requested yet since the last restart" };
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (value: string | null) =>
|
||||
value ? new Date(value).toLocaleString() : "never";
|
||||
|
||||
/**
|
||||
* USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable.
|
||||
* The live CBE rate always wins; every successful fetch overwrites the stored
|
||||
* value, so it tracks the last known good rate on its own. Editing here is for
|
||||
* a prolonged outage — the next successful CBE fetch replaces it.
|
||||
*/
|
||||
export default function ExchangeRateSettingsCard() {
|
||||
const { data, isLoading, refetch, isFetching } = useExchangeSettingsQuery();
|
||||
const setRate = useSetExchangeFallbackRate();
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
|
||||
const value = draft !== "" ? draft : (data?.fallbackRate?.toString() ?? "");
|
||||
const parsed = Number(value);
|
||||
const invalid = !Number.isFinite(parsed) || parsed < 1 || parsed > 10_000;
|
||||
const dirty = draft !== "" && parsed !== data?.fallbackRate;
|
||||
|
||||
const feed = feedLabel(data?.feed?.source ?? null);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (invalid) return;
|
||||
await setRate.mutateAsync(parsed);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-gray-200 dark:border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>Exchange rate (USD → ETB)</CardTitle>
|
||||
<CardDescription>
|
||||
Rates come from the Commercial Bank of Ethiopia. The fallback
|
||||
below is used only when CBE cannot be reached, and is refreshed
|
||||
automatically after every successful update.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
className={`flex items-start gap-2 rounded-md border p-3 text-sm ${
|
||||
feed.live
|
||||
? "border-green-200 bg-green-50 text-green-900 dark:border-green-900 dark:bg-green-950 dark:text-green-100"
|
||||
: "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100"
|
||||
}`}
|
||||
>
|
||||
{feed.live ? (
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{feed.text}</p>
|
||||
{data?.feed?.rate != null && (
|
||||
<p>Rate in use: {data.feed.rate} ETB per USD</p>
|
||||
)}
|
||||
<p className="opacity-80">
|
||||
Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)}
|
||||
</p>
|
||||
{data?.feed?.lastError && (
|
||||
<p className="opacity-80">Last error: {data.feed.lastError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="fallback-rate">
|
||||
Fallback rate (ETB per USD)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="fallback-rate"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
min={1}
|
||||
max={10000}
|
||||
className="max-w-[220px]"
|
||||
disabled={isLoading}
|
||||
value={value}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!dirty || invalid || setRate.isPending}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && draft !== "" && (
|
||||
<p className="text-sm text-red-600">
|
||||
Enter a rate between 1 and 10,000.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data?.fallbackSource === "MANUAL"
|
||||
? "Set manually. The next successful CBE update will replace it."
|
||||
: `Synced automatically from CBE (${formatTime(
|
||||
data?.lastSyncedAt ?? null,
|
||||
)}).`}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -18,16 +20,23 @@ import {
|
||||
Checkbox,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, History } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { useListControls } from "@/hooks/useListControls";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { api } from "@/services/api";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import type { EmptyContainerReturnStatus } from "@/types/importOperations";
|
||||
import type {
|
||||
EmptyContainerReturn,
|
||||
EmptyContainerReturnStatus,
|
||||
} from "@/types/importOperations";
|
||||
|
||||
type ReturnType = "all" | "edr" | "customer";
|
||||
|
||||
@@ -51,6 +60,13 @@ const RETURN_STATUS_LABEL: Record<EmptyContainerReturnStatus, string> = {
|
||||
COMPLETED: "Completed",
|
||||
};
|
||||
|
||||
// Fixed series colors (colors follow the entity, never the rank) — pair
|
||||
// validated for CVD separation + surface contrast.
|
||||
const RETURNED_BY_SERIES = [
|
||||
{ key: "edr", label: "EDR Last Mile", color: "#0d9488" },
|
||||
{ key: "customer", label: "Customer Self-Haul", color: "#b45309" },
|
||||
];
|
||||
|
||||
interface ContainerReturnRow {
|
||||
key: string;
|
||||
containerNumber: string;
|
||||
@@ -186,10 +202,47 @@ export default function ContainerReturnsPage() {
|
||||
enabled: bookingIds.length > 0 && !queueLoading,
|
||||
});
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
|
||||
const filteredReturnedContainers = useMemo(() => {
|
||||
if (filterType === "all") return returnedContainers;
|
||||
return returnedContainers.filter((ret: any) => ret.returnedBy === filterType.toUpperCase());
|
||||
}, [returnedContainers, filterType]);
|
||||
let rows = returnedContainers as EmptyContainerReturn[];
|
||||
if (filterType !== "all") {
|
||||
rows = rows.filter((ret) => ret.returnedBy === filterType.toUpperCase());
|
||||
}
|
||||
if (statusFilter) {
|
||||
rows = rows.filter((ret) => ret.status === statusFilter);
|
||||
}
|
||||
return rows;
|
||||
}, [returnedContainers, filterType, statusFilter]);
|
||||
|
||||
const returnedControls = useListControls(filteredReturnedContainers, {
|
||||
dateKey: "returnDate",
|
||||
searchValue: (ret) =>
|
||||
`${ret.containerNumber} ${ret.facility ?? ""} ${ret.yard ?? ""} ${ret.condition ?? ""}`,
|
||||
});
|
||||
|
||||
// Charts read the filtered set, so the controls above drive them too.
|
||||
const returnsPerDay = useMemo(() => {
|
||||
const byDay = new Map<string, { date: string; edr: number; customer: number }>();
|
||||
for (const ret of returnedControls.filteredRows) {
|
||||
const day = toDayString(ret.returnDate);
|
||||
if (!day) continue;
|
||||
const entry = byDay.get(day) ?? { date: day, edr: 0, customer: 0 };
|
||||
if (ret.returnedBy === "CUSTOMER") entry.customer += 1;
|
||||
else entry.edr += 1;
|
||||
byDay.set(day, entry);
|
||||
}
|
||||
return [...byDay.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}, [returnedControls.filteredRows]);
|
||||
|
||||
const returnsByStatus = useMemo(
|
||||
() =>
|
||||
RETURN_STATUS_ORDER.map((status) => ({
|
||||
label: RETURN_STATUS_LABEL[status],
|
||||
value: returnedControls.filteredRows.filter((ret) => ret.status === status).length,
|
||||
})),
|
||||
[returnedControls.filteredRows],
|
||||
);
|
||||
|
||||
const allGroups = useMemo(() => containerReturnsQuery.data ?? [], [containerReturnsQuery.data]);
|
||||
const filteredGroups = useMemo(() => {
|
||||
@@ -271,6 +324,103 @@ export default function ContainerReturnsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const returnedColumns: ColumnDef<EmptyContainerReturn>[] = [
|
||||
{
|
||||
id: "containerNumber",
|
||||
header: "Container Number",
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.containerNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bookingRef",
|
||||
header: "Booking Ref",
|
||||
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||
},
|
||||
{
|
||||
id: "returnedBy",
|
||||
header: "Returned By",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnedBy ? (
|
||||
<Badge size="sm" color={row.original.returnedBy === "EDR" ? "edr-green" : "orange"}>
|
||||
{row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "returnDate",
|
||||
header: "Returned Date",
|
||||
cell: ({ row }) =>
|
||||
row.original.returnDate ? new Date(row.original.returnDate).toLocaleDateString() : "—",
|
||||
},
|
||||
{
|
||||
id: "facility",
|
||||
header: "Facility",
|
||||
cell: ({ row }) => row.original.facility || "—",
|
||||
},
|
||||
{
|
||||
id: "yard",
|
||||
header: "Yard",
|
||||
cell: ({ row }) => row.original.yard || "—",
|
||||
},
|
||||
{
|
||||
id: "condition",
|
||||
header: "Condition",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" lineClamp={2}>
|
||||
{row.original.condition || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm">
|
||||
{RETURN_STATUS_LABEL[row.original.status] ?? row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "Action",
|
||||
cell: ({ row }) => {
|
||||
const ret = row.original;
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setHistoryRow(ret)}
|
||||
title="View status history"
|
||||
>
|
||||
<History size={14} />
|
||||
</ActionIcon>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Done
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const activeGroup = activeKey ? (filteredGroups.find((g) => `${g.returnType.toLowerCase()}-${g.bookingId}` === activeKey) ?? null) : null;
|
||||
|
||||
if (queueLoading || containerReturnsQuery.isLoading) {
|
||||
@@ -305,73 +455,45 @@ export default function ContainerReturnsPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{filteredReturnedContainers.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} mb="xs">Returned Containers</Text>
|
||||
<Table.ScrollContainer minWidth={1000} mb="lg">
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container Number</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Returned Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Condition</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Action</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filteredReturnedContainers.map((ret: any) => {
|
||||
const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1];
|
||||
return (
|
||||
<Table.Tr key={ret.id}>
|
||||
<Table.Td>{ret.containerNumber}</Table.Td>
|
||||
<Table.Td>{ret.bookingId ? "Associated" : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{ret.returnedBy ? (
|
||||
<Badge size="sm" color={ret.returnedBy === "EDR" ? "edr-green" : "blue"}>
|
||||
{ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"}</Table.Td>
|
||||
<Table.Td>{ret.facility || "—"}</Table.Td>
|
||||
<Table.Td>{ret.yard || "—"}</Table.Td>
|
||||
<Table.Td>{ret.condition || "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm">{RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryRow(ret)} title="View status history">
|
||||
<History size={14} />
|
||||
</ActionIcon>
|
||||
{nextStatus ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
loading={advanceStatusMutation.isPending && advanceStatusMutation.variables === ret.id}
|
||||
onClick={() => advanceStatusMutation.mutate(ret.id)}
|
||||
>
|
||||
Advance to {RETURN_STATUS_LABEL[nextStatus]}
|
||||
</Button>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">Done</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</>
|
||||
{returnedContainers.length > 0 && (
|
||||
<Card withBorder radius="lg" p="md" mb="lg">
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Returned Containers</Text>
|
||||
<ListControls
|
||||
search={returnedControls.search}
|
||||
onSearchChange={returnedControls.setSearch}
|
||||
searchPlaceholder="Search container, facility, condition…"
|
||||
dateFrom={returnedControls.dateFrom}
|
||||
onDateFromChange={returnedControls.setDateFrom}
|
||||
dateTo={returnedControls.dateTo}
|
||||
onDateToChange={returnedControls.setDateTo}
|
||||
dateLabel="Returned"
|
||||
hasFilters={returnedControls.hasFilters || Boolean(statusFilter)}
|
||||
onReset={() => {
|
||||
returnedControls.reset();
|
||||
setStatusFilter(null);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
data={RETURN_STATUS_ORDER.map((status) => ({
|
||||
value: status,
|
||||
label: RETURN_STATUS_LABEL[status],
|
||||
}))}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
</ListControls>
|
||||
<DataTable
|
||||
columns={returnedColumns}
|
||||
data={returnedControls.pagedRows}
|
||||
containerClassName="border-0 shadow-none"
|
||||
{...returnedControls.tableProps}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filteredGroups.length === 0 ? (
|
||||
@@ -471,6 +593,26 @@ export default function ContainerReturnsPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="md" mt="lg">
|
||||
<OverviewStackedBarChart
|
||||
title="Returns per day by truck type"
|
||||
data={returnsPerDay}
|
||||
series={RETURNED_BY_SERIES}
|
||||
formatXLabel={(value) =>
|
||||
new Date(value).toLocaleDateString(undefined, { day: "numeric", month: "short" })
|
||||
}
|
||||
emptyMessage="No returns in this range"
|
||||
/>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Returns by status"
|
||||
data={returnsByStatus}
|
||||
valueLabel="Containers"
|
||||
emptyMessage="No returns in this range"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
<ContainerReturnModal
|
||||
opened={returnModalOpen}
|
||||
onClose={() => setReturnModalOpen(false)}
|
||||
|
||||
@@ -11,12 +11,18 @@ export interface ContractTemplateArticle {
|
||||
|
||||
export interface ContractTemplate {
|
||||
id: string;
|
||||
// Import/export split by customs clearing; intercity is domestic, crosses no
|
||||
// border, and so has a single template.
|
||||
code:
|
||||
| "IMPORT_BULK"
|
||||
| "EXPORT_BULK"
|
||||
| "IMPORT_BULK_CUSTOMS"
|
||||
| "IMPORT_BULK_NO_CUSTOMS"
|
||||
| "EXPORT_BULK_CUSTOMS"
|
||||
| "EXPORT_BULK_NO_CUSTOMS"
|
||||
| "INTERCITY_BULK"
|
||||
| "IMPORT_CONTAINER"
|
||||
| "EXPORT_CONTAINER"
|
||||
| "IMPORT_CONTAINER_CUSTOMS"
|
||||
| "IMPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_CUSTOMS"
|
||||
| "EXPORT_CONTAINER_NO_CUSTOMS"
|
||||
| "INTERCITY_CONTAINER";
|
||||
name: string;
|
||||
description?: string | null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api as client } from "../auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
const BASE = URL_CONSTANTS.EXCHANGE_SETTINGS.BASE;
|
||||
|
||||
/** Where the rate the API last served came from. */
|
||||
export type ExchangeRateSource = "live" | "cache" | "stored" | "default";
|
||||
|
||||
/** Health of the CBE exchange-rate feed. */
|
||||
export interface ExchangeFeedStatus {
|
||||
rate: number | null;
|
||||
source: ExchangeRateSource | null;
|
||||
lastSuccessAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ExchangeSettings {
|
||||
fallbackRate: number;
|
||||
/** `AUTO` when synced from CBE, `MANUAL` when set here. */
|
||||
fallbackSource: "AUTO" | "MANUAL";
|
||||
lastSyncedAt: string | null;
|
||||
updatedById: string | null;
|
||||
feed?: ExchangeFeedStatus;
|
||||
}
|
||||
|
||||
export const exchangeSettingsService = {
|
||||
get: async (): Promise<ExchangeSettings> => {
|
||||
const response = await client.get<ApiResponse<ExchangeSettings>>(BASE);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setFallbackRate: async (fallbackRate: number): Promise<ExchangeSettings> => {
|
||||
const response = await client.patch<ApiResponse<ExchangeSettings>>(BASE, {
|
||||
fallbackRate,
|
||||
});
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -265,8 +265,10 @@ function NewShipmentBookingForm({
|
||||
// still flip it per shipment.
|
||||
withReturn: contract.equipmentReturn === "WITH_RETURN",
|
||||
// The contract quotes USD; the customer bills this shipment in the
|
||||
// currency they pick here. Intercity is always ETB.
|
||||
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "USD",
|
||||
// currency they pick here. Intercity is always ETB, so it is preset;
|
||||
// everything else starts empty so the customer picks deliberately
|
||||
// instead of silently inheriting USD.
|
||||
paymentCurrency: contract.tradeDirection === "DOMESTIC" ? "ETB" : "",
|
||||
},
|
||||
resolver: zodResolver(
|
||||
createShipmentFormSchema({
|
||||
@@ -340,7 +342,11 @@ function NewShipmentBookingForm({
|
||||
...(values.contractRouteId
|
||||
? { contractRouteId: values.contractRouteId }
|
||||
: {}),
|
||||
paymentCurrency: values.paymentCurrency,
|
||||
// Validation guarantees a currency by here; the guard keeps an empty
|
||||
// value out of the payload rather than tripping the API's @IsIn check.
|
||||
...(values.paymentCurrency
|
||||
? { paymentCurrency: values.paymentCurrency }
|
||||
: {}),
|
||||
// Intercity bookings carry no date — staff assign a passing train later.
|
||||
...(values.scheduledDate
|
||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||
@@ -1131,23 +1137,32 @@ function ScheduleStep({
|
||||
<Controller
|
||||
name="paymentCurrency"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<Box mb="lg">
|
||||
<StepLabel>Billing currency *</StepLabel>
|
||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||
Your contract is quoted in USD. Pick the currency this shipment is
|
||||
invoiced in — the total is converted for you.
|
||||
</Text>
|
||||
{/* Rendered unselected until the customer chooses: SegmentedControl
|
||||
highlights whatever value it is given, so passing a fallback
|
||||
here would look like a made choice. */}
|
||||
<SegmentedControl
|
||||
value={field.value ?? "USD"}
|
||||
value={field.value || ""}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
data={[
|
||||
{ label: "Select…", value: "", disabled: true },
|
||||
{ label: "USD", value: "USD" },
|
||||
{ label: "ETB", value: "ETB" },
|
||||
]}
|
||||
color="edr-green"
|
||||
radius={10}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<Text fz={12.5} c="red.6" mt={6}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createShipmentFormSchema, initialShipmentFormValues } from "./schema";
|
||||
|
||||
const schema = createShipmentFormSchema({
|
||||
isContainer: false,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
requiresDate: false,
|
||||
});
|
||||
|
||||
const values = (over: Record<string, unknown> = {}) => ({
|
||||
...initialShipmentFormValues,
|
||||
cargoWeightTons: "10",
|
||||
...over,
|
||||
});
|
||||
|
||||
const currencyIssues = (input: Record<string, unknown>) => {
|
||||
const result = schema.safeParse(input);
|
||||
return result.success
|
||||
? []
|
||||
: result.error.issues.filter((i) => i.path[0] === "paymentCurrency");
|
||||
};
|
||||
|
||||
describe("paymentCurrency validation", () => {
|
||||
it("defaults to empty rather than silently picking USD", () => {
|
||||
expect(initialShipmentFormValues.paymentCurrency ?? "").toBe("");
|
||||
});
|
||||
|
||||
it("rejects a submit with no currency chosen", () => {
|
||||
const issues = currencyIssues(values({ paymentCurrency: "" }));
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].message).toBe("Select the billing currency for this shipment.");
|
||||
});
|
||||
|
||||
it("accepts either currency once chosen", () => {
|
||||
expect(currencyIssues(values({ paymentCurrency: "USD" }))).toHaveLength(0);
|
||||
expect(currencyIssues(values({ paymentCurrency: "ETB" }))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -76,8 +76,9 @@ const shipmentFormBase = z.object({
|
||||
// EXPORT rail: the specific train picked for the shipment day (schedule id).
|
||||
trainScheduleId: z.string().default(""),
|
||||
// The contract quotes in USD; the customer picks the billing currency for
|
||||
// THIS shipment. Intercity is forced to ETB (server-enforced too).
|
||||
paymentCurrency: z.enum(["USD", "ETB"]).default("USD"),
|
||||
// THIS shipment. Starts empty so the choice is deliberate — validated as
|
||||
// required below. Intercity is forced to ETB (server-enforced too).
|
||||
paymentCurrency: z.enum(["USD", "ETB", ""]).default(""),
|
||||
// Container contracts only: return the empty container(s) to EDR after
|
||||
// unloading. Seeded from the contract's equipment return; bulk ignores it.
|
||||
withReturn: z.boolean().default(false),
|
||||
@@ -101,6 +102,15 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
||||
});
|
||||
}
|
||||
|
||||
// No default currency — the customer must pick one before submitting.
|
||||
if (!data.paymentCurrency) {
|
||||
refineCtx.addIssue({
|
||||
code: "custom",
|
||||
path: ["paymentCurrency"],
|
||||
message: "Select the billing currency for this shipment.",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.isContainer) {
|
||||
// Containerized cargo must say WHAT is inside — required per booking.
|
||||
if (!data.cargoDescription.trim()) {
|
||||
@@ -311,6 +321,6 @@ export const shipmentStepFields: Record<
|
||||
"bulkReeferQuantity",
|
||||
"withReturn",
|
||||
],
|
||||
2: ["scheduledDate"],
|
||||
2: ["paymentCurrency", "scheduledDate"],
|
||||
3: ["notes"],
|
||||
};
|
||||
|
||||
@@ -1,30 +1,62 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
|
||||
import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options";
|
||||
import {
|
||||
EXCHANGE_DEFAULTS,
|
||||
ExchangeOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
import {
|
||||
CurrencyPair,
|
||||
ExchangeRateProvider,
|
||||
} from "./exchange.types";
|
||||
|
||||
/** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */
|
||||
const USD_RATE_REGEX =
|
||||
/currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/;
|
||||
/** One currency's rates within a daily record returned by the CBE endpoint. */
|
||||
interface CbeExchangeRateEntry {
|
||||
transactionalSelling?: number | string | null;
|
||||
transactionalBuying?: number | string | null;
|
||||
currency?: { CurrencyCode?: string | null } | null;
|
||||
}
|
||||
|
||||
/** A single day's record from the CBE `daily-exchange-rates` endpoint. */
|
||||
interface CbeDailyRecord {
|
||||
Date?: string | null;
|
||||
ExchangeRate?: CbeExchangeRateEntry[] | null;
|
||||
}
|
||||
|
||||
/** Where the most recently served rate came from. */
|
||||
export type CbeRateSource = "live" | "cache" | "stored" | "default";
|
||||
|
||||
/** Health of the CBE feed, for operator-facing status displays. */
|
||||
export interface CbeProviderStatus {
|
||||
/** The rate most recently served, whatever its source. */
|
||||
rate: number | null;
|
||||
/** Where that rate came from. `live` means the API answered. */
|
||||
source: CbeRateSource | null;
|
||||
/** Epoch ms of the last successful live fetch, or `null` if never. */
|
||||
lastSuccessAt: number | null;
|
||||
/** Message from the most recent failed fetch, cleared on success. */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Central Bank of Ethiopia (CBE) rate provider.
|
||||
* Commercial Bank of Ethiopia (CBE) rate provider.
|
||||
*
|
||||
* Sources a single canonical direction — **USD→ETB** (selling rate) — by
|
||||
* scraping ethio.forex, caching the result, and falling back to a configured
|
||||
* rate when the scrape fails. The inverse (ETB→USD) is derived by
|
||||
* {@link ExchangeService}, so this provider only ever reports USD→ETB.
|
||||
* Sources a single canonical direction — **USD→ETB** (transactional selling
|
||||
* rate) — from CBE's public `daily-exchange-rates` JSON endpoint, caching the
|
||||
* result and falling back to a configured rate when the fetch fails. The
|
||||
* inverse (ETB→USD) is derived by {@link ExchangeService}, so this provider
|
||||
* only ever reports USD→ETB.
|
||||
*/
|
||||
export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
readonly name = "CBE";
|
||||
|
||||
private readonly logger = new Logger(CbeExchangeProvider.name);
|
||||
private readonly options: Required<ExchangeOptions>;
|
||||
private readonly options: ResolvedExchangeOptions;
|
||||
private cachedRate: number | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private lastSuccessAt: number | null = null;
|
||||
private lastError: string | null = null;
|
||||
private lastSource: CbeRateSource | null = null;
|
||||
|
||||
constructor(options: ExchangeOptions) {
|
||||
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
|
||||
@@ -38,15 +70,29 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
return this.getUsdToEtbRate();
|
||||
}
|
||||
|
||||
/** Health of the CBE feed — what was served last, and whether it is failing. */
|
||||
getStatus(): CbeProviderStatus {
|
||||
return {
|
||||
rate: this.cachedRate,
|
||||
source: this.lastSource,
|
||||
lastSuccessAt: this.lastSuccessAt,
|
||||
lastError: this.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex.
|
||||
* Cached for `cacheTtlMs`; on failure reuses the last cached rate, else
|
||||
* returns `fallbackRate`.
|
||||
* Returns the current CBE USD→ETB **transactional selling** rate.
|
||||
*
|
||||
* Cached for `cacheTtlMs`. On a successful fetch the rate is written back via
|
||||
* `saveFallbackRate`, so the stored fallback is never more than one good
|
||||
* fetch stale. On failure the chain is: cached rate → `loadFallbackRate()`
|
||||
* → static `fallbackRate`.
|
||||
*/
|
||||
private async getUsdToEtbRate(): Promise<number> {
|
||||
const now = Date.now();
|
||||
|
||||
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
|
||||
this.lastSource = "cache";
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
@@ -56,68 +102,133 @@ export class CbeExchangeProvider implements ExchangeRateProvider {
|
||||
try {
|
||||
const response = await fetch(scrapeUrl, {
|
||||
signal: AbortSignal.timeout(requestTimeoutMs),
|
||||
headers: { "User-Agent": "Mozilla/5.0" },
|
||||
headers: { Accept: "application/json", "User-Agent": "Mozilla/5.0" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`CBE scrape responded with status ${response.status}`);
|
||||
throw new Error(`CBE rates responded with status ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const rates = this.parseScrapedRates(html);
|
||||
const payload = (await response.json()) as unknown;
|
||||
const day = this.latestRecord(payload);
|
||||
|
||||
if (!rates) {
|
||||
throw new Error("USD rate not found in ethio.forex page HTML");
|
||||
if (!day) {
|
||||
throw new Error("CBE rates payload contained no daily record");
|
||||
}
|
||||
|
||||
const rate = rates.selling;
|
||||
if (!Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Invalid selling rate parsed: ${rate}`);
|
||||
const rate = this.parseUsdRate(day);
|
||||
|
||||
if (rate === null) {
|
||||
throw new Error(
|
||||
`USD transactionalSelling not found in CBE record for ${day.Date ?? "unknown date"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previous = this.cachedRate;
|
||||
this.cachedRate = rate;
|
||||
this.cacheExpiresAt = now + cacheTtlMs;
|
||||
this.lastSuccessAt = now;
|
||||
this.lastError = null;
|
||||
this.lastSource = "live";
|
||||
this.logger.log(
|
||||
`CBE USD→ETB rate refreshed from ethio.forex — buying=${rates.buying} selling=${rate}`,
|
||||
);
|
||||
return rate;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Failed to scrape CBE exchange rate — using fallback ${fallbackRate} ETB/USD. Error: ${(err as Error).message}`,
|
||||
`CBE USD→ETB rate refreshed — transactionalSelling=${rate} (date=${day.Date ?? "unknown"})`,
|
||||
);
|
||||
|
||||
// Persist as the new fallback so a later outage reuses the last good
|
||||
// rate. Skipped when unchanged, to avoid pointless writes and audit noise.
|
||||
if (rate !== previous) {
|
||||
await this.persistFallback(rate);
|
||||
}
|
||||
|
||||
return rate;
|
||||
} catch (err) {
|
||||
const message = (err as Error).message;
|
||||
this.lastError = message;
|
||||
this.logger.error(`Failed to fetch CBE exchange rate. Error: ${message}`);
|
||||
|
||||
if (this.cachedRate !== null) {
|
||||
this.lastSource = "cache";
|
||||
this.logger.warn(
|
||||
`Using previously cached CBE rate: ${this.cachedRate}`,
|
||||
);
|
||||
return this.cachedRate;
|
||||
}
|
||||
|
||||
const stored = await this.loadStoredFallback();
|
||||
if (stored !== null) {
|
||||
this.lastSource = "stored";
|
||||
this.logger.warn(`Using stored fallback CBE rate: ${stored}`);
|
||||
return stored;
|
||||
}
|
||||
|
||||
this.lastSource = "default";
|
||||
this.logger.warn(`Using default fallback CBE rate: ${fallbackRate}`);
|
||||
return fallbackRate;
|
||||
}
|
||||
}
|
||||
|
||||
private parseScrapedRates(
|
||||
html: string,
|
||||
): { buying: number; selling: number } | null {
|
||||
const decoded = this.unescapeHtml(html);
|
||||
const match = USD_RATE_REGEX.exec(decoded);
|
||||
if (!match) return null;
|
||||
/**
|
||||
* Writes a freshly fetched rate back as the stored fallback. Failures are
|
||||
* logged and swallowed: persisting the fallback is housekeeping, and must
|
||||
* never fail the pricing call that triggered it.
|
||||
*/
|
||||
private async persistFallback(rate: number): Promise<void> {
|
||||
const { saveFallbackRate } = this.options;
|
||||
if (!saveFallbackRate) return;
|
||||
|
||||
const buying = Number(match[1]);
|
||||
const selling = Number(match[2]);
|
||||
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
|
||||
|
||||
return { buying, selling };
|
||||
try {
|
||||
await saveFallbackRate(rate);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to persist CBE fallback rate ${rate}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private unescapeHtml(html: string): string {
|
||||
return html
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
/**
|
||||
* Reads the persisted fallback. Returns `null` — falling through to the
|
||||
* static default — when unconfigured, unusable, or itself failing.
|
||||
*/
|
||||
private async loadStoredFallback(): Promise<number | null> {
|
||||
const { loadFallbackRate } = this.options;
|
||||
if (!loadFallbackRate) return null;
|
||||
|
||||
try {
|
||||
const stored = await loadFallbackRate();
|
||||
const rate = Number(stored);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to load stored CBE fallback rate: ${(err as Error).message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The endpoint returns an array of daily records (one when `_limit=1`), but
|
||||
* tolerate a bare object in case the shape changes.
|
||||
*/
|
||||
private latestRecord(payload: unknown): CbeDailyRecord | null {
|
||||
const record = Array.isArray(payload) ? payload[0] : payload;
|
||||
return record && typeof record === "object"
|
||||
? (record as CbeDailyRecord)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls USD `transactionalSelling` out of a daily record. Returns `null` when
|
||||
* the entry is missing or the value isn't a usable positive number — CBE
|
||||
* publishes `0`/`null` for currencies it isn't quoting that day.
|
||||
*/
|
||||
private parseUsdRate(day: CbeDailyRecord): number | null {
|
||||
const usd = day.ExchangeRate?.find(
|
||||
(entry) => entry?.currency?.CurrencyCode === "USD",
|
||||
);
|
||||
if (!usd) return null;
|
||||
|
||||
const rate = Number(usd.transactionalSelling);
|
||||
return Number.isFinite(rate) && rate > 0 ? rate : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,18 +4,39 @@ export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS");
|
||||
/** Configuration for the {@link ExchangeService} and its CBE provider. */
|
||||
export interface ExchangeOptions {
|
||||
/**
|
||||
* ethio.forex CBET page scraped for USD buying/selling rates.
|
||||
* @default 'https://ethio.forex/bank/CBET'
|
||||
* CBE daily-exchange-rates JSON endpoint. Returns an array of daily records;
|
||||
* `_limit=1&_sort=Date%3ADESC` narrows it to the most recent day.
|
||||
* @default 'https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC'
|
||||
*/
|
||||
scrapeUrl?: string;
|
||||
|
||||
/**
|
||||
* Base USD→ETB rate used when scraping fails and no previously cached rate
|
||||
* exists. The ETB→USD direction is derived as its inverse.
|
||||
* @default 130
|
||||
* Last-resort USD→ETB rate, used only when the fetch fails, no cached rate
|
||||
* exists, and {@link loadFallbackRate} supplies nothing. The ETB→USD
|
||||
* direction is derived as its inverse.
|
||||
* @default 162
|
||||
*/
|
||||
fallbackRate?: number;
|
||||
|
||||
/**
|
||||
* Reads the persisted fallback rate — the last known good CBE rate, or one
|
||||
* set by an operator. Consulted only when the live fetch fails and no cached
|
||||
* rate is available; a `null` result falls through to {@link fallbackRate}.
|
||||
*
|
||||
* Optional: omit it and the provider uses the static `fallbackRate` alone.
|
||||
*/
|
||||
loadFallbackRate?: () => Promise<number | null>;
|
||||
|
||||
/**
|
||||
* Persists a freshly fetched live rate as the new fallback, so the stored
|
||||
* value is never more than one successful fetch stale. Called after every
|
||||
* successful fetch that produced a changed rate.
|
||||
*
|
||||
* Failures here are logged and swallowed — persisting the fallback must
|
||||
* never break the pricing call that triggered it.
|
||||
*/
|
||||
saveFallbackRate?: (rate: number) => Promise<void>;
|
||||
|
||||
/**
|
||||
* How long a successfully fetched rate is cached, in milliseconds.
|
||||
* @default 3_600_000 (1 hour)
|
||||
@@ -23,16 +44,23 @@ export interface ExchangeOptions {
|
||||
cacheTtlMs?: number;
|
||||
|
||||
/**
|
||||
* Timeout for the scrape HTTP request, in milliseconds.
|
||||
* Timeout for the rate HTTP request, in milliseconds.
|
||||
* @default 8_000
|
||||
*/
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Defaults applied to any unset {@link ExchangeOptions} field. */
|
||||
export const EXCHANGE_DEFAULTS: Required<ExchangeOptions> = {
|
||||
scrapeUrl: "https://ethio.forex/bank/CBET",
|
||||
fallbackRate: 130,
|
||||
/** The scalar options, all resolved — the callbacks stay genuinely optional. */
|
||||
export type ResolvedExchangeOptions = Required<
|
||||
Omit<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">
|
||||
> &
|
||||
Pick<ExchangeOptions, "loadFallbackRate" | "saveFallbackRate">;
|
||||
|
||||
/** Defaults applied to any unset scalar {@link ExchangeOptions} field. */
|
||||
export const EXCHANGE_DEFAULTS: ResolvedExchangeOptions = {
|
||||
scrapeUrl:
|
||||
"https://combanketh.et/cbeapi/daily-exchange-rates/?_limit=1&_sort=Date%3ADESC",
|
||||
fallbackRate: 162,
|
||||
cacheTtlMs: 3_600_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
|
||||
import { CbeExchangeProvider } from "./cbe.provider";
|
||||
import { CbeExchangeProvider, CbeProviderStatus } from "./cbe.provider";
|
||||
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
|
||||
import { CurrencyCode } from "./exchange.types";
|
||||
|
||||
@@ -47,6 +47,14 @@ export class ExchangeService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health of the underlying rate feed — what was served last and whether it
|
||||
* is currently failing. For operator-facing status displays.
|
||||
*/
|
||||
getProviderStatus(): CbeProviderStatus {
|
||||
return this.provider.getStatus();
|
||||
}
|
||||
|
||||
/** Converts `amount` from one currency to another using {@link getRate}. */
|
||||
async convert(
|
||||
amount: number,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
export { ExchangeService } from "./exchange.service";
|
||||
export { ExchangeModule } from "./exchange.module";
|
||||
export { CbeExchangeProvider } from "./cbe.provider";
|
||||
export type { CbeProviderStatus, CbeRateSource } from "./cbe.provider";
|
||||
export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options";
|
||||
export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options";
|
||||
export type {
|
||||
ExchangeOptions,
|
||||
ExchangeAsyncOptions,
|
||||
ResolvedExchangeOptions,
|
||||
} from "./exchange.options";
|
||||
export type {
|
||||
CurrencyCode,
|
||||
CurrencyPair,
|
||||
|
||||
Reference in New Issue
Block a user