Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2

This commit is contained in:
yaschalew
2026-06-23 10:54:20 +03:00
249 changed files with 12254 additions and 3801 deletions

View File

@@ -17,3 +17,6 @@ export * from "./entities/base.entity";
// Repositories
export * from "./repositories/base.repository";
// Services
export * from "./services/exchange";

View File

@@ -0,0 +1,129 @@
import { Logger } from "@nestjs/common";
import { EXCHANGE_DEFAULTS, ExchangeOptions } 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.]+)\]/;
/**
* Central 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.
*/
export class CbeExchangeProvider implements ExchangeRateProvider {
readonly name = "CBE";
private readonly logger = new Logger(CbeExchangeProvider.name);
private readonly options: Required<ExchangeOptions>;
private cachedRate: number | null = null;
private cacheExpiresAt = 0;
constructor(options: ExchangeOptions) {
this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) };
}
async getBaseRate(pair: CurrencyPair): Promise<number | null> {
// CBE only sources USD→ETB; everything else is derived upstream.
if (pair.from !== "USD" || pair.to !== "ETB") {
return null;
}
return this.getUsdToEtbRate();
}
/**
* 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`.
*/
private async getUsdToEtbRate(): Promise<number> {
const now = Date.now();
if (this.cachedRate !== null && now < this.cacheExpiresAt) {
return this.cachedRate;
}
const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } =
this.options;
try {
const response = await fetch(scrapeUrl, {
signal: AbortSignal.timeout(requestTimeoutMs),
headers: { "User-Agent": "Mozilla/5.0" },
});
if (!response.ok) {
throw new Error(`CBE scrape responded with status ${response.status}`);
}
const html = await response.text();
const rates = this.parseScrapedRates(html);
if (!rates) {
throw new Error("USD rate not found in ethio.forex page HTML");
}
const rate = rates.selling;
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error(`Invalid selling rate parsed: ${rate}`);
}
this.cachedRate = rate;
this.cacheExpiresAt = now + cacheTtlMs;
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}`,
);
if (this.cachedRate !== null) {
this.logger.warn(
`Using previously cached CBE rate: ${this.cachedRate}`,
);
return this.cachedRate;
}
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;
const buying = Number(match[1]);
const selling = Number(match[2]);
if (!Number.isFinite(buying) || !Number.isFinite(selling)) return null;
return { buying, selling };
}
private unescapeHtml(html: string): string {
return html
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
}
/** Drops keys whose value is `undefined` so they don't override defaults via spread. */
function stripUndefined(options: ExchangeOptions): ExchangeOptions {
return Object.fromEntries(
Object.entries(options).filter(([, value]) => value !== undefined),
);
}

View File

@@ -0,0 +1,52 @@
import { DynamicModule, Module, Provider } from "@nestjs/common";
import {
EXCHANGE_OPTIONS,
ExchangeAsyncOptions,
ExchangeOptions,
} from "./exchange.options";
import { ExchangeService } from "./exchange.service";
/**
* Provides {@link ExchangeService} (currency conversion, currently CBE-backed).
*
* Register once in the app root, then inject `ExchangeService` anywhere:
*
* ```ts
* // static config
* ExchangeModule.forRoot({ fallbackRate: 135 })
*
* // config resolved from ConfigService
* ExchangeModule.forRootAsync({
* inject: [ConfigService],
* useFactory: (config: ConfigService) => config.get('app.exchange'),
* })
* ```
*/
@Module({})
export class ExchangeModule {
static forRoot(options: ExchangeOptions = {}): DynamicModule {
return {
module: ExchangeModule,
providers: [
{ provide: EXCHANGE_OPTIONS, useValue: options },
ExchangeService,
],
exports: [ExchangeService],
};
}
static forRootAsync(options: ExchangeAsyncOptions): DynamicModule {
const optionsProvider: Provider = {
provide: EXCHANGE_OPTIONS,
useFactory: options.useFactory,
inject: (options.inject ?? []) as never[],
};
return {
module: ExchangeModule,
providers: [optionsProvider, ExchangeService],
exports: [ExchangeService],
};
}
}

View File

@@ -0,0 +1,46 @@
/** Injection token carrying the resolved {@link ExchangeOptions}. */
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'
*/
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
*/
fallbackRate?: number;
/**
* How long a successfully fetched rate is cached, in milliseconds.
* @default 3_600_000 (1 hour)
*/
cacheTtlMs?: number;
/**
* Timeout for the scrape 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,
cacheTtlMs: 3_600_000,
requestTimeoutMs: 8_000,
};
/** Factory contract for {@link ExchangeModule.forRootAsync}. */
export interface ExchangeAsyncOptions {
/** Providers to inject into {@link useFactory} (e.g. `[ConfigService]`). */
inject?: unknown[];
/** Returns the options, possibly async. */
useFactory: (...args: never[]) => ExchangeOptions | Promise<ExchangeOptions>;
}

View File

@@ -0,0 +1,72 @@
import { Inject, Injectable } from "@nestjs/common";
import { CbeExchangeProvider } from "./cbe.provider";
import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options";
import { CurrencyCode } from "./exchange.types";
/**
* Currency exchange service. Resolves the rate between any supported currency
* pair and converts amounts, backed by a rate provider (currently CBE).
*
* Resolution order for `getRate(from, to)`:
* 1. `from === to` → `1`.
* 2. Provider supplies the pair directly (e.g. CBE → USD→ETB).
* 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD).
*
* Configure via {@link ExchangeModule.forRoot} / `forRootAsync`.
*/
@Injectable()
export class ExchangeService {
private readonly provider: CbeExchangeProvider;
constructor(@Inject(EXCHANGE_OPTIONS) options: ExchangeOptions) {
this.provider = new CbeExchangeProvider(options);
}
/**
* Returns the rate to convert 1 unit of `from` into `to`
* (i.e. `amountInTo = amountInFrom * getRate(from, to)`).
*/
async getRate(from: CurrencyCode, to: CurrencyCode): Promise<number> {
if (from === to) {
return 1;
}
const direct = await this.provider.getBaseRate({ from, to });
if (direct !== null) {
return direct;
}
const inverse = await this.provider.getBaseRate({ from: to, to: from });
if (inverse !== null && inverse > 0) {
return 1 / inverse;
}
throw new Error(
`No exchange rate available for ${from}${to} from provider ${this.provider.name}`,
);
}
/** Converts `amount` from one currency to another using {@link getRate}. */
async convert(
amount: number,
from: CurrencyCode,
to: CurrencyCode,
): Promise<number> {
const rate = await this.getRate(from, to);
return amount * rate;
}
/**
* Convenience alias for `getRate('USD', 'ETB')`.
* @deprecated Prefer {@link getRate}; kept for existing callers.
*/
getUsdToEtbRate(): Promise<number> {
return this.getRate("USD", "ETB");
}
/** Convenience alias for `getRate('ETB', 'USD')`. */
getEtbToUsdRate(): Promise<number> {
return this.getRate("ETB", "USD");
}
}

View File

@@ -0,0 +1,31 @@
/**
* ISO-4217 currency codes the exchange service can handle.
* Extend this union as new currencies are supported.
*/
export type CurrencyCode = "USD" | "ETB";
/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */
export interface CurrencyPair {
from: CurrencyCode;
to: CurrencyCode;
}
/**
* A source of base exchange rates. Implementations fetch (scrape/API) the rate
* for a single canonical direction; the {@link ExchangeService} derives the
* inverse and same-currency (1:1) cases on top.
*
* Today the only implementation is the CBE (Central Bank of Ethiopia) provider,
* which sources USD→ETB. New providers (other banks, other base pairs) can be
* added without touching consumers.
*/
export interface ExchangeRateProvider {
/** Human-readable provider name, used in logs (e.g. `'CBE'`). */
readonly name: string;
/**
* Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`),
* or `null` if this provider cannot supply that pair directly.
*/
getBaseRate(pair: CurrencyPair): Promise<number | null>;
}

View File

@@ -0,0 +1,10 @@
export { ExchangeService } from "./exchange.service";
export { ExchangeModule } from "./exchange.module";
export { CbeExchangeProvider } from "./cbe.provider";
export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options";
export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options";
export type {
CurrencyCode,
CurrencyPair,
ExchangeRateProvider,
} from "./exchange.types";

View File

@@ -0,0 +1,79 @@
export interface ETradeAddressInfo {
Region: string;
Zone: string;
Woreda: string;
Kebele: string;
HouseNo: string;
MobilePhone: string;
RegularPhone: string;
}
export interface ETradeAssociateInfo {
Position: string | null;
ManagerName: string;
ManagerNameEng: string;
Photo: string | null;
MobilePhone: string | null;
RegularPhone: string | null;
}
export interface ETradeBusinessInfo {
MainGuid: string;
OwnerTIN: string;
DateRegistered: string;
TradeName: string;
LicenceNumber: string;
Status: number;
StatusDescription: string;
Capital: number;
AssociateShortInfos: ETradeAssociateInfo[];
AddressInfo: ETradeAddressInfo;
RenewedTo: string;
RenewedToDateString: string;
RenewalDate: string;
RenewedFrom: string;
CancellationDate: string | null;
}
export interface ETradeCompanyInfo {
Tin: string;
LegalCondtion: string;
RegNo: string;
RegDate: string;
BusinessName: string;
BusinessNameAmh: string;
PaidUpCapital: number;
AssociateShortInfos: ETradeAssociateInfo[];
Businesses: Array<{
MainGuid: string;
OwnerTIN: string;
DateRegistered: string;
TradeNameAmh: string;
TradesName: string;
LicenceNumber: string;
RenewalDate: string;
RenewedFrom: string;
RenewedTo: string;
BusinessLicensingGroupMain: string | null;
SubGroups: string | null;
}>;
}
export interface CompanyRegistrationData {
licenceNumber: string;
statusDescription: string;
dateRegistered: string;
renewedFrom: string;
renewalDate: string;
renewedTo: string;
region: string;
zone: string;
woreda: string;
kebele: string;
houseNo: string;
mobilePhone: string;
regularPhone: string;
managerName: string;
managerEmail?: string;
managerPhone: string;
}

View File

@@ -3,6 +3,7 @@ import type { BaseEntity } from "../common";
export * from "./dropdown_settings";
export * from "./file_upload_settings";
export * from "./overview";
export * from "./etrade";
export enum TradeDirection {
IMPORT = "IMPORT",
@@ -41,6 +42,26 @@ export enum FreightType {
Bulk = "BULK",
}
/**
* Distinguishes a normal one-time booking from a general contract — an umbrella
* commitment that is signed and paid once, then drawn down by many orders over
* its period. Stored on the booking row.
*/
export enum BookingType {
OneTime = "ONE_TIME",
GeneralContract = "GENERAL_CONTRACT",
}
/**
* How a cargo type's quantity is measured. Bulk cargo is weighed in tons,
* break-bulk is counted per item. Containerised freight is always counted by
* container and carries no unit-of-measure.
*/
export enum CargoUnitOfMeasure {
PerTon = "PER_TON",
PerItem = "PER_ITEM",
}
export enum BookingStatus {
Draft = "DRAFT",
Submitted = "SUBMITTED",
@@ -67,6 +88,10 @@ export enum BookingStatus {
Cancelled = "CANCELLED",
PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED",
/** General contract: paid umbrella contract that is accepting drawdown orders. */
ContractActive = "CONTRACT_ACTIVE",
/** General contract: closed because its quantity was exhausted (or period elapsed). */
ContractClosed = "CONTRACT_CLOSED",
}
export enum ConsignmentStatus {
@@ -328,6 +353,11 @@ export interface IBooking extends BaseEntity {
customerId: string;
trainId?: string | null;
status: BookingStatus;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
bookingType?: BookingType;
/** General contracts only: when ordering closes (null until active / for one-time). */
expiresAt?: string | null;
/** Null for general contracts at creation — the date is chosen per order. */
scheduledDate: string;
totalAmount: number;
paymentStatus: PaymentStatus;
@@ -473,6 +503,8 @@ export interface BookingReferenceCargoTypeChild {
name: string;
code: string;
show_free_text_box: boolean;
/** How this cargo is measured (PER_TON / PER_ITEM); null when unset. */
unit_of_measure?: CargoUnitOfMeasure | null;
}
export interface BookingReferenceCargoTypeGroup {
@@ -553,7 +585,10 @@ export interface CreateBookingDto {
companyId?: string | undefined;
trainId?: string | undefined;
trainScheduleId?: string | undefined;
scheduledDate: string;
/** Optional for general contracts — they pick the date per order, not at creation. */
scheduledDate?: string | undefined;
/** Defaults to ONE_TIME. GENERAL_CONTRACT creates an umbrella contract. */
bookingType?: BookingType | undefined;
contractType: string;
previousContractId?: string | undefined;
serviceTypeId: string;
@@ -577,3 +612,73 @@ export interface CreateBookingDto {
containers?: CreateBookingContainerDto[];
allowConsolidation?: boolean;
}
// ── General Contracts & Booking Orders ──────────────────────────────────────────
/**
* A line of contracted quantity. For CONTAINER contracts there is one line per
* container type (each its own drawdown pool); for BULK/BREAK_BULK a single line
* with a null containerTypeId carries the total tons/items.
*/
export interface ContractQuantityLine {
containerTypeId: string | null;
containerTypeName?: string | null;
/** PER_TON / PER_ITEM for bulk-style lines; null for container lines. */
unitOfMeasure?: CargoUnitOfMeasure | null;
/** Total contracted units on this line (containers, tons, or items). */
contractedQuantity: number;
/** Units already drawn down by non-cancelled orders. */
orderedQuantity: number;
/** contractedQuantity orderedQuantity. */
remainingQuantity: number;
}
/**
* Customer-facing view of a general contract (a Booking with
* bookingType = GENERAL_CONTRACT) and its remaining drawdown pool.
*/
export interface IGeneralContract extends IBooking {
bookingType: BookingType;
/** When the contract becomes ACTIVE; when ordering closes. Null until active. */
expiresAt?: string | null;
/** Per-line contracted / ordered / remaining quantities. */
quantityLines: ContractQuantityLine[];
}
export interface CreateBookingOrderLineDto {
/** Null for bulk/break-bulk; the container type id for container contracts. */
containerTypeId?: string | null;
quantity: number;
}
export interface CreateBookingOrderDto {
/** The general contract (booking) this order draws down from. */
contractBookingId: string;
/** The shipment day the customer wants for this order. */
scheduledDate: string;
lines: CreateBookingOrderLineDto[];
}
export interface IBookingOrderLine {
id: string;
containerTypeId?: string | null;
containerTypeName?: string | null;
quantity: number;
}
export interface IBookingOrder {
id: string;
reference: string;
contractBookingId: string;
/** The child shipment booking spawned for this order (enters scheduling). */
bookingId?: string | null;
bookingReference?: string | null;
companyId?: string | null;
scheduledDate: string;
status: BookingStatus;
schedulingStatus: SchedulingStatus;
trainScheduleId?: string | null;
lines: IBookingOrderLine[];
createdAt: string;
updatedAt: string;
}