diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 0e7949d7e..73e19709e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index 050b07145..8e03ac3f0 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -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), }, })); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index eb9541d8e..e6fcaf8f7 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -119,6 +119,7 @@ export class ContractDocumentViewModelBuilder { const dynamicSource = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); dynamicTemplate = dynamicSource ? { diff --git a/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts new file mode 100644 index 000000000..88e715b05 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3230000000000-SplitContractTemplatesByCustoms.ts @@ -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 `_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 { + // 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 { + 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`], + ); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts new file mode 100644 index 000000000..68ad4f49b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3240000000000-CreateExchangeSettings.ts @@ -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 { + 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 { + await queryRunner.dropTable('freight.exchange_settings', true); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 2063bb01d..84354d860 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -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("app.cbeExchange") ?? {}, - }), + registerExchangeModule(), ], controllers: [BookingsController], providers: [ diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts new file mode 100644 index 000000000..803814967 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-template-code.spec.ts @@ -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); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts index 0478db102..11a838223 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.spec.ts @@ -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()}`); }); }); diff --git a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts index d2755fece..cb956878d 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/contract-templates.service.ts @@ -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 = { - 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 { - const code = contractTemplateCodeFor(tradeDirection, freightType); + const code = contractTemplateCodeFor( + tradeDirection, + freightType, + customsClearingEnabled, + ); const template = await this.repository.findByCode(code); return template?.isActive ? template : null; } diff --git a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts index 73729fbb6..c322c20a4 100644 --- a/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts +++ b/apps/edr-freight-api/src/modules/contract-templates/entities/contract-template.entity.ts @@ -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" }) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 1a4de8245..0a073c9f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -423,6 +423,7 @@ export class ContractTransitionService { const active = await this.contractTemplates.findActiveForContract( contract.tradeDirection, contract.freightType, + contract.customsClearingEnabled, ); if (!active) return null; return { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 0e60462a4..2a07b02e5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -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('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ContractsController, GlExchangeController], providers: [ diff --git a/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts new file mode 100644 index 000000000..98e87007c --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/dto/update-exchange-setting.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts new file mode 100644 index 000000000..1e1f4ad66 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/entities/exchange-setting.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts new file mode 100644 index 000000000..fb126f969 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-module-options.ts @@ -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("app.cbeExchange") ?? {}), + loadFallbackRate: () => settings.loadFallbackRate(), + saveFallbackRate: (rate: number) => settings.saveFallbackRate(rate), + }), + }); +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts new file mode 100644 index 000000000..782a5b6a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.controller.ts @@ -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, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts new file mode 100644 index 000000000..3b536534a --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.module.ts @@ -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 {} diff --git a/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts new file mode 100644 index 000000000..4cccdf8ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/exchange-settings/exchange-settings.service.ts @@ -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, + ) {} + + /** The settings row, created at the seed rate on first access. */ + async get(): Promise { + 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 { + 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 { + 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 { + 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(); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 4011bc14f..d91ac2baf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -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('app.cbeExchange') ?? {}, - }), + registerExchangeModule(), ], controllers: [ WarehousesController, diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts index 24118d882..593abb305 100644 --- a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -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 { diff --git a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts index 01442db07..6255c90fc 100644 --- a/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts +++ b/apps/edr-freight-api/src/seed/data/contract-template-defaults.ts @@ -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>; } +/** + * 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; + const a = (id: string, title: string, body: string): Omit => ({ id, title, @@ -28,8 +35,7 @@ const a = (id: string, title: string, body: string): Omit> = [ + 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" }, ]; diff --git a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts index d8e585a35..d669e0335 100644 --- a/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts +++ b/apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts @@ -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++; } diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 01bb5ceea..49aeb17cd 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -153,11 +153,13 @@ const RuleEngineFormDialog = ({ buildInitialValues(fields, initialRecord), ); const [position, setPosition] = useState(RULE_ENGINE_POSITION_END); + const [fieldErrors, setFieldErrors] = useState>({}); 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 = {}; + // 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) => ({ diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 0e3facead..d8993df04 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -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}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts new file mode 100644 index 000000000..d5fca3c6e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useExchangeSettings.ts @@ -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, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx index 8f4281c14..d005064b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/SettingsPage.tsx @@ -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 (
+ + diff --git a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx index 4a3654aa3..30b31f54d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contract_templates/ContractTemplatesPage.tsx @@ -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() { {isLoading - ? Array.from({ length: 6 }, (_, i) => ) + ? Array.from({ length: 10 }, (_, i) => ) : (templates ?? []).map((template) => ( - {!template.isActive && ( - - - Inactive - - - )} + + {customs !== null && ( + + + {customs ? "With customs" : "No customs"} + + + )} + {!template.isActive && ( + + + Inactive + + + )} + {/* Name + description */} diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx new file mode 100644 index 000000000..47d3e8f89 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -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(""); + + 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 ( + + +
+
+ Exchange rate (USD → ETB) + + 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. + +
+ +
+
+ + +
+ {feed.live ? ( + + ) : ( + + )} +
+

{feed.text}

+ {data?.feed?.rate != null && ( +

Rate in use: {data.feed.rate} ETB per USD

+ )} +

+ Last successful update: {formatTime(data?.feed?.lastSuccessAt ?? null)} +

+ {data?.feed?.lastError && ( +

Last error: {data.feed.lastError}

+ )} +
+
+ +
+ +
+ setDraft(e.target.value)} + /> + +
+ {invalid && draft !== "" && ( +

+ Enter a rate between 1 and 10,000. +

+ )} +

+ {data?.fallbackSource === "MANUAL" + ? "Set manually. The next successful CBE update will replace it." + : `Synced automatically from CBE (${formatTime( + data?.lastSyncedAt ?? null, + )}).`} +

+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index feb4c4815..6f329c8d7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -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 = { 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(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(); + 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[] = [ + { + id: "containerNumber", + header: "Container Number", + cell: ({ row }) => ( + + {row.original.containerNumber} + + ), + }, + { + id: "bookingRef", + header: "Booking Ref", + cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"), + }, + { + id: "returnedBy", + header: "Returned By", + cell: ({ row }) => + row.original.returnedBy ? ( + + {row.original.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} + + ) : ( + "—" + ), + }, + { + 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 }) => ( + + {row.original.condition || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => ( + + {RETURN_STATUS_LABEL[row.original.status] ?? row.original.status} + + ), + }, + { + id: "action", + header: "Action", + cell: ({ row }) => { + const ret = row.original; + const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; + return ( + + setHistoryRow(ret)} + title="View status history" + > + + + {nextStatus ? ( + + ) : ( + + Done + + )} + + ); + }, + }, + ]; + 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() { - {filteredReturnedContainers.length > 0 && ( - <> - Returned Containers - - - - - Container Number - Booking Ref - Returned By - Returned Date - Facility - Yard - Condition - Status - Action - - - - {filteredReturnedContainers.map((ret: any) => { - const nextStatus = RETURN_STATUS_ORDER[RETURN_STATUS_ORDER.indexOf(ret.status) + 1]; - return ( - - {ret.containerNumber} - {ret.bookingId ? "Associated" : "—"} - - {ret.returnedBy ? ( - - {ret.returnedBy === "EDR" ? "EDR Last Mile" : "Customer Self-Haul"} - - ) : ( - "—" - )} - - {ret.returnDate ? new Date(ret.returnDate).toLocaleDateString() : "—"} - {ret.facility || "—"} - {ret.yard || "—"} - {ret.condition || "—"} - - {RETURN_STATUS_LABEL[ret.status as EmptyContainerReturnStatus] ?? ret.status} - - - - setHistoryRow(ret)} title="View status history"> - - - {nextStatus ? ( - - ) : ( - Done - )} - - - - ); - })} - -
-
- + {returnedContainers.length > 0 && ( + + + Returned Containers + { + returnedControls.reset(); + setStatusFilter(null); + }} + > +