merge conflict fix

This commit is contained in:
hagiye
2026-06-24 12:31:59 +03:00
494 changed files with 21885 additions and 14336 deletions

8
.gitignore vendored
View File

@@ -24,11 +24,3 @@ coverage/
.idea/
.vscode/
.npmrc
# emacs cache files
*~
\#*\#
.\#*
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat

6
.gitmodules vendored
View File

@@ -1,6 +0,0 @@
[submodule "user-management"]
path = user-management
url = git@github.com:Tria-plc/iamui.git
[submodule "apps/edr-freight-web/backoffice/user-management"]
path = apps/edr-freight-web/backoffice/user-management
url = git@github.com:Tria-plc/iamui.git

View File

View File

@@ -17,6 +17,7 @@
"type-check": "tsc --noEmit",
"seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts",
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh"
},
"dependencies": {
@@ -35,8 +36,8 @@
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.2",
"@nestjs/typeorm": "^11.0.1",
"@tria-plc/api-common": "^1.4.3",
"@tria-plc/iamapi-common": "^0.6.6",
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.16.1",
@@ -44,13 +45,13 @@
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"handlebars": "^4.7.9",
"libphonenumber-js": "^1.13.6",
"minio": "7.1.3",
"pg": "^8.13.0",
"puppeteer": "^24.2.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.30"
},
"devDependencies": {
"@edr/api-common": "workspace:*",

View File

@@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config";
import rabbitmqConfig from "./config/rabbitmq.config";
import { BookingsModule } from "./modules/bookings/bookings.module";
import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module";
import { SignaturesModule } from "./modules/signatures/signatures.module";
import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
@@ -62,6 +63,9 @@ import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module';
@Module({
imports: [
@@ -90,6 +94,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
permissions: EDR_FREIGHT_PERMISSIONS,
}),
BookingsModule,
BookingOrdersModule,
SignaturesModule,
FilesModule,
ConsignmentsModule,
@@ -120,6 +125,9 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
WarehousesModule,
OverviewModule,
VehiclesModule,
DriversModule,
FirstMileModule,
LastMileModule,
],
providers: [
EdrOrgSeeder,

View File

@@ -0,0 +1,53 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js';
/**
* Country-aware phone validation. The value is expected as a full international
* number (E.164, e.g. "+251911223344"), so the country is derived from the
* value itself — no separate country field needed.
*/
@ValidatorConstraint({ name: 'IsValidPhone', async: false })
export class IsValidPhoneConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
// Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed.
if (value === undefined || value === null || value === '') return true;
if (typeof value !== 'string') return false;
return isValidPhoneNumber(value);
}
defaultMessage(args: ValidationArguments): string {
return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`;
}
}
/** Class-validator decorator wrapping the country-aware phone constraint. */
export function IsValidPhone(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options: validationOptions,
constraints: [],
validator: IsValidPhoneConstraint,
});
};
}
/**
* Normalize a phone string to canonical E.164. Returns the canonical form when
* parseable, otherwise the trimmed original (tolerant — never throws), or the
* value unchanged when empty/nullish.
*/
export function normalizeE164(
value: string | null | undefined,
): string | null | undefined {
if (value === undefined || value === null || value === '') return value;
const parsed = parsePhoneNumberFromString(value);
return parsed?.isValid() ? parsed.number : value.trim();
}

View File

@@ -14,17 +14,13 @@ export default registerAs("app", () => ({
maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760),
maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53),
},
// Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts).
cbeExchange: {
/** ethio.forex CBET page — scraped for USD buying/selling rates. */
scrapeUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
/** @deprecated use scrapeUrl — kept for backward-compatible config reads */
apiUrl:
process.env.CBE_EXCHANGE_SCRAPE_URL ??
process.env.CBE_EXCHANGE_API_URL ??
"https://ethio.forex/bank/CBET",
fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130),
cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000),
},

View File

@@ -0,0 +1,67 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddActiveModeAndOnboardingToExternalProfiles1791000000000
implements MigrationInterface
{
name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS active_profile_type varchar(32);
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS onboarding_step varchar(40);
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false;
`);
// Existing users already use the portal — never re-gate them behind the
// new onboarding wizard.
await queryRunner.query(`
UPDATE freight.external_profiles
SET onboarding_completed = true
WHERE onboarding_completed = false;
`);
// Backfill the active mode for existing users from their company's
// operational profiles. Prefer importer, then exporter, then whichever
// single profile the company has (forwarder/dj/transporter).
await queryRunner.query(`
UPDATE freight.external_profiles ep
SET active_profile_type = cp.type
FROM (
SELECT DISTINCT ON (company_id) company_id, type
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE ep.company_id = cp.company_id
AND ep.active_profile_type IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS onboarding_completed;
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS onboarding_step;
`);
await queryRunner.query(`
ALTER TABLE freight.external_profiles
DROP COLUMN IF EXISTS active_profile_type;
`);
}
}

View File

@@ -0,0 +1,94 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCompanyProfileIdToBookings1791000000001
implements MigrationInterface
{
name = 'AddCompanyProfileIdToBookings1791000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS company_profile_id UUID;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id
ON freight.bookings(company_profile_id);
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id'
) THEN
ALTER TABLE freight.bookings
ADD CONSTRAINT "FK_bookings_company_profile_id"
FOREIGN KEY (company_profile_id)
REFERENCES freight.company_profiles(id);
END IF;
END $$;
`);
// Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter
// profile, for each booking's own company.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM freight.company_profiles cp
WHERE cp.company_id = b.company_id
AND b.company_profile_id IS NULL
AND (
(b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR
(b.trade_direction = 'EXPORT' AND cp.type = 'exporter')
);
`);
// Forwarder / single-profile companies: one profile per company, so the
// mapping is unambiguous regardless of trade direction.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM freight.company_profiles cp
JOIN freight.companies c ON c.id = cp.company_id
WHERE cp.company_id = b.company_id
AND c.type <> 'customer'
AND b.company_profile_id IS NULL;
`);
// Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no
// matching profile): attribute to the company's importer profile, else its
// exporter profile, so nothing disappears from the customer's list.
await queryRunner.query(`
UPDATE freight.bookings b
SET company_profile_id = cp.id
FROM (
SELECT DISTINCT ON (company_id) company_id, id
FROM freight.company_profiles
ORDER BY company_id,
CASE type
WHEN 'importer' THEN 0
WHEN 'exporter' THEN 1
ELSE 2
END
) cp
WHERE cp.company_id = b.company_id
AND b.company_id IS NOT NULL
AND b.company_profile_id IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id";
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id;
`);
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS company_profile_id;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddNationalityToCompanies1791000000002
implements MigrationInterface
{
name = "AddNationalityToCompanies1791000000002";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS nationality varchar(32);
`);
// Existing companies default to Ethiopian (country defaults to Ethiopia).
await queryRunner.query(`
UPDATE freight.companies
SET nationality = 'ethiopian'
WHERE nationality IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS nationality;
`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
implements MigrationInterface
{
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.company_profiles
DROP COLUMN IF EXISTS business_license_files;
`);
}
}

View File

@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddETradeFieldsToCompanies1791000000003
implements MigrationInterface
{
name = "AddETradeFieldsToCompanies1791000000003";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS licence_number varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS status_description text;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS date_registered varchar(50);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS renewed_from varchar(50);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS renewal_date varchar(50);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS renewed_to varchar(50);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS region varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS zone varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS woreda varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS kebele varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS house_no varchar(100);
`);
await queryRunner.query(`
ALTER TABLE freight.companies
ADD COLUMN IF NOT EXISTS etrade_phone varchar(20);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS licence_number;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS status_description;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS date_registered;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS renewed_from;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS renewal_date;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS renewed_to;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS region;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS zone;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS woreda;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS kebele;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS house_no;
`);
await queryRunner.query(`
ALTER TABLE freight.companies
DROP COLUMN IF EXISTS etrade_phone;
`);
}
}

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner, TableColumn, TableForeignKey } from 'typeorm';
/**
* Fix: migration 1750000000001 (AddFacilityIdToWarehouses) silently skipped because
* the freight.warehouses table didn't exist yet at that timestamp. The column was
* never added. Add it now with idempotent guards.
*/
export class AddFacilityIdToWarehousesFix1791000000004 implements MigrationInterface {
private readonly table = 'freight.warehouses';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn(this.table, 'facility_id'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({
name: 'facility_id',
type: 'uuid',
isNullable: true,
}),
);
}
const table = await queryRunner.getTable(this.table);
const hasFk = table?.foreignKeys.some((fk) => fk.columnNames.includes('facility_id'));
if (!hasFk) {
await queryRunner.createForeignKey(
this.table,
new TableForeignKey({
columnNames: ['facility_id'],
referencedColumnNames: ['id'],
referencedTableName: 'facilities',
referencedSchema: 'freight',
onDelete: 'SET NULL',
}),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable(this.table);
if (!table) return;
const foreignKey = table.foreignKeys.find((fk) => fk.columnNames.includes('facility_id'));
if (foreignKey) {
await queryRunner.dropForeignKey(this.table, foreignKey);
}
if (await queryRunner.hasColumn(this.table, 'facility_id')) {
await queryRunner.dropColumn(this.table, 'facility_id');
}
}
}

View File

@@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Creates the generic dropdown settings tables (freight.dropdown_settings +
* freight.dropdown_options) backing the DropdownSetting / DropdownOption
* entities. These tables previously only existed via `synchronize` on some
* databases; this migration makes them part of the migration history so the
* SeedGeneralContractPeriod migration (which inserts into them) can run on a
* fresh database. Idempotent so it is safe on DBs where the tables already exist.
*/
export class CreateDropdownSettings1791999999999
implements MigrationInterface
{
name = 'CreateDropdownSettings1791999999999';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."dropdown_settings" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"code" varchar(128) NOT NULL,
"label" varchar(256) NOT NULL,
"description" text,
"multiple" boolean NOT NULL DEFAULT false,
"meta" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
CONSTRAINT "PK_dropdown_settings" PRIMARY KEY ("id")
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_settings_code"
ON "freight"."dropdown_settings" ("code");
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."dropdown_options" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"setting_id" uuid NOT NULL,
"value" varchar(256) NOT NULL,
"label" varchar(256) NOT NULL,
"note" text,
"is_disabled" boolean NOT NULL DEFAULT false,
"display_order" integer NOT NULL DEFAULT 0,
"meta" jsonb,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
CONSTRAINT "PK_dropdown_options" PRIMARY KEY ("id"),
CONSTRAINT "FK_dropdown_options_setting" FOREIGN KEY ("setting_id")
REFERENCES "freight"."dropdown_settings" ("id") ON DELETE CASCADE
);
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dropdown_options_setting_value"
ON "freight"."dropdown_options" ("setting_id", "value");
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."dropdown_options";`,
);
await queryRunner.query(
`DROP TABLE IF EXISTS "freight"."dropdown_settings";`,
);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUnitOfMeasureToCargoTypes1792000000000
implements MigrationInterface
{
name = 'AddUnitOfMeasureToCargoTypes1792000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`,
);
}
}

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBookingTypeAndContractFields1792000000001
implements MigrationInterface
{
name = 'AddBookingTypeAndContractFields1792000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`,
);
// General contracts have no shipment date at creation — relax the NOT NULL.
await queryRunner.query(
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`,
);
// Reinstate NOT NULL only if no null rows exist (general contracts would block it).
await queryRunner.query(
`ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`,
);
}
}

View File

@@ -0,0 +1,74 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateBookingOrders1792000000002 implements MigrationInterface {
name = 'CreateBookingOrders1792000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_orders',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'reference', type: 'varchar', length: '64', isUnique: true },
{ name: 'contract_booking_id', type: 'uuid' },
{ name: 'booking_id', type: 'uuid', isNullable: true },
{ name: 'company_id', type: 'uuid', isNullable: true },
{ name: 'scheduled_date', type: 'timestamptz' },
{ name: 'status', type: 'varchar', length: '40', default: "'PAID'" },
{ name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" },
{ name: 'train_schedule_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.createIndex(
'freight.booking_orders',
new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }),
);
await queryRunner.createIndex(
'freight.booking_orders',
new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }),
);
await queryRunner.createTable(
new Table({
schema: 'freight',
name: 'booking_order_lines',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
{ name: 'order_id', type: 'uuid' },
{ name: 'container_type_id', type: 'uuid', isNullable: true },
{ name: 'quantity', type: 'numeric', precision: 12, scale: 3 },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
foreignKeys: [
{
columnNames: ['order_id'],
referencedSchema: 'freight',
referencedTableName: 'booking_orders',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
await queryRunner.createIndex(
'freight.booking_order_lines',
new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('freight.booking_order_lines', true);
await queryRunner.dropTable('freight.booking_orders', true);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Seeds the global "general contract period" setting (months). Stored as a
* dropdown_settings row with a single option whose `value` holds the month count
* so backoffice can manage it through the existing settings UI later.
*/
export class SeedGeneralContractPeriod1792000000003
implements MigrationInterface
{
name = 'SeedGeneralContractPeriod1792000000003';
private readonly code = 'general_contract_period';
public async up(queryRunner: QueryRunner): Promise<void> {
const existing = await queryRunner.query(
`SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`,
[this.code],
);
if (existing.length > 0) return;
const inserted = await queryRunner.query(
`INSERT INTO freight.dropdown_settings (code, label, description, multiple)
VALUES ($1, $2, $3, false)
RETURNING id;`,
[
this.code,
'General Contract Period (months)',
'How many months a general contract stays open for ordering after activation.',
],
);
const settingId = inserted[0].id;
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, 0);`,
[settingId, '3', '3 months'],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.dropdown_settings WHERE code = $1;`,
[this.code],
);
}
}

View File

@@ -0,0 +1,50 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Add driver assignment fields to vehicles table
*/
export class AddVehicleDriverAssignment1800000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const vehiclesTable = await queryRunner.getTable('freight.vehicles');
if (vehiclesTable) {
const hasAssignedDriverId = vehiclesTable.columns.some((col) => col.name === 'assigned_driver_id');
if (!hasAssignedDriverId) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({
name: 'assigned_driver_id',
type: 'uuid',
isNullable: true,
}),
);
}
const hasAssignedDriverName = vehiclesTable.columns.some((col) => col.name === 'assigned_driver_name');
if (!hasAssignedDriverName) {
await queryRunner.addColumn(
'freight.vehicles',
new TableColumn({
name: 'assigned_driver_name',
type: 'varchar',
isNullable: true,
}),
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const vehiclesTable = await queryRunner.getTable('freight.vehicles');
if (vehiclesTable) {
const hasAssignedDriverId = vehiclesTable.columns.some((col) => col.name === 'assigned_driver_id');
if (hasAssignedDriverId) {
await queryRunner.dropColumn('freight.vehicles', 'assigned_driver_id');
}
const hasAssignedDriverName = vehiclesTable.columns.some((col) => col.name === 'assigned_driver_name');
if (hasAssignedDriverName) {
await queryRunner.dropColumn('freight.vehicles', 'assigned_driver_name');
}
}
}
}

View File

@@ -0,0 +1,106 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.first_mile table — one row per booking's first-mile
* (door → terminal) leg, with payment split and an optional assigned vehicle.
*/
export class CreateFirstMile1810000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.first_mile',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'booking_id', type: 'uuid', isNullable: false },
{
name: 'status',
type: 'varchar',
length: '30',
default: `'PAYMENT_PENDING'`,
isNullable: false,
},
{
name: 'advanced_payment',
type: 'numeric',
precision: 14,
scale: 2,
default: 0,
isNullable: false,
},
{
name: 'remaining_payment',
type: 'numeric',
precision: 14,
scale: 2,
default: 0,
isNullable: false,
},
{
name: 'estimated_km',
type: 'numeric',
precision: 10,
scale: 2,
isNullable: true,
},
{
name: 'exact_km',
type: 'numeric',
precision: 10,
scale: 2,
isNullable: true,
},
{ name: 'vehicle_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.createForeignKey(
'freight.first_mile',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.first_mile',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_booking_id" ON "freight"."first_mile" ("booking_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_status" ON "freight"."first_mile" ("status")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_first_mile_vehicle_id" ON "freight"."first_mile" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.first_mile');
if (exists) {
await queryRunner.dropTable('freight.first_mile');
}
}
}

View File

@@ -0,0 +1,106 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create the freight.last_mile table — one row per booking's last-mile
* (terminal → door) leg, with payment split and an optional assigned vehicle.
*/
export class CreateLastMile1810000000001 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.last_mile',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'gen_random_uuid()',
},
{ name: 'booking_id', type: 'uuid', isNullable: false },
{
name: 'status',
type: 'varchar',
length: '30',
default: `'PAYMENT_PENDING'`,
isNullable: false,
},
{
name: 'advanced_payment',
type: 'numeric',
precision: 14,
scale: 2,
default: 0,
isNullable: false,
},
{
name: 'remaining_payment',
type: 'numeric',
precision: 14,
scale: 2,
default: 0,
isNullable: false,
},
{
name: 'estimated_km',
type: 'numeric',
precision: 10,
scale: 2,
isNullable: true,
},
{
name: 'exact_km',
type: 'numeric',
precision: 10,
scale: 2,
isNullable: true,
},
{ name: 'vehicle_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.createForeignKey(
'freight.last_mile',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile',
new TableForeignKey({
columnNames: ['vehicle_id'],
referencedTableName: 'freight.vehicles',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_booking_id" ON "freight"."last_mile" ("booking_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_status" ON "freight"."last_mile" ("status")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_last_mile_vehicle_id" ON "freight"."last_mile" ("vehicle_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile');
if (exists) {
await queryRunner.dropTable('freight.last_mile');
}
}
}

View File

@@ -0,0 +1,53 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingOrdersService } from './booking-orders.service';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { GeneralContractService } from './general-contract.service';
@ApiTags('Booking Orders')
@Controller('booking-orders')
export class BookingOrdersController {
constructor(
private readonly ordersService: BookingOrdersService,
private readonly generalContractService: GeneralContractService,
) {}
@Post()
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
async create(
@Body() dto: CreateBookingOrderDto,
@CurrentUser() user: TCurrentUser,
) {
return this.ordersService.create(dto, user?.id);
}
@Get()
@ApiOperation({ summary: 'List orders placed against a contract' })
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
return this.ordersService.listByContract(contractBookingId);
}
@Get('contract/:id/pool')
@ApiOperation({
summary: 'Contracted / ordered / remaining quantities for a general contract',
})
async pool(@Param('id', ParseUUIDPipe) id: string) {
return this.generalContractService.getQuantityLines(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single booking order' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.ordersService.findById(id);
}
}

View File

@@ -0,0 +1,30 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { CompaniesModule } from '../companies/companies.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { BookingOrdersController } from './booking-orders.controller';
import { BookingOrdersRepository } from './booking-orders.repository';
import { BookingOrdersService } from './booking-orders.service';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
@Module({
imports: [
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
BookingsModule,
CompaniesModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
],
controllers: [BookingOrdersController],
providers: [
BookingOrdersService,
BookingOrdersRepository,
GeneralContractService,
],
exports: [BookingOrdersService, GeneralContractService],
})
export class BookingOrdersModule {}

View File

@@ -0,0 +1,41 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BookingOrder } from './entities/booking-order.entity';
@Injectable()
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
constructor(
@InjectRepository(BookingOrder)
repository: Repository<BookingOrder>,
) {
super(repository);
}
/** Orders placed against a given contract, newest first, with their lines. */
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.repository.find({
where: { contractBookingId },
relations: { lines: { containerType: true }, booking: true },
order: { createdAt: 'DESC' },
});
}
override findById(id: string): Promise<BookingOrder | null> {
return this.repository.findOne({
where: { id },
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
});
}
/** Count this calendar year's orders, for reference generation. */
async countByYear(year: number): Promise<number> {
const start = new Date(Date.UTC(year, 0, 1));
const end = new Date(Date.UTC(year + 1, 0, 1));
return this.repository
.createQueryBuilder('o')
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
.getCount();
}
}

View File

@@ -0,0 +1,293 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
@Injectable()
export class BookingOrdersService {
private readonly logger = new Logger(BookingOrdersService.name);
constructor(
private readonly dataSource: DataSource,
private readonly ordersRepository: BookingOrdersRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
}
/**
* Place a drawdown order against an ACTIVE general contract.
*
* Validates the requested quantities against the remaining pool, then spawns a
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
* route/cargo/service) so it flows through the existing train-scheduling
* pipeline. The order row is the ledger entry linking contract → child booking.
*/
async create(
dto: CreateBookingOrderDto,
userId?: string,
): Promise<BookingOrder> {
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
if (!contract) {
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
}
if (!this.generalContractService.isGeneralContract(contract)) {
throw new BadRequestException('Booking is not a general contract');
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
);
}
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Contract ordering window has expired');
}
// The customer placing the order must own the contract.
if (userId && !(await this.userOwnsContract(userId, contract))) {
throw new BadRequestException('You do not have access to this contract');
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
contract.originYardId,
contract.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
// Validate each line against the remaining pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
const isContainer = contract.freightType === 'CONTAINER';
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(contract, dto, manager);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
status: 'PAID',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
const lines = dto.lines.map((l) =>
manager.create(BookingOrderLine, {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// Feed the child booking into the day-pool batch so it allocates to a train.
try {
await this.bookingBatchService.processRouteDay({
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
day,
});
} catch (err) {
this.logger.error(
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Close the contract once its pool is exhausted.
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
.update(contract.id, { status: 'CONTRACT_CLOSED' });
this.logger.log(
`Contract ${contract.reference} CLOSED — quantity exhausted`,
);
}
return (await this.ordersRepository.findById(order.id)) ?? order;
}
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const now = new Date();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
const containerByType = new Map(
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
);
let totalWeight = 0;
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
totalWeight += vgmPerUnit * line.quantity;
}
} else {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
const child = manager.create(Booking, {
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,
previousContractId: contract.id,
serviceTypeId: contract.serviceTypeId,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: contract.isHazardous,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Already covered by the contract's one-time payment: enter the pool ready
// and paid so the batch engine reserves → allocates it immediately.
status: 'FULLY_EXECUTED',
paymentStatus: 'PAID',
fullyExecutedAt: now,
customerSignedAt: now,
priorityScore: contract.priorityScore,
totalAmount: 0,
allowConsolidation: false,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const ct = line.containerTypeId
? await manager.getRepository(ContainerType).findOne({
where: { id: line.containerTypeId },
})
: null;
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
const row = manager.create(BookingContainer, {
bookingId: savedChild.id,
containerTypeId: line.containerTypeId ?? null,
quantity: line.quantity,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
isOverweight: false,
});
await manager.save(row);
}
}
return savedChild;
}
private async userOwnsContract(
userId: string,
contract: Booking,
): Promise<boolean> {
if (!contract.companyId) return true; // government / staff-created
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(
userId,
);
return company.id === contract.companyId;
} catch {
return false;
}
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.ordersRepository.countByYear(year);
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private async generateChildBookingReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
/** A single contracted/ordered/remaining pool line for a general contract. */
export class ContractQuantityLineView {
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
containerTypeId!: string | null;
@ApiProperty({ nullable: true })
containerTypeName!: string | null;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
unitOfMeasure!: CargoUnitOfMeasure | null;
@ApiProperty()
contractedQuantity!: number;
@ApiProperty()
orderedQuantity!: number;
@ApiProperty()
remainingQuantity!: number;
}

View File

@@ -0,0 +1,45 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
export class CreateBookingOrderLineDto {
@ApiPropertyOptional({
format: 'uuid',
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
})
@IsOptional()
@IsUUID()
containerTypeId?: string;
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
quantity!: number;
}
export class CreateBookingOrderDto {
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
@IsUUID()
contractBookingId!: string;
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ type: [CreateBookingOrderLineDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingOrderLineDto)
lines!: CreateBookingOrderLineDto[];
}

View File

@@ -0,0 +1,30 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
import { BookingOrder } from './booking-order.entity';
/**
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
* single line with a null containerTypeId carries the tons/items.
*/
@Entity({ schema: 'freight', name: 'booking_order_lines' })
export class BookingOrderLine extends BaseEntity {
@Column({ name: 'order_id', type: 'uuid' })
orderId!: string;
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'order_id' })
order?: BookingOrder;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType | null;
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
quantity!: number;
}

View File

@@ -0,0 +1,62 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Company } from '../../companies/entities/company.entity';
import { BookingOrderLine } from './booking-order-line.entity';
/**
* A single drawdown against a general contract. Each order spawns its own
* ONE_TIME child Booking (the shipment that enters the train scheduling
* pipeline); this row is the ledger entry linking the contract to that
* shipment and recording the drawn-down quantities.
*/
@Entity({ schema: 'freight', name: 'booking_orders' })
export class BookingOrder extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
@Column({ name: 'contract_booking_id', type: 'uuid' })
contractBookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'contract_booking_id' })
contractBooking?: Booking;
/** The ONE_TIME child shipment booking spawned for this order. */
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Denormalized from the contract for fast company-scoped filtering. */
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company | null;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
status!: string;
@Column({
name: 'scheduling_status',
type: 'varchar',
length: 30,
default: SchedulingStatus.NotScheduled,
})
schedulingStatus!: string;
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
lines?: BookingOrderLine[];
}

View File

@@ -0,0 +1,161 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
import { DataSource } from 'typeorm';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingOrder } from './entities/booking-order.entity';
import { ContractQuantityLineView } from './dto/contract-view.dto';
/** Setting code holding the global ordering window (in months) for general contracts. */
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
/** Fallback when the setting is missing or unparseable. */
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
/**
* Owns general-contract concerns that sit alongside the generic booking flow:
* the configurable ordering period, post-payment activation, and computing the
* remaining drawdown pool per contract.
*/
@Injectable()
export class GeneralContractService {
private readonly logger = new Logger(GeneralContractService.name);
constructor(
private readonly dataSource: DataSource,
private readonly dropdownSettings: DropdownSettingsService,
) {}
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
return booking.bookingType === BookingType.GeneralContract;
}
/** The configured ordering window in months (defaults to 3). */
async getPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const raw = setting.children?.[0]?.value;
const months = Number(raw);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded yet — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
/**
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
* entering the train queue like a one-time booking) and stamp the ordering
* window. Idempotent.
*/
async activateAfterPayment(bookingId: string): Promise<void> {
const repo = this.dataSource.getRepository(Booking);
const booking = await repo.findOne({ where: { id: bookingId } });
if (!booking || !this.isGeneralContract(booking)) return;
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
return;
}
const months = await this.getPeriodMonths();
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + months);
await repo.update(bookingId, {
status: 'CONTRACT_ACTIVE',
paymentStatus: 'PAID',
expiresAt,
});
this.logger.log(
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
);
}
/**
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
* per container type for CONTAINER contracts, or a single total line for
* BULK/BREAK_BULK (keyed on a null container type).
*/
async getQuantityLines(
contractBookingId: string,
): Promise<ContractQuantityLineView[]> {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: contractBookingId },
relations: { bookingContainers: { containerType: true }, cargoType: true },
});
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
const ordered = await this.orderedByContainerType(contractBookingId);
if (booking.freightType === 'CONTAINER') {
return (booking.bookingContainers ?? []).map((c) => {
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
const contracted = Number(c.quantity);
return {
containerTypeId: c.containerTypeId ?? null,
containerTypeName: c.containerType?.label ?? null,
unitOfMeasure: null,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
};
});
}
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
const orderedQty = ordered.get('') ?? 0;
const contracted = Number(booking.cargoTotalWeightVgm);
const uom: CargoUnitOfMeasure | null =
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
CargoUnitOfMeasure.PerTon;
return [
{
containerTypeId: null,
containerTypeName: null,
unitOfMeasure: uom,
contractedQuantity: contracted,
orderedQuantity: orderedQty,
remainingQuantity: Math.max(0, contracted - orderedQty),
},
];
}
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
private async orderedByContainerType(
contractBookingId: string,
): Promise<Map<string, number>> {
const rows = await this.dataSource
.getRepository(BookingOrder)
.createQueryBuilder('o')
.innerJoin('o.lines', 'line')
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
.addSelect('SUM(line.quantity)', 'total')
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
.setParameter('empty', '')
.groupBy('key')
.getRawMany<{ key: string; total: string }>();
const map = new Map<string, number>();
for (const row of rows) map.set(row.key ?? '', Number(row.total));
return map;
}
/** Convenience: how many units remain for a given container type ('' = bulk). */
async remainingFor(
contractBookingId: string,
containerTypeKey: string,
): Promise<number> {
const lines = await this.getQuantityLines(contractBookingId);
const line = lines.find(
(l) => (l.containerTypeId ?? '') === containerTypeKey,
);
return line?.remainingQuantity ?? 0;
}
/** True once every contracted line is fully drawn down. */
async isExhausted(contractBookingId: string): Promise<boolean> {
const lines = await this.getQuantityLines(contractBookingId);
return lines.every((l) => l.remainingQuantity <= 0);
}
}

View File

@@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => {
let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock };
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
let exchangeService: { getRate: jest.Mock };
beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
};
cbeExchangeService = {
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
exchangeService = {
getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
};
service = new BookingPricingService(
@@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
ratesService as never,
{} as never,
cbeExchangeService as never,
exchangeService as never,
);
});

View File

@@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
import { ExchangeService } from '@edr/api-common';
import {
AppliedCargoModifier,
BookingEvaluationInput,
@@ -41,7 +41,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
private readonly cbeExchangeService: CbeExchangeService,
private readonly exchangeService: ExchangeService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -84,7 +84,7 @@ export class BookingPricingService {
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const lineItems: PriceLineItemDto[] = [];
let total = 0;
@@ -285,7 +285,7 @@ export class BookingPricingService {
const liveRates = await this.ratesService.findLiveRates();
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1;
const isBulk = booking.freightType === 'BULK';
const rateType =

View File

@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
name: child.cargoTypeName,
code: child.code,
show_free_text_box: child.showFreeTextBox,
unit_of_measure: child.unitOfMeasure ?? null,
}),
);

View File

@@ -132,8 +132,39 @@ export class BookingsController {
const companyId =
await this.bookingsService.resolveCustomerCompanyId(userId);
// No linked company yet → no bookings to show (avoids leaking all bookings).
if (!companyId) return { items: [], total: 0 };
return this.bookingsService.findAll(filter, companyId);
if (!companyId) {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
return {
items: [],
total: 0,
meta: {
page,
pageSize,
total: 0,
totalPages: 0,
hasNextPage: false,
hasPreviousPage: false,
},
};
}
// Scope to the active operational profile (importer/exporter) when one
// resolves; otherwise fall back to company-level scoping.
const companyProfileId =
await this.bookingsService.resolveActiveCompanyProfileId(userId);
return this.bookingsService.findAll(
filter,
companyId,
companyProfileId ?? undefined,
);
}
@Get('by-company/:companyId/customer-view')
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
findByCompanyCustomerView(
@Param('companyId', ParseUUIDPipe) companyId: string,
) {
return this.bookingsService.findCustomerBookings(companyId);
}
@Get('list-summary')

View File

@@ -1,5 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
// import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from '../companies/companies.module';
@@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
@Module({
imports: [
@@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
// CustomersModule,
RuleEngineModule,
SignaturesModule,
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
}),
],
controllers: [BookingsController, PayController],
providers: [
@@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CbeExchangeService,
],
exports: [BookingsService, BookingsRepository],
})

View File

@@ -25,14 +25,18 @@ export interface BookingListFilterOptions {
schedulingStatuses?: string[];
assignedToSchedule?: 'true' | 'false';
companyId?: string;
companyProfileId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
bookingType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
createdFrom?: string;
createdTo?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -434,7 +438,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
}): Promise<{
items: Booking[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}> {
const page = options.page;
const pageSize = options.pageSize;
@@ -481,7 +496,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
}
return { items, total };
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
// Return both the flat `total` (consumed by the backoffice list) and a
// `meta` block (consumed by the portal, matching PaginationMeta) so neither
// app needs to change its read shape.
return {
items,
total,
meta: {
page,
pageSize,
total,
totalPages,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
},
};
}
async getStatusCounts(): Promise<Record<string, number>> {
@@ -559,6 +589,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
companyId: options.companyId,
});
}
if (options.companyProfileId) {
qb.andWhere('booking.company_profile_id = :companyProfileId', {
companyProfileId: options.companyProfileId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,
@@ -579,6 +614,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
freightType: options.freightType,
});
}
if (options.bookingType) {
qb.andWhere('booking.booking_type = :bookingType', {
bookingType: options.bookingType,
});
}
if (options.createdFrom) {
qb.andWhere('booking.created_at >= :createdFrom', {
createdFrom: options.createdFrom,
});
}
if (options.createdTo) {
// Inclusive end-of-day: callers pass a date; include the whole day.
qb.andWhere('booking.created_at <= :createdTo', {
createdTo: options.createdTo,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,

View File

@@ -10,6 +10,7 @@ import {
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
@@ -41,6 +42,20 @@ import {
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings {
items: Booking[];
total: number;
meta: {
page: number;
pageSize: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
}
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
@@ -257,6 +272,7 @@ export class BookingsService {
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
@@ -291,11 +307,12 @@ export class BookingsService {
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else {
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later.
const day = eatDay(new Date(dto.scheduledDate));
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
@@ -323,6 +340,29 @@ export class BookingsService {
dto.tradeDirection,
);
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
tradeDirection,
fallbackType,
);
}
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
@@ -348,6 +388,7 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
@@ -370,7 +411,8 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
scheduledDate: new Date(dto.scheduledDate),
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
@@ -504,6 +546,22 @@ export class BookingsService {
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
// If the route (hence trade direction) changed, re-stamp the operational
// profile so an edited draft doesn't get stranded under the wrong profile.
if (
tradeDirection !== existing.tradeDirection &&
!existing.isGovernment &&
existing.companyId
) {
updates.companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
existing.companyId,
tradeDirection,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
@@ -583,7 +641,8 @@ export class BookingsService {
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
): Promise<{ items: Booking[]; total: number }> {
forceCompanyProfileId?: string,
): Promise<PaginatedBookings> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
@@ -597,14 +656,20 @@ export class BookingsService {
assignedToSchedule: filter.assignedToSchedule,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
companyId: forceCompanyId ?? filter.companyId,
// When an active profile resolves, scope to it; otherwise fall back to the
// company so nothing breaks for not-yet-onboarded customers.
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
@@ -627,15 +692,20 @@ export class BookingsService {
async findMyPayable(
userId: string,
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
): Promise<PaginatedBookings> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables.
const companyProfileId =
await this.companiesService.resolveActiveCompanyProfileId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: company.id,
companyId: companyProfileId ? undefined : company.id,
companyProfileId: companyProfileId ?? undefined,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -655,6 +725,15 @@ export class BookingsService {
}
}
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
@@ -756,9 +835,12 @@ export class BookingsService {
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
@@ -1036,4 +1118,37 @@ export class BookingsService {
return this.findById(id);
}
async findCustomerBookings(companyId: string): Promise<{
id: string;
reference: string;
status: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
totalAmount: number;
currency: string;
scheduledDate: Date | null;
createdAt: Date;
}[]> {
const { items } = await this.bookingsRepository.findAllPaginated({
page: 1,
pageSize: 500,
companyId,
});
return items.map((b) => ({
id: b.id,
reference: b.reference,
status: b.status,
tradeDirection: b.tradeDirection,
freightType: b.freightType,
originLabel: b.originYard?.label ?? '',
destinationLabel: b.destinationYard?.label ?? '',
totalAmount: Number(b.totalAmount),
currency: b.paymentCurrency,
scheduledDate: b.scheduledDate ?? null,
createdAt: b.createdAt,
}));
}
}

View File

@@ -1,4 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { CargoUnitOfMeasure } from '@edr/types';
export class BookingReferenceYardDto {
@ApiProperty({ format: 'uuid' })
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
@ApiProperty()
show_free_text_box!: boolean;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
unit_of_measure?: CargoUnitOfMeasure | null;
}
export class BookingReferenceCargoTypeGroupDto {

View File

@@ -17,7 +17,7 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
export {
BOOKING_STATUSES,
BOOKING_TYPES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
@@ -105,10 +106,24 @@ export class CreateBookingDto {
@IsUUID()
trainScheduleId?: string;
/** The day the customer wants to ship (the pool day key). */
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@ApiPropertyOptional({
enum: BOOKING_TYPES,
default: 'ONE_TIME',
description:
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
})
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
/**
* The day the customer wants to ship (the pool day key). Required for one-time
* bookings; omitted for general contracts, which pick the date per order.
*/
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
@IsDateString()
scheduledDate!: string;
scheduledDate?: string;
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])

View File

@@ -1,8 +1,9 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
import {
BOOKING_STATUSES,
BOOKING_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
@@ -56,6 +57,21 @@ export class FilterBookingDto {
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
@IsOptional()
@IsIn([...BOOKING_TYPES])
bookingType?: string;
@ApiPropertyOptional({ description: 'Filter bookings created on/after this date (ISO)' })
@IsOptional()
@IsDateString()
createdFrom?: string;
@ApiPropertyOptional({ description: 'Filter bookings created on/before this date (ISO)' })
@IsOptional()
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
import { CompanyProfile } from '../../companies/entities/company-profile.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
@@ -40,10 +41,15 @@ export const BOOKING_STATUSES = [
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
export const PAYMENT_STATUSES = [
'PENDING',
'PNR_GENERATED',
@@ -92,6 +98,20 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'company_id' })
company?: Company | null;
/**
* The operational profile (importer/exporter/forwarder) this booking belongs
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
* legacy/government/staff-created bookings.
*/
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
companyProfileId?: string | null;
@ManyToOne(() => CompanyProfile, { nullable: true })
@JoinColumn({ name: 'company_profile_id' })
companyProfile?: CompanyProfile | null;
@Column({ name: 'is_government', type: 'boolean', default: false })
isGovernment!: boolean;
@@ -110,8 +130,28 @@ export class Booking extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
status!: string;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
/**
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
* umbrella contract that is signed/paid once and then drawn down by many
* orders (each order spawns its own ONE_TIME child booking).
*/
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
bookingType!: string;
/**
* Nullable: general contracts have no shipment date at creation — the date is
* chosen per drawdown order. One-time bookings always set this (the pool day key).
*/
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
scheduledDate?: Date | null;
/**
* General contracts only: when the ordering window closes, computed from the
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
* bookings and for contracts that are not yet active.
*/
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt?: Date | null;
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
totalAmount!: number;

View File

@@ -24,15 +24,25 @@ import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto";
import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto";
import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto";
import { SetActiveModeDto } from "./dto/set-active-mode.dto";
import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto";
import { StartOnboardingDto } from "./dto/start-onboarding.dto";
import {
ResponseCompanyDto,
ResponseCompanyProfileDto,
} from "./dto/response-company.dto";
import { BusinessLicenseFile } from "./entities/company-profile.entity";
import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto";
import { CompanyInfoResponseDto } from "./dto/company-info-response.dto";
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import { UpdateCompanyProfileStatusDto } from "./dto/update-company-profile-status.dto";
import { FetchETradeDto } from "./dto/fetch-etrade.dto";
import { ETradeResponseDto } from "./dto/etrade-response.dto";
interface CurrentIamUser {
id: string;
@@ -80,6 +90,15 @@ export class CompaniesController {
return this.companiesService.getDashboardSummary(user.id);
}
@Post("fetch-etrade-info")
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
async fetchETradeInfo(
@Body() dto: FetchETradeDto,
): Promise<ETradeResponseDto> {
const data = await this.companiesService.fetchETradeData(dto.tin);
return new ETradeResponseDto(data);
}
@Patch("profile")
@ApiOperation({ summary: "Update profile (flattened settings page)" })
async updateProfile(
@@ -105,6 +124,113 @@ export class CompaniesController {
return profiles.map((p) => new ResponseCompanyProfileDto(p));
}
@Post("onboarding/start")
@ApiOperation({
summary:
"Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally",
})
async startOnboarding(
@CurrentUser() user: CurrentIamUser,
@Body() dto: StartOnboardingDto,
): Promise<CompanyInfoResponseDto> {
const nameParts = (user.name?.en ?? "").split(" ");
const { profile, company } = await this.companiesService.startOnboarding(
{
userId: user.id,
firstName: nameParts[0] || "",
lastName: nameParts.slice(-1)[0] || "",
email: user.email ?? "",
phone: user.phoneNumber ?? "",
},
dto.companyType,
dto.roles,
dto.nationality,
);
return new CompanyInfoResponseDto(profile, company);
}
@Post("company-profile")
@ApiOperation({
summary:
"Create a single operational profile for the current user's company and make it the active mode",
})
async createCompanyProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: CreateCompanyProfileDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.createCompanyProfileForUser(
user.id,
dto.type,
dto.businessLicense,
);
return new ResponseCompanyProfileDto(profile);
}
@Post("company-profiles/:profileId/license")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary:
"Upload business-license document(s) for one of the current user's company profiles",
})
async uploadProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
): Promise<BusinessLicenseFile[]> {
return this.companiesService.uploadProfileLicenseFiles(
user.id,
profileId,
files,
);
}
@Get("company-profiles/:profileId/license")
@ApiOperation({
summary: "List business-license documents for a company profile",
})
async listProfileLicense(
@CurrentUser() user: CurrentIamUser,
@Param("profileId", ParseUUIDPipe) profileId: string,
): Promise<BusinessLicenseFile[]> {
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
}
@Patch("active-mode")
@ApiOperation({
summary: "Switch the current user's active operational mode (importer/exporter)",
})
async setActiveMode(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetActiveModeDto,
): Promise<CompanyInfoResponseDto> {
const { profile, company } = await this.companiesService.setActiveMode(
user.id,
dto.type,
);
return new CompanyInfoResponseDto(profile, company);
}
@Patch("onboarding-step")
@ApiOperation({ summary: "Persist the user's current onboarding wizard step" })
@HttpCode(HttpStatus.NO_CONTENT)
async setOnboardingStep(
@CurrentUser() user: CurrentIamUser,
@Body() dto: SetOnboardingStepDto,
): Promise<void> {
await this.companiesService.setOnboardingStep(user.id, dto.step);
}
@Post("onboarding/complete")
@ApiOperation({ summary: "Mark the current user's onboarding as complete" })
async completeOnboarding(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyInfoResponseDto> {
const { profile, company } =
await this.companiesService.markOnboardingComplete(user.id);
return new CompanyInfoResponseDto(profile, company);
}
// Used by portal
@Post("create")
@ApiOperation({
@@ -142,29 +268,19 @@ export class CompaniesController {
return new ResponseCompanyDto(company);
}
@Get("stats")
@ApiOperation({ summary: "Company counts by status (KPI strip)" })
async getStats(): Promise<CompanyStatsResponseDto> {
return this.companiesService.getCompanyStats();
}
@Get()
@ApiOperation({ summary: "List all companies" })
async findAll(): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies.map((c) => new ResponseCompanyDto(c));
}
@Get("type/:type")
@ApiOperation({ summary: "Find companies by type" })
async findByType(@Param("type") type: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.type === type)
.map((c) => new ResponseCompanyDto(c));
}
@Get("search")
@ApiOperation({ summary: "Search companies by name" })
async search(@Query("name") name: string): Promise<ResponseCompanyDto[]> {
const companies = await this.companiesService.findAllCompanies();
return companies
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
.map((c) => new ResponseCompanyDto(c));
@ApiOperation({ summary: "List companies (paginated, filterable)" })
async findAll(
@Query() query: ListCompaniesQueryDto,
): Promise<{ items: ResponseCompanyDto[]; total: number }> {
const { items, total } = await this.companiesService.listCompanies(query);
return { items: items.map((c) => new ResponseCompanyDto(c)), total };
}
@Get(":id")
@@ -195,6 +311,23 @@ export class CompaniesController {
await this.companiesService.deleteCompany(id);
}
@Get(":companyId/documents")
@ApiOperation({ summary: "List documents uploaded for a company" })
async listDocuments(
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
const files = await this.filesService.findByResource(companyId, "companies");
return files.map((f) => ({
id: f.id,
name: f.name,
code: f.code,
mimeType: f.mimeType,
size: f.size,
uploadedAt: f.createdAt,
url: f.url,
}));
}
@Post(":companyId/documents")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@@ -206,6 +339,20 @@ export class CompaniesController {
return this.filesService.uploadMany(companyId, "companies", files);
}
@Patch("company-profiles/:profileId/status")
@FreightAdmin()
@ApiOperation({ summary: "Update a company profile's approval status" })
async updateCompanyProfileStatus(
@Param("profileId", ParseUUIDPipe) profileId: string,
@Body() dto: UpdateCompanyProfileStatusDto,
): Promise<ResponseCompanyProfileDto> {
const profile = await this.companiesService.setCompanyProfileStatus(
profileId,
dto.status,
);
return new ResponseCompanyProfileDto(profile);
}
@Post(":companyId/profiles")
@FreightAdmin()
@ApiOperation({ summary: "Add a profile (employee) to a company" })

View File

@@ -1,6 +1,8 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { FilesModule } from "../files/files.module";
import { MinioModule } from "../minio/minio.module";
import { CompaniesController } from "./companies.controller";
import { CompaniesService } from "./companies.service";
import { CompaniesRepository } from "./companies.repository";
@@ -11,11 +13,14 @@ import { ExternalProfile } from "./entities/external-profile.entity";
import { CompanyProfile } from "./entities/company-profile.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ETradeService } from "./services/etrade.service";
@Module({
imports: [
TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]),
HttpModule,
FilesModule,
MinioModule,
],
controllers: [CompaniesController],
providers: [
@@ -24,6 +29,7 @@ import { CompanyProfileRepository } from "./company-profile.repository";
ExternalProfileRepository,
CompanyProfileRepository,
CompanyDashboardRepository,
ETradeService,
],
exports: [CompaniesService],
})

View File

@@ -3,6 +3,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Company } from './entities/company.entity';
import { ListCompaniesQueryDto } from './dto/list-companies-query.dto';
import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
@Injectable()
export class CompaniesRepository extends BaseRepository<Company> {
@@ -32,4 +34,68 @@ export class CompaniesRepository extends BaseRepository<Company> {
const count = await this.repository.count({ where: { tin } as any });
return count > 0;
}
async findPaginated(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
const { page = 1, pageSize = 20, search, type, status } = query;
const qb = this.repository
.createQueryBuilder('company')
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
.where('company.deleted_at IS NULL');
if (type) {
qb.andWhere('company.type = :type', { type });
}
if (status) {
qb.andWhere('company.status = :status', { status });
}
if (search) {
const term = `%${search.trim()}%`;
qb.andWhere(
`(company.name ILIKE :term
OR company.tin ILIKE :term
OR company.email ILIKE :term
OR EXISTS (
SELECT 1 FROM freight.company_profiles cp
WHERE cp.company_id = company.id
AND cp.reference ILIKE :term
AND cp.deleted_at IS NULL
))`,
{ term },
);
}
const [items, total] = await qb
.orderBy('company.name', 'ASC')
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
async getStats(): Promise<CompanyStatsResponseDto> {
const rows: { status: string; count: string }[] = await this.repository
.createQueryBuilder('company')
.select('company.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('company.deleted_at IS NULL')
.groupBy('company.status')
.getRawMany();
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
return {
total,
active: map.get('active') ?? 0,
pending: map.get('pending') ?? 0,
suspended: map.get('suspended') ?? 0,
blacklisted: map.get('blacklisted') ?? 0,
};
}
}

View File

@@ -8,6 +8,9 @@ import { CompaniesRepository } from "./companies.repository";
import { CompanyProfileRepository } from "./company-profile.repository";
import { ExternalProfileRepository } from "./external-profile.repository";
import { CompanyDashboardRepository } from "./company-dashboard.repository";
import { MinioService } from "../minio/minio.service";
import { ETradeService } from "./services/etrade.service";
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
import { CreateCompanyDto } from "./dto/create-company.dto";
import { UpdateCompanyDto } from "./dto/update-company.dto";
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
@@ -15,9 +18,17 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d
import { UpdateProfileDto } from "./dto/update-profile.dto";
import { ProfileResponseDto } from "./dto/profile-response.dto";
import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto";
import { Company } from "./entities/company.entity";
import { ListCompaniesQueryDto } from "./dto/list-companies-query.dto";
import { CompanyStatsResponseDto } from "./dto/company-stats-response.dto";
import {
Company,
CompanyNationality,
CompanyStatus,
CompanyType,
} from "./entities/company.entity";
import { ExternalProfile } from "./entities/external-profile.entity";
import {
BusinessLicenseFile,
CompanyProfile,
ProfileType,
ProfileStatus,
@@ -38,6 +49,8 @@ export class CompaniesService {
private readonly companyProfilesRepo: CompanyProfileRepository,
private readonly profilesRepo: ExternalProfileRepository,
private readonly dashboardRepo: CompanyDashboardRepository,
private readonly minioService: MinioService,
private readonly etradeService: ETradeService,
) { }
async createCompany(dto: CreateCompanyDto): Promise<Company> {
@@ -76,20 +89,34 @@ export class CompaniesService {
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: dto.companyPhone ?? null,
phone: normalizeE164(dto.companyPhone) ?? null,
email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null,
});
// Default active mode from the chosen role(s): importer wins when both are
// picked, otherwise the first allowed type chosen.
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
const chosenTypes = (dto.companyProfiles ?? [])
.map((p) => p.type)
.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const profile = await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: identity.phone,
phone: normalizeE164(identity.phone) ?? identity.phone,
jobTitle: dto.jobTitle ?? null,
isPrimaryContact: dto.isPrimaryContact ?? true,
activeProfileType,
onboardingStep: "company",
});
// Persist the operational role(s) chosen during onboarding. Types are
@@ -123,6 +150,131 @@ export class CompaniesService {
return { company, profile };
}
async listCompanies(
query: ListCompaniesQueryDto,
): Promise<{ items: Company[]; total: number }> {
return this.companiesRepo.findPaginated(query);
}
async getCompanyStats(): Promise<CompanyStatsResponseDto> {
return this.companiesRepo.getStats();
}
/**
* Begin onboarding: create a DRAFT company + the user's external profile + the
* chosen operational role(s) up front, so every subsequent wizard step can
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
*
* Idempotent: if the user already has a profile, returns it unchanged (only
* adding any newly-chosen roles). The draft company carries a placeholder TIN
* (the real one is filled on the Company Information step) and stays
* status=pending / onboardingCompleted=false until the wizard finishes.
*/
async startOnboarding(
identity: UserIdentity,
companyType: CompanyType,
roles: ProfileType[],
nationality?: CompanyNationality,
): Promise<{ profile: ExternalProfile; company: Company }> {
// Already started — reuse the existing draft, just ensure roles exist and
// keep the nationality up to date if it was (re)selected.
const existing = await this.profilesRepo.findByUserId(identity.userId);
if (existing) {
const companyId = existing.company?.id ?? existing.companyId;
await this.ensureCompanyProfiles(companyId, companyType, roles);
if (nationality) {
await this.companiesRepo.update(companyId, { nationality });
}
return this.getCompanyInfoByUserId(identity.userId);
}
// A profile may exist for the same email under a different IAM id — block
// duplicates as the final create does.
const byEmail = await this.profilesRepo.findByEmail(identity.email);
if (byEmail) {
throw new ConflictException(
`Profile with email ${identity.email} already exists`,
);
}
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
const chosenTypes = roles.filter((t) => allowedTypes.includes(t));
const activeProfileType =
chosenTypes.find((t) => t === ProfileType.importer) ??
chosenTypes[0] ??
allowedTypes[0] ??
null;
const company = await this.companiesRepo.create({
name: identity.firstName
? `${identity.firstName}'s company`
: "New company",
type: companyType,
tin: await this.generateDraftTin(),
country: "Ethiopia",
nationality: nationality ?? CompanyNationality.Ethiopian,
status: CompanyStatus.Pending,
});
await this.profilesRepo.create({
userId: identity.userId,
companyId: company.id,
firstName: identity.firstName,
lastName: identity.lastName,
email: identity.email,
phone: normalizeE164(identity.phone) ?? identity.phone,
isPrimaryContact: true,
activeProfileType,
onboardingStep: "company",
onboardingCompleted: false,
});
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
return this.getCompanyInfoByUserId(identity.userId);
}
/** Create any of the requested operational profiles that don't exist yet. */
private async ensureCompanyProfiles(
companyId: string,
companyType: CompanyType,
roles: ProfileType[],
): Promise<void> {
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
for (const type of roles) {
if (!allowedTypes.includes(type)) continue;
const existing = await this.companyProfilesRepo.findByType(
companyId,
type,
);
if (existing) continue;
const reference = await this.companyProfilesRepo.generateReference(type);
await this.companyProfilesRepo.create({
companyId,
type,
reference,
status: ProfileStatus.Active,
});
}
}
/**
* A unique 10-char placeholder TIN for a draft company (the column is
* NOT NULL + unique). Overwritten with the real TIN on the company step.
*/
private async generateDraftTin(): Promise<string> {
for (let i = 0; i < 10; i++) {
const candidate =
"D" +
Math.floor(Math.random() * 1_000_000_000)
.toString()
.padStart(9, "0");
if (!(await this.companiesRepo.existsByTin(candidate))) return candidate;
}
// Extremely unlikely; fall back to a timestamp-derived value.
return ("D" + Date.now().toString()).slice(0, 10);
}
async findAllCompanies(): Promise<Company[]> {
return this.companiesRepo.findAll({ order: { name: "ASC" } });
}
@@ -130,6 +282,7 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
return company;
}
@@ -146,8 +299,9 @@ export class CompaniesService {
`Company for profile ${profile.id} not found`,
);
company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(company.id);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(
company.id,
);
return { profile, company };
}
@@ -174,6 +328,18 @@ export class CompaniesService {
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
if (!companyId) return this.emptyDashboardSummary();
// Scope KPIs to the active operational profile (importer/exporter mode) when
// one resolves; otherwise aggregate across the whole company.
const companyProfileId = profile?.activeProfileType
? ((await this.companyProfilesRepo.findByType(
companyId,
profile.activeProfileType,
)) ?? null)
: null;
const scope = companyProfileId
? { companyProfileId: companyProfileId.id }
: { companyId };
const now = new Date();
const yearStart = new Date(now.getFullYear(), 0, 1);
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
@@ -191,22 +357,22 @@ export class CompaniesService {
tonnagePrev,
monthlyRows,
] = await Promise.all([
this.dashboardRepo.countDelivered(companyId, yearStart, now),
this.dashboardRepo.countCommitted(companyId, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now),
this.dashboardRepo.countDelivered(scope, yearStart, now),
this.dashboardRepo.countCommitted(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now),
this.dashboardRepo.sumPaidSpendByCurrency(
companyId,
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now),
this.dashboardRepo.sumCommittedTonnage(
companyId,
scope,
prevYearStart,
prevYearToDate,
),
this.dashboardRepo.monthlyCommittedTonnage(
companyId,
scope,
this.monthsAgo(now, 5),
now,
),
@@ -323,14 +489,27 @@ export class CompaniesService {
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
if (dto.nationality !== undefined)
companyUpdates.nationality = dto.nationality;
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyPhone !== undefined)
companyUpdates.phone = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.tin !== undefined && dto.tin !== company.tin) {
// Reject a TIN already taken by a different company (the user's own draft
// placeholder is fine to overwrite).
const owner = await this.companiesRepo.findByTin(dto.tin);
if (owner && owner.id !== company.id) {
throw new ConflictException(
`This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`,
);
}
companyUpdates.tin = dto.tin;
}
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.fanNumber = dto.fanNumber;
@@ -338,21 +517,45 @@ export class CompaniesService {
if (dto.contactPersonName !== undefined)
attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPosition !== undefined)
attrUpdates.contactPersonPosition = dto.contactPersonPosition;
if (dto.contactPersonEmail !== undefined)
attrUpdates.contactPersonEmail = dto.contactPersonEmail;
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = dto.contactPersonPhone;
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined)
attrUpdates.generalManagerPhone = dto.generalManagerPhone;
attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone);
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
companyUpdates.licenceNumber = dto.licenceNumber;
if (dto.statusDescription !== undefined)
companyUpdates.statusDescription = dto.statusDescription;
if (dto.dateRegistered !== undefined)
companyUpdates.dateRegistered = dto.dateRegistered;
if (dto.renewedFrom !== undefined)
companyUpdates.renewedFrom = dto.renewedFrom;
if (dto.renewalDate !== undefined)
companyUpdates.renewalDate = dto.renewalDate;
if (dto.renewedTo !== undefined) companyUpdates.renewedTo = dto.renewedTo;
if (dto.region !== undefined) companyUpdates.region = dto.region;
if (dto.zone !== undefined) companyUpdates.zone = dto.zone;
if (dto.woreda !== undefined) companyUpdates.woreda = dto.woreda;
if (dto.kebele !== undefined) companyUpdates.kebele = dto.kebele;
if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo;
if (dto.etradePhone !== undefined)
companyUpdates.etradePhone = normalizeE164(dto.etradePhone);
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
@@ -393,7 +596,13 @@ export class CompaniesService {
private getProfileTypeForCompanyType(companyType: string): ProfileType[] {
switch (companyType) {
case "customer":
return [ProfileType.importer, ProfileType.exporter];
// A customer can operate as an importer and/or exporter, and may also
// add a freight-forwarder service profile under the same company.
return [
ProfileType.importer,
ProfileType.exporter,
ProfileType.freightForwarder,
];
case "freight_forwarder":
return [ProfileType.freightForwarder];
case "dj_freight_forwarder":
@@ -405,6 +614,19 @@ export class CompaniesService {
}
}
async setCompanyProfileStatus(
profileId: string,
status: ProfileStatus,
): Promise<CompanyProfile> {
const updated = await this.companyProfilesRepo.updateStatus(
profileId,
status,
);
if (!updated)
throw new NotFoundException(`Company profile ${profileId} not found`);
return updated;
}
async createCompanyProfile(
companyId: string,
profileType?: ProfileType,
@@ -505,4 +727,243 @@ export class CompaniesService {
return this.companyProfilesRepo.findByCompanyId(companyId);
}
/**
* Create a single operational profile for the current user's company and
* make it the active mode in the same call. Powers the header "Switch to
* Exporter/Importer" flow when the target profile doesn't exist yet.
*/
async createCompanyProfileForUser(
userId: string,
type: ProfileType,
businessLicense?: string,
): Promise<CompanyProfile> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
let created = await this.companyProfilesRepo.findByType(companyId, type);
if (!created) {
const reference = await this.companyProfilesRepo.generateReference(type);
created = await this.companyProfilesRepo.create({
companyId,
type,
reference,
businessLicense: businessLicense ?? null,
status: ProfileStatus.Active,
});
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return created;
}
/**
* Switch the user's active operational mode. The target profile must already
* exist — clients create it first via createCompanyProfileForUser.
*/
async setActiveMode(
userId: string,
type: ProfileType,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
const allowedTypes = this.getProfileTypeForCompanyType(company.type);
if (!allowedTypes.includes(type)) {
throw new BadRequestException(
`Profile type "${type}" is not allowed for company type "${company.type}"`,
);
}
const existing = await this.companyProfilesRepo.findByType(companyId, type);
if (!existing) {
throw new ConflictException(
`No ${type} profile exists yet — create it before switching`,
);
}
await this.profilesRepo.update(profile.id, { activeProfileType: type });
return this.getCompanyInfoByUserId(userId);
}
async setOnboardingStep(userId: string, step: string): Promise<void> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
await this.profilesRepo.update(profile.id, { onboardingStep: step });
}
async markOnboardingComplete(
userId: string,
): Promise<{ profile: ExternalProfile; company: Company }> {
const profile = await this.profilesRepo.findByUserId(userId);
if (!profile)
throw new NotFoundException(`Profile for user ${userId} not found`);
const companyId = profile.company?.id ?? profile.companyId;
const company = await this.findCompanyById(companyId);
// Guard against finishing on a still-draft company (TIN never filled in).
if (!company.tin || company.tin.startsWith("D")) {
throw new BadRequestException(
"Company information is incomplete — please fill in your company details before finishing.",
);
}
// Every operational profile must have at least one business-license file
// (stored directly on the profile).
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) {
throw new BadRequestException(
`Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`,
);
}
}
await this.profilesRepo.update(profile.id, {
onboardingCompleted: true,
onboardingStep: "done",
});
await this.companiesRepo.update(companyId, {
status: CompanyStatus.Active,
});
return this.getCompanyInfoByUserId(userId);
}
/**
* Authorize and resolve a company_profile that must belong to the current
* user's company — used before accepting/returning its license files.
*/
async resolveOwnedProfile(
userId: string,
profileId: string,
): Promise<CompanyProfile> {
const { company } = await this.getCompanyInfoByUserId(userId);
const owned = (company.companyProfiles ?? []).find(
(p) => p.id === profileId,
);
if (!owned) {
throw new NotFoundException(`Profile ${profileId} not found`);
}
return owned;
}
/**
* Upload business-license document(s) and store them directly on the company
* profile (multi-file). Bytes go to object storage; only metadata/URLs are
* persisted on the profile — intentionally not via the FileRecord file model.
* New files are appended to any already present. Returns the full list.
*/
async uploadProfileLicenseFiles(
userId: string,
profileId: string,
files: Express.Multer.File[],
): Promise<BusinessLicenseFile[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
const uploaded: BusinessLicenseFile[] = [];
for (const file of files) {
const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`;
const url = await this.minioService.uploadFile(
objectName,
file.buffer,
file.mimetype,
);
uploaded.push({
name: file.originalname,
url,
size: file.size,
mimeType: file.mimetype,
});
}
const next = [...(profile.businessLicenseFiles ?? []), ...uploaded];
await this.companyProfilesRepo.update(profileId, {
businessLicenseFiles: next,
});
return next;
}
/** The business-license files stored on a single company profile. */
async listProfileLicenseFiles(
userId: string,
profileId: string,
): Promise<BusinessLicenseFile[]> {
const profile = await this.resolveOwnedProfile(userId, profileId);
return profile.businessLicenseFiles ?? [];
}
/**
* Resolve which company_profile a new booking belongs to, from the company
* and the booking's trade direction. IMPORT → importer profile, EXPORT →
* exporter profile; for DOMESTIC or a forwarder/single-profile company (or
* when the natural profile doesn't exist) it falls back to the user's active
* profile, then the company's first profile. Returns null when the company
* has no profiles at all.
*/
async resolveCompanyProfileIdForBooking(
companyId: string,
tradeDirection: string,
fallbackType?: ProfileType | null,
): Promise<string | null> {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
if (profiles.length === 0) return null;
const naturalType =
tradeDirection === "IMPORT"
? ProfileType.importer
: tradeDirection === "EXPORT"
? ProfileType.exporter
: null;
const byType = (type?: ProfileType | null) =>
type ? profiles.find((p) => p.type === type) : undefined;
const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0];
return match?.id ?? null;
}
/**
* Resolve the company_profile a customer's data should be scoped to, from
* their persisted active mode. Returns null when nothing can be resolved
* (not onboarded yet) so callers can fall back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
try {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const type = profile.activeProfileType;
if (!type) return null;
const match = company.companyProfiles?.find((p) => p.type === type);
return match?.id ?? null;
} catch {
return null;
}
}
async fetchETradeData(tin: string) {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"No business license found for this TIN. Please check the number and try again.",
);
}
return this.etradeService.extractRegistrationData(businessInfo);
}
}

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
@@ -31,6 +31,27 @@ export interface CurrencyTotal {
total: number;
}
/**
* What the dashboard is scoped to: a single operational profile (the active
* importer/exporter mode) when one resolves, otherwise the whole company
* (legacy / not-yet-onboarded fallback).
*/
export type DashboardScope =
| { companyProfileId: string }
| { companyId: string };
/** Apply the scope as a WHERE clause on a bookings query builder. */
function applyScope(
qb: SelectQueryBuilder<Booking>,
scope: DashboardScope,
): SelectQueryBuilder<Booking> {
return 'companyProfileId' in scope
? qb.where('b.company_profile_id = :companyProfileId', {
companyProfileId: scope.companyProfileId,
})
: qb.where('b.company_id = :companyId', { companyId: scope.companyId });
}
export interface MonthlyTonnage {
year: number;
month: number; // 1-12
@@ -50,35 +71,33 @@ export class CompanyDashboardRepository {
private readonly bookings: Repository<Booking>,
) {}
/** Count of delivered/completed bookings for a company within [from, to). */
async countDelivered(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
/** Count of delivered/completed bookings within [from, to) for the scope. */
async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise<number> {
return applyScope(this.bookings.createQueryBuilder('b'), scope)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */
async countCommitted(companyId: string, from: Date, to: Date): Promise<number> {
return this.bookings
.createQueryBuilder('b')
.where('b.company_id = :companyId', { companyId })
/** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */
async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise<number> {
return applyScope(this.bookings.createQueryBuilder('b'), scope)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
.getCount();
}
/** Sum of paid booking totals, grouped by currency, within [from, to). */
async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */
async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise<CurrencyTotal[]> {
const rows = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('b.payment_currency', 'currency')
.addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere("b.payment_status = 'PAID'")
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
@@ -88,12 +107,14 @@ export class CompanyDashboardRepository {
return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) }));
}
/** Total committed tonnage (cargo VGM) for a company within [from, to). */
async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise<number> {
const row = await this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Total committed tonnage (cargo VGM) within [from, to) for the scope. */
async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<number> {
const row = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })
@@ -102,14 +123,16 @@ export class CompanyDashboardRepository {
return Number(row?.total ?? 0);
}
/** Committed tonnage grouped by calendar month within [from, to). */
async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total')
.where('b.company_id = :companyId', { companyId })
/** Committed tonnage grouped by calendar month within [from, to) for the scope. */
async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise<MonthlyTonnage[]> {
const rows = await applyScope(
this.bookings
.createQueryBuilder('b')
.select('EXTRACT(YEAR FROM b.created_at)', 'year')
.addSelect('EXTRACT(MONTH FROM b.created_at)', 'month')
.addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'),
scope,
)
.andWhere('b.deleted_at IS NULL')
.andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] })
.andWhere('b.created_at >= :from AND b.created_at < :to', { from, to })

View File

@@ -2,7 +2,7 @@ import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { BaseRepository } from "@edr/api-common";
import { CompanyProfile, ProfileType } from "./entities/company-profile.entity";
import { CompanyProfile, ProfileStatus, ProfileType } from "./entities/company-profile.entity";
const SEQUENCE_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "seq_company_profile_ex",
@@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
const PREFIX_MAP: Record<ProfileType, string> = {
[ProfileType.exporter]: "EX",
[ProfileType.importer]: "IM",
[ProfileType.freightForwarder]: "FFE",
[ProfileType.freightForwarder]: "FF",
[ProfileType.djFreightForwarder]: "FWJ",
[ProfileType.transporter]: "TR",
};
@@ -58,4 +58,16 @@ export class CompanyProfileRepository extends BaseRepository<CompanyProfile> {
async findByReference(reference: string): Promise<CompanyProfile | null> {
return this.repository.findOne({ where: { reference } });
}
async findById(id: string): Promise<CompanyProfile | null> {
return this.repository.findOne({ where: { id } });
}
async updateStatus(
id: string,
status: ProfileStatus,
): Promise<CompanyProfile | null> {
await this.repository.update({ id }, { status });
return this.repository.findOne({ where: { id } });
}
}

View File

@@ -8,7 +8,7 @@ export class CompanyInfoResponseDto {
company: ResponseCompanyDto;
constructor(profile: ExternalProfile, company: Company) {
this.profile = new ResponseExternalProfileDto(profile);
this.profile = new ResponseExternalProfileDto(profile, company);
this.company = new ResponseCompanyDto(company);
}
}

View File

@@ -0,0 +1,7 @@
export class CompanyStatsResponseDto {
total!: number;
active!: number;
pending!: number;
suspended!: number;
blacklisted!: number;
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity';
export class CreateCompanyProfileDto {
@IsEnum(ProfileType)
type!: ProfileType;
@IsOptional()
@IsString()
@MaxLength(100)
businessLicense?: string;
}

View File

@@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum
import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
@@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional()

View File

@@ -1,5 +1,6 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CreateCompanyDto {
@IsString()
@@ -37,6 +38,7 @@ export class CreateCompanyDto {
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
phone?: string;
@IsOptional()

View File

@@ -1,4 +1,5 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class CreateExternalProfileDto {
@IsUUID()
@@ -26,6 +27,7 @@ export class CreateExternalProfileDto {
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
phone?: string;
@IsOptional()

View File

@@ -0,0 +1,39 @@
import { CompanyRegistrationData } from "@edr/types";
export class ETradeResponseDto implements 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;
constructor(data: CompanyRegistrationData) {
this.licenceNumber = data.licenceNumber;
this.statusDescription = data.statusDescription;
this.dateRegistered = data.dateRegistered;
this.renewedFrom = data.renewedFrom;
this.renewalDate = data.renewalDate;
this.renewedTo = data.renewedTo;
this.region = data.region;
this.zone = data.zone;
this.woreda = data.woreda;
this.kebele = data.kebele;
this.houseNo = data.houseNo;
this.mobilePhone = data.mobilePhone;
this.regularPhone = data.regularPhone;
this.managerName = data.managerName;
this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone;
}
}

View File

@@ -0,0 +1,8 @@
import { IsString, IsNotEmpty, Length } from "class-validator";
export class FetchETradeDto {
@IsString()
@IsNotEmpty()
@Length(10, 10, { message: "TIN must be exactly 10 digits" })
tin!: string;
}

View File

@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
import { Transform } from "class-transformer";
import { CompanyStatus, CompanyType } from "../entities/company.entity";
export class ListCompaniesQueryDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
pageSize?: number = 20;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: CompanyType })
@IsOptional()
@IsIn(Object.values(CompanyType))
type?: CompanyType;
@ApiPropertyOptional({ enum: CompanyStatus })
@IsOptional()
@IsIn(Object.values(CompanyStatus))
status?: CompanyStatus;
}

View File

@@ -6,6 +6,7 @@ export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
nationality: string | null;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
@@ -16,7 +17,22 @@ export class ProfileResponseDto {
companyProfiles: ResponseCompanyProfileDto[];
licenceNumber: string | null;
statusDescription: string | null;
dateRegistered: string | null;
renewedFrom: string | null;
renewalDate: string | null;
renewedTo: string | null;
region: string | null;
zone: string | null;
woreda: string | null;
kebele: string | null;
houseNo: string | null;
etradePhone: string | null;
contactPersonName: string | null;
contactPersonPosition: string | null;
contactPersonEmail: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
@@ -34,6 +50,7 @@ export class ProfileResponseDto {
this.companyId = company.id;
this.companyName = company.name;
this.companyType = company.type;
this.nationality = company.nationality ?? null;
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
@@ -46,8 +63,23 @@ export class ProfileResponseDto {
this.fanNumber = company.fanNumber ?? null;
this.profileId = profile.id;
this.licenceNumber = company.licenceNumber ?? null;
this.statusDescription = company.statusDescription ?? null;
this.dateRegistered = company.dateRegistered ?? null;
this.renewedFrom = company.renewedFrom ?? null;
this.renewalDate = company.renewalDate ?? null;
this.renewedTo = company.renewedTo ?? null;
this.region = company.region ?? null;
this.zone = company.zone ?? null;
this.woreda = company.woreda ?? null;
this.kebele = company.kebele ?? null;
this.houseNo = company.houseNo ?? null;
this.etradePhone = company.etradePhone ?? null;
const attrs = company.attributes ?? {};
this.contactPersonName = attrs.contactPersonName ?? null;
this.contactPersonPosition = attrs.contactPersonPosition ?? null;
this.contactPersonEmail = attrs.contactPersonEmail ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;

View File

@@ -1,23 +1,37 @@
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
import { CompanyProfile } from '../entities/company-profile.entity';
import {
Company,
CompanyType,
CompanyStatus,
CompanyNationality,
} from '../entities/company.entity';
import {
BusinessLicenseFile,
CompanyProfile,
} from '../entities/company-profile.entity';
import { ResponseExternalProfileDto } from './response-external-profile.dto';
export class ResponseCompanyProfileDto {
id: string;
companyId: string;
type: string;
reference: string;
status: string;
/** @deprecated Superseded by licenseFiles. Kept for back-compat. */
businessLicense?: string | null;
/** Business-license documents stored on the profile (multi-file). */
licenseFiles: BusinessLicenseFile[];
attributes?: Record<string, any> | null;
createdAt: Date;
updatedAt: Date;
constructor(profile: CompanyProfile) {
this.id = profile.id;
this.companyId = profile.companyId;
this.type = profile.type;
this.reference = profile.reference;
this.status = profile.status;
this.businessLicense = profile.businessLicense;
this.licenseFiles = profile.businessLicenseFiles ?? [];
this.attributes = profile.attributes;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
@@ -29,6 +43,7 @@ export class ResponseCompanyDto {
name: string;
type: CompanyType;
status: CompanyStatus;
nationality?: CompanyNationality | null;
tin: string;
vatNumber?: string | null;
fanNumber?: string | null;
@@ -48,6 +63,7 @@ export class ResponseCompanyDto {
this.name = company.name;
this.type = company.type;
this.status = company.status;
this.nationality = company.nationality ?? null;
this.tin = company.tin;
this.vatNumber = company.vatNumber;
this.fanNumber = company.fanNumber;
@@ -58,7 +74,9 @@ export class ResponseCompanyDto {
this.website = company.website;
this.attributes = company.attributes;
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p));
this.companyProfiles = company.companyProfiles?.map(
(p) => new ResponseCompanyProfileDto(p),
);
this.createdAt = company.createdAt;
this.updatedAt = company.updatedAt;
}

View File

@@ -1,4 +1,8 @@
import { ExternalProfile } from '../entities/external-profile.entity';
import { Company } from '../entities/company.entity';
import {
ExternalProfile,
} from '../entities/external-profile.entity';
import { ProfileType } from '../entities/company-profile.entity';
export class ResponseExternalProfileDto {
id: string;
@@ -11,10 +15,20 @@ export class ResponseExternalProfileDto {
nationalId?: string | null;
jobTitle?: string | null;
isPrimaryContact: boolean;
/** The active operational mode (importer/exporter/forwarder). */
activeProfileType?: ProfileType | null;
/**
* The id of the company_profile matching activeProfileType, resolved
* server-side so the client never re-derives it. Null until a company
* (with profiles) is loaded and a matching profile exists.
*/
activeCompanyProfileId?: string | null;
onboardingStep?: string | null;
onboardingCompleted: boolean;
createdAt: Date;
updatedAt: Date;
constructor(profile: ExternalProfile) {
constructor(profile: ExternalProfile, company?: Company) {
this.id = profile.id;
this.userId = profile.userId;
this.companyId = profile.companyId;
@@ -25,6 +39,13 @@ export class ResponseExternalProfileDto {
this.nationalId = profile.nationalId;
this.jobTitle = profile.jobTitle;
this.isPrimaryContact = profile.isPrimaryContact;
this.activeProfileType = profile.activeProfileType ?? null;
this.onboardingStep = profile.onboardingStep ?? null;
this.onboardingCompleted = profile.onboardingCompleted ?? false;
this.activeCompanyProfileId =
company?.companyProfiles?.find(
(p) => p.type === profile.activeProfileType,
)?.id ?? null;
this.createdAt = profile.createdAt;
this.updatedAt = profile.updatedAt;
}

View File

@@ -0,0 +1,7 @@
import { IsEnum } from 'class-validator';
import { ProfileType } from '../entities/company-profile.entity';
export class SetActiveModeDto {
@IsEnum(ProfileType)
type!: ProfileType;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MaxLength } from 'class-validator';
export class SetOnboardingStepDto {
@IsString()
@MaxLength(40)
step!: string;
}

View File

@@ -0,0 +1,17 @@
import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator";
import { CompanyNationality, CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
export class StartOnboardingDto {
@IsEnum(CompanyType)
companyType!: CompanyType;
@IsArray()
@ArrayMinSize(1)
@IsEnum(ProfileType, { each: true })
roles!: ProfileType[];
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn } from "class-validator";
import { ProfileStatus } from "../entities/company-profile.entity";
export class UpdateCompanyProfileStatusDto {
@ApiProperty({ enum: ProfileStatus })
@IsIn(Object.values(ProfileStatus))
status!: ProfileStatus;
}

View File

@@ -1,6 +1,12 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
export class UpdateProfileDto {
@IsOptional()
@IsEnum(CompanyNationality)
nationality?: CompanyNationality;
@IsOptional()
@IsString()
@MaxLength(200)
@@ -14,6 +20,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional()
@@ -47,6 +54,15 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
contactPersonPosition?: string;
@IsOptional()
@IsEmail()
contactPersonEmail?: string;
@IsOptional()
@IsString()
@IsValidPhone()
contactPersonPhone?: string;
@IsOptional()
@@ -59,6 +75,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@IsValidPhone()
generalManagerPhone?: string;
@IsOptional()
@@ -67,6 +84,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@IsValidPhone()
poaPhone?: string;
@IsOptional()
@@ -80,4 +98,64 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
poaAddress?: string;
@IsOptional()
@IsString()
@MaxLength(100)
licenceNumber?: string;
@IsOptional()
@IsString()
statusDescription?: string;
@IsOptional()
@IsString()
@MaxLength(50)
dateRegistered?: string;
@IsOptional()
@IsString()
@MaxLength(50)
renewedFrom?: string;
@IsOptional()
@IsString()
@MaxLength(50)
renewalDate?: string;
@IsOptional()
@IsString()
@MaxLength(50)
renewedTo?: string;
@IsOptional()
@IsString()
@MaxLength(100)
region?: string;
@IsOptional()
@IsString()
@MaxLength(100)
zone?: string;
@IsOptional()
@IsString()
@MaxLength(100)
woreda?: string;
@IsOptional()
@IsString()
@MaxLength(100)
kebele?: string;
@IsOptional()
@IsString()
@MaxLength(100)
houseNo?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
etradePhone?: string;
}

View File

@@ -17,6 +17,14 @@ export enum ProfileStatus {
Blacklisted = "blacklisted",
}
/** A business-license document stored directly on the company profile. */
export interface BusinessLicenseFile {
name: string;
url: string;
size: number;
mimeType?: string;
}
@Entity({ schema: "freight", name: "company_profiles" })
@Index(["reference"], { unique: true })
@Index(["type"])
@@ -57,6 +65,14 @@ export class CompanyProfile extends BaseEntity {
})
businessLicense?: string | null;
/**
* Business-license documents for this profile, stored directly on the profile
* (multi-file). The bytes live in object storage; only the metadata/URLs are
* persisted here — this is intentionally NOT modelled via the FileRecord table.
*/
@Column({ name: "business_license_files", type: "jsonb", nullable: true })
businessLicenseFiles?: BusinessLicenseFile[] | null;
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
}

View File

@@ -17,6 +17,11 @@ export enum CompanyStatus {
Blacklisted = "blacklisted",
}
export enum CompanyNationality {
Ethiopian = "ethiopian",
Foreign = "foreign",
}
@Entity({ schema: "freight", name: "companies" })
@Index(["tin"])
@Index(["type"])
@@ -47,6 +52,16 @@ export class Company extends BaseEntity {
@Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" })
country!: string;
/** Whether the company is Ethiopian or Foreign — drives the required onboarding documents. */
@Column({
name: "nationality",
type: "varchar",
length: 32,
nullable: true,
enum: CompanyNationality,
})
nationality?: CompanyNationality | null;
@Column({ name: "address", type: "text", nullable: true })
address?: string | null;
@@ -102,6 +117,67 @@ export class Company extends BaseEntity {
@Column({ name: "attributes", type: "jsonb", nullable: true })
attributes?: Record<string, any> | null;
@Column({
name: "licence_number",
type: "varchar",
length: 100,
nullable: true,
})
licenceNumber?: string | null;
@Column({ name: "status_description", type: "text", nullable: true })
statusDescription?: string | null;
@Column({
name: "date_registered",
type: "varchar",
length: 50,
nullable: true,
})
dateRegistered?: string | null;
@Column({
name: "renewed_from",
type: "varchar",
length: 50,
nullable: true,
})
renewedFrom?: string | null;
@Column({
name: "renewal_date",
type: "varchar",
length: 50,
nullable: true,
})
renewalDate?: string | null;
@Column({
name: "renewed_to",
type: "varchar",
length: 50,
nullable: true,
})
renewedTo?: string | null;
@Column({ name: "region", type: "varchar", length: 100, nullable: true })
region?: string | null;
@Column({ name: "zone", type: "varchar", length: 100, nullable: true })
zone?: string | null;
@Column({ name: "woreda", type: "varchar", length: 100, nullable: true })
woreda?: string | null;
@Column({ name: "kebele", type: "varchar", length: 100, nullable: true })
kebele?: string | null;
@Column({ name: "house_no", type: "varchar", length: 100, nullable: true })
houseNo?: string | null;
@Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true })
etradePhone?: string | null;
@OneToMany(() => ExternalProfile, (profile) => profile.company)
profiles?: ExternalProfile[];

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { Company } from './company.entity';
import { ProfileType } from './company-profile.entity';
@Entity({ schema: 'freight', name: 'external_profiles' })
@Index(['userId'])
@@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity {
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
isPrimaryContact!: boolean;
/**
* The operational profile the user is currently "in" (importer vs exporter,
* or the single forwarder profile). Drives header switching and scopes the
* customer's bookings / dashboard to that company_profile. Nullable for
* users who haven't picked a role yet.
*/
@Column({
name: 'active_profile_type',
type: 'varchar',
length: 32,
nullable: true,
enum: ProfileType,
})
activeProfileType?: ProfileType | null;
/** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */
@Column({
name: 'onboarding_step',
type: 'varchar',
length: 40,
nullable: true,
})
onboardingStep?: string | null;
@Column({ name: 'onboarding_completed', type: 'boolean', default: false })
onboardingCompleted!: boolean;
}

View File

@@ -0,0 +1,113 @@
import { Injectable, BadRequestException } from "@nestjs/common";
import { HttpService } from "@nestjs/axios";
import { Agent } from "https";
import { firstValueFrom } from "rxjs";
import {
ETradeCompanyInfo,
ETradeBusinessInfo,
CompanyRegistrationData,
} from "@edr/types";
@Injectable()
export class ETradeService {
private readonly baseUrl = "https://etrade.gov.et/api";
private readonly referer = "https://etrade.gov.et/business-license-checker";
/**
* The eTrade server serves an incomplete TLS chain (it omits the intermediate
* CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT.
* Scope a relaxed agent to these outbound calls only — the rest of the app
* keeps full certificate verification.
*/
private readonly httpsAgent = new Agent({ rejectUnauthorized: false });
constructor(private readonly httpService: HttpService) {}
async getCompanyInfoByTin(tin: string): Promise<ETradeCompanyInfo> {
const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`;
try {
const response = await firstValueFrom(
this.httpService.get<ETradeCompanyInfo>(url, {
headers: { Referer: this.referer },
httpsAgent: this.httpsAgent,
}),
);
return response.data;
} catch (error: any) {
throw new BadRequestException(
`Failed to fetch company info from eTrade: ${error.message}`,
);
}
}
async getBusinessByLicenseNo(
licenseNo: string,
tin: string,
): Promise<ETradeBusinessInfo> {
const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`;
try {
const response = await firstValueFrom(
this.httpService.get<ETradeBusinessInfo>(url, {
params: {
LicenseNo: licenseNo,
Tin: tin,
Lang: "en",
},
headers: { Referer: this.referer },
httpsAgent: this.httpsAgent,
}),
);
return response.data;
} catch (error: any) {
throw new BadRequestException(
`Failed to fetch business info from eTrade: ${error.message}`,
);
}
}
async resolveCompanyData(tin: string): Promise<{
companyInfo: ETradeCompanyInfo;
businessInfo: ETradeBusinessInfo | null;
}> {
const companyInfo = await this.getCompanyInfoByTin(tin);
if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) {
return { companyInfo, businessInfo: null };
}
const latestBusiness = companyInfo.Businesses[0];
try {
const businessInfo = await this.getBusinessByLicenseNo(
latestBusiness.LicenceNumber,
tin,
);
return { companyInfo, businessInfo };
} catch {
return { companyInfo, businessInfo: null };
}
}
extractRegistrationData(
businessInfo: ETradeBusinessInfo,
): CompanyRegistrationData {
const primaryManager = businessInfo.AssociateShortInfos?.[0];
return {
licenceNumber: businessInfo.LicenceNumber,
statusDescription: businessInfo.StatusDescription,
dateRegistered: businessInfo.DateRegistered,
renewedFrom: businessInfo.RenewedFrom,
renewalDate: businessInfo.RenewalDate,
renewedTo: businessInfo.RenewedTo,
region: businessInfo.AddressInfo?.Region || "",
zone: businessInfo.AddressInfo?.Zone || "",
woreda: businessInfo.AddressInfo?.Woreda || "",
kebele: businessInfo.AddressInfo?.Kebele || "",
houseNo: businessInfo.AddressInfo?.HouseNo || "",
mobilePhone: businessInfo.AddressInfo?.MobilePhone || "",
regularPhone: businessInfo.AddressInfo?.RegularPhone || "",
managerName: primaryManager?.ManagerNameEng || "",
managerPhone: primaryManager?.RegularPhone || "",
};
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
export class CreateFirstMileDto {
@ApiProperty({ description: 'Booking this first-mile leg belongs to (FK → bookings.id)' })
@IsUUID()
bookingId!: string;
@ApiPropertyOptional({
enum: FIRST_MILE_STATUSES,
default: 'PAYMENT_PENDING',
})
@IsOptional()
@IsIn(FIRST_MILE_STATUSES as unknown as string[])
status?: FirstMileStatus;
@ApiPropertyOptional({ description: 'Amount already paid in advance', example: 4200 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
advancedPayment?: number;
@ApiPropertyOptional({ description: 'Outstanding balance to be collected', example: 1800 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
remainingPayment?: number;
@ApiPropertyOptional({ description: 'Planned distance for the leg, in km', example: 42.5 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
estimatedKm?: number;
@ApiPropertyOptional({ description: 'Actual distance travelled, in km', example: 44.1 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
exactKm?: number;
@ApiPropertyOptional({
type: String,
format: 'uuid',
description: 'Assigned vehicle (FK → vehicles.id). May be null until assigned.',
nullable: true,
})
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFirstMileDto } from './create-first-mile.dto';
export class UpdateFirstMileDto extends PartialType(CreateFirstMileDto) {}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export const FIRST_MILE_STATUSES = [
'PAYMENT_PENDING',
'READY_TO_TRANSIT',
'IN_TRANSIT',
'RECEIVED_TO_PORT',
] as const;
export type FirstMileStatus = (typeof FIRST_MILE_STATUSES)[number];
@Entity({ name: 'first_mile', schema: 'freight' })
@Index(['bookingId'])
@Index(['status'])
@Index(['vehicleId'])
export class FirstMile extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { nullable: false, eager: false })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: FirstMileStatus;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;
@Column({ name: 'exact_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
exactKm?: number | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service';
@ApiTags('first-mile')
@ApiBearerAuth()
@Controller('first-mile')
@TrainSchedulingView()
export class FirstMileController {
constructor(private readonly firstMileService: FirstMileService) {}
@Get()
@ApiOperation({ summary: 'List first-mile legs' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('vehicleId') vehicleId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.firstMileService.findAll({
status: status as FirstMileStatus | undefined,
bookingId,
vehicleId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a first-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.findById(id);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a first-mile leg' })
create(@Body() dto: CreateFirstMileDto) {
return this.firstMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a first-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
return this.firstMileService.update(id, dto);
}
@Delete(':id')
@TrainSchedulingManage()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a first-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FirstMile } from './entities/first-mile.entity';
import { FirstMileController } from './first-mile.controller';
import { FirstMileRepository } from './first-mile.repository';
import { FirstMileService } from './first-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([FirstMile])],
controllers: [FirstMileController],
providers: [FirstMileRepository, FirstMileService],
exports: [FirstMileRepository, FirstMileService],
})
export class FirstMileModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { FirstMile } from './entities/first-mile.entity';
@Injectable()
export class FirstMileRepository extends BaseRepository<FirstMile> {
constructor(
@InjectRepository(FirstMile)
repository: Repository<FirstMile>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileRepository } from './first-mile.repository';
type FirstMileListFilter = {
status?: FirstMileStatus;
bookingId?: string;
vehicleId?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
const SORTABLE_FIELDS: (keyof FirstMile)[] = [
'status',
'advancedPayment',
'remainingPayment',
'createdAt',
];
@Injectable()
export class FirstMileService {
constructor(private readonly firstMileRepository: FirstMileRepository) {}
async findAll(filter: FirstMileListFilter = {}): Promise<{
data: FirstMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof FirstMile)
? (filter.sortBy as keyof FirstMile)
: 'createdAt';
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const where: FindOptionsWhere<FirstMile> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
const [data, total] = await this.firstMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
});
if (!record) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
return record;
}
async create(dto: CreateFirstMileDto): Promise<FirstMile> {
return this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'PAYMENT_PENDING',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
});
}
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
await this.findById(id);
const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.firstMileRepository.softDelete(id);
}
}

View File

@@ -0,0 +1,60 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity';
const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
export class CreateLastMileDto {
@ApiProperty({ description: 'Booking this last-mile leg belongs to (FK → bookings.id)' })
@IsUUID()
bookingId!: string;
@ApiPropertyOptional({
enum: LAST_MILE_STATUSES,
default: 'PAYMENT_PENDING',
})
@IsOptional()
@IsIn(LAST_MILE_STATUSES as unknown as string[])
status?: LastMileStatus;
@ApiPropertyOptional({ description: 'Amount already paid in advance', example: 4200 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
advancedPayment?: number;
@ApiPropertyOptional({ description: 'Outstanding balance to be collected', example: 1800 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
remainingPayment?: number;
@ApiPropertyOptional({ description: 'Planned distance for the leg, in km', example: 42.5 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
estimatedKm?: number;
@ApiPropertyOptional({ description: 'Actual distance travelled, in km', example: 44.1 })
@IsOptional()
@Transform(toNumber)
@IsNumber()
@Min(0)
exactKm?: number;
@ApiPropertyOptional({
type: String,
format: 'uuid',
description: 'Assigned vehicle (FK → vehicles.id). May be null until assigned.',
nullable: true,
})
@IsOptional()
@IsUUID()
vehicleId?: string | null;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateLastMileDto } from './create-last-mile.dto';
export class UpdateLastMileDto extends PartialType(CreateLastMileDto) {}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export const LAST_MILE_STATUSES = [
'PAYMENT_PENDING',
'READY_TO_TRANSIT',
'IN_TRANSIT',
'DELIVERED',
] as const;
export type LastMileStatus = (typeof LAST_MILE_STATUSES)[number];
@Entity({ name: 'last_mile', schema: 'freight' })
@Index(['bookingId'])
@Index(['status'])
@Index(['vehicleId'])
export class LastMile extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { nullable: false, eager: false })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'status', type: 'varchar', length: 30, default: 'PAYMENT_PENDING' })
status!: LastMileStatus;
@Column({ name: 'advanced_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
advancedPayment!: number;
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number;
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
estimatedKm?: number | null;
@Column({ name: 'exact_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
exactKm?: number | null;
@Column({ name: 'vehicle_id', type: 'uuid', nullable: true })
vehicleId?: string | null;
@ManyToOne(() => Vehicle, { nullable: true, eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle?: Vehicle | null;
}

View File

@@ -0,0 +1,79 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { TrainSchedulingManage, TrainSchedulingView } from '../../common/booking-guards';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service';
@ApiTags('last-mile')
@ApiBearerAuth()
@Controller('last-mile')
@TrainSchedulingView()
export class LastMileController {
constructor(private readonly lastMileService: LastMileService) {}
@Get()
@ApiOperation({ summary: 'List last-mile legs' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('vehicleId') vehicleId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('sortBy') sortBy?: string,
@Query('sortOrder') sortOrder?: 'ASC' | 'DESC',
) {
return this.lastMileService.findAll({
status: status as LastMileStatus | undefined,
bookingId,
vehicleId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
sortBy,
sortOrder,
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a last-mile leg by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.findById(id);
}
@Post()
@TrainSchedulingManage()
@ApiOperation({ summary: 'Create a last-mile leg' })
create(@Body() dto: CreateLastMileDto) {
return this.lastMileService.create(dto);
}
@Patch(':id')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Update a last-mile leg' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
return this.lastMileService.update(id, dto);
}
@Delete(':id')
@TrainSchedulingManage()
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a last-mile leg' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.lastMileService.remove(id);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LastMile } from './entities/last-mile.entity';
import { LastMileController } from './last-mile.controller';
import { LastMileRepository } from './last-mile.repository';
import { LastMileService } from './last-mile.service';
@Module({
imports: [TypeOrmModule.forFeature([LastMile])],
controllers: [LastMileController],
providers: [LastMileRepository, LastMileService],
exports: [LastMileRepository, LastMileService],
})
export class LastMileModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { LastMile } from './entities/last-mile.entity';
@Injectable()
export class LastMileRepository extends BaseRepository<LastMile> {
constructor(
@InjectRepository(LastMile)
repository: Repository<LastMile>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,113 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { CreateLastMileDto } from './dto/create-last-mile.dto';
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileRepository } from './last-mile.repository';
type LastMileListFilter = {
status?: LastMileStatus;
bookingId?: string;
vehicleId?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
const SORTABLE_FIELDS: (keyof LastMile)[] = [
'status',
'advancedPayment',
'remainingPayment',
'createdAt',
];
@Injectable()
export class LastMileService {
constructor(private readonly lastMileRepository: LastMileRepository) {}
async findAll(filter: LastMileListFilter = {}): Promise<{
data: LastMile[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const sortBy = SORTABLE_FIELDS.includes(filter.sortBy as keyof LastMile)
? (filter.sortBy as keyof LastMile)
: 'createdAt';
const sortOrder = filter.sortOrder?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const where: FindOptionsWhere<LastMile> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
if (filter.vehicleId) where.vehicleId = filter.vehicleId;
const [data, total] = await this.lastMileRepository.findAndCount({
where,
relations: { booking: true, vehicle: true },
order: { [sortBy]: sortOrder },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
}
async findById(id: string): Promise<LastMile> {
const record = await this.lastMileRepository.findById(id, {
relations: { booking: true, vehicle: true },
});
if (!record) {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
return record;
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'PAYMENT_PENDING',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
});
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
await this.findById(id);
const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}),
...(dto.advancedPayment !== undefined ? { advancedPayment: dto.advancedPayment } : {}),
...(dto.remainingPayment !== undefined ? { remainingPayment: dto.remainingPayment } : {}),
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
});
if (!updated) {
throw new NotFoundException(`Last-mile record ${id} not found`);
}
return updated;
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.lastMileRepository.softDelete(id);
}
}

View File

@@ -4,6 +4,7 @@ import {
Get,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
Res,
@@ -33,6 +34,14 @@ import {
export class PaymentController {
constructor(private readonly paymentService: PaymentService) { }
@Get("by-company/:companyId/customer-view")
@ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" })
findByCompanyCustomerView(
@Param("companyId", ParseUUIDPipe) companyId: string,
) {
return this.paymentService.findByCompanyId(companyId);
}
@Get("summary")
@BookingView()
@ApiOperation({ summary: "Payment count/amount summary for dashboard cards" })

View File

@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
import { InternalPaymentController } from "./internal-payment.controller";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
@@ -54,6 +55,7 @@ function rabbitMQImport(): DynamicModule[] {
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
DropdownSettingsModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
...rabbitMQImport(),

View File

@@ -61,4 +61,56 @@ export class PaymentRepository {
return this.paymentRepo.createQueryBuilder(alias);
}
async findByCompanyId(companyId: string): Promise<{
id: string;
merchantOrderId: string;
bookingReference: string;
amount: number;
currency: string;
method: string;
status: string;
paidAt: Date | null;
createdAt: Date;
}[]> {
const rows: {
id: string;
merchant_order_id: string;
booking_reference: string;
amount: number;
currency: string;
method: string;
status: string;
paid_at: Date | null;
created_at: Date;
}[] = await this.dataSource.query(
`SELECT p.id,
p.merchant_order_id,
b.reference AS booking_reference,
p.amount,
p.currency,
p.method,
p.status,
p.paid_at,
p.created_at
FROM freight.payments p
JOIN freight.bookings b ON b.id = p.ref_id
WHERE b.company_id = $1
AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`,
[companyId],
);
return rows.map((r) => ({
id: r.id,
merchantOrderId: r.merchant_order_id,
bookingReference: r.booking_reference,
amount: Number(r.amount),
currency: r.currency,
method: r.method,
status: r.status,
paidAt: r.paid_at,
createdAt: r.created_at,
}));
}
}

View File

@@ -34,6 +34,11 @@ import {
RefundDto,
} from "./payments.dto";
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
/** Setting code holding the global ordering window (months) for general contracts. */
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
@@ -54,8 +59,23 @@ export class PaymentService {
private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly dropdownSettings: DropdownSettingsService,
) { }
/** Configured general-contract ordering window in months (defaults to 3). */
private async contractPeriodMonths(): Promise<number> {
try {
const setting = await this.dropdownSettings.getByCode(
CONTRACT_PERIOD_SETTING_CODE,
);
const months = Number(setting.children?.[0]?.value);
if (Number.isFinite(months) && months > 0) return months;
} catch {
// Setting not seeded — fall back to the default.
}
return DEFAULT_CONTRACT_PERIOD_MONTHS;
}
async getAll(filters: {
search?: string;
status?: string;
@@ -293,15 +313,44 @@ export class PaymentService {
const paidAt = input.paidAt ?? new Date();
// A general contract is paid once, up front; it does NOT enter the train
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
// opens its ordering window. Orders placed later spawn their own paid
// child bookings that go through the normal pipeline.
const booking = await this.datasource
.getRepository(Booking)
.findOne({ where: { id: input.bookingId } });
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
let contractExpiresAt: Date | null = null;
if (isGeneralContract) {
const months = await this.contractPeriodMonths();
contractExpiresAt = new Date(paidAt);
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
}
await this.datasource.transaction(async (mg) => {
await mg.update(
PaymentEntity,
{ id: intent.id },
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
);
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
await mg.update(
Booking,
{ id: input.bookingId },
isGeneralContract
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
: { paymentStatus: "PAID", status: "PAID" },
);
});
if (isGeneralContract) {
this.logger.log(
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
);
return { alreadyFinalized: false };
}
try {
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
} catch (err) {
@@ -428,4 +477,8 @@ export class PaymentService {
default: return "action-required";
}
}
async findByCompanyId(companyId: string) {
return this.paymentRepo.findByCompanyId(companyId);
}
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { CargoUnitOfMeasure } from '@edr/types';
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class CreateCargoTypeDto {
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
@@ -7,6 +8,14 @@ export class CreateCargoTypeDto {
@MaxLength(255)
cargoTypeName!: string;
@ApiPropertyOptional({
enum: CargoUnitOfMeasure,
description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)',
})
@IsOptional()
@IsEnum(CargoUnitOfMeasure)
unitOfMeasure?: CargoUnitOfMeasure;
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
@IsOptional()
@IsUUID()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
@Entity({ schema: 'freight', name: 'cargo_types' })
@@ -19,6 +20,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
showFreeTextBox!: boolean;
/**
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
* (break-bulk). Nullable for container/legacy cargo, which is counted by
* container. Drives the unit shown when ordering against a general contract.
*/
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -82,6 +82,7 @@ export class CargoTypesService {
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
displayOrder,
});
}

View File

@@ -1,7 +1,9 @@
export interface SchedulingPriorityBooking {
isGovernment?: boolean;
priorityScore?: number | null;
scheduledDate: Date | string;
// One-time bookings always carry a date; general contracts (never scheduled)
// may be null — treated as epoch 0 so they sort last.
scheduledDate?: Date | string | null;
}
/** Government first, then priority score, then earliest scheduled date. */
@@ -15,5 +17,7 @@ export function compareSchedulingPriority(
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
return aTime - bTime;
}

View File

@@ -3,10 +3,11 @@
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
*/
/** Batch boundaries — every 3h from 07:00 (the 07:0010:00 intake settles at 10:00, etc.). */
/** Batch boundaries — every 3h from 00:00 (0003, 0306, … 2124), matching the board windows. */
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
// export const BATCH_CRON = '*/3 * * * *';
export const BATCH_CRON = '*/5 * * * *';
// export const BATCH_CRON = '0 */3 * * *';//
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';

View File

@@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
if (priorityDiff !== 0) return priorityDiff;
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
return aTime - bTime;
});
}

View File

@@ -1893,7 +1893,7 @@ export class TrainSchedulingService {
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
preferredDepartureDate: booking.scheduledDate.toISOString(),
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
status: booking.status,
};
}
@@ -2165,6 +2165,7 @@ export class TrainSchedulingService {
weightTons: roundTons(Number(sb.booking?.cargoTotalWeightVgm ?? 0)),
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
})) ?? [],
};
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsEnum, IsNumber, IsOptional } from 'class-validator';
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@@ -29,4 +29,12 @@ export class CreateVehicleDto {
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsUUID()
assignedDriverId?: string;
@IsOptional()
@IsString()
assignedDriverName?: string;
}

View File

@@ -1,4 +1,4 @@
import { Entity, Column, Index } from 'typeorm';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum VehicleType {
@@ -26,39 +26,40 @@ export enum VehicleStatus {
}
@Entity({ name: 'vehicles', schema: 'freight' })
@Index(['plateNumber'])
@Index(['registrationNumber'])
@Index(['status'])
@Index(['vehicleType'])
@Index(['manufacturer'])
export class Vehicle extends BaseEntity {
@Column({ name: 'plate_number', unique: true })
plateNumber!: string;
@Column({ name: 'plate_number', unique: true, nullable: true })
plateNumber?: string;
@Column({ name: 'registration_number', unique: true })
registrationNumber!: string;
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
@Column({ name: 'vehicle_type', type: 'varchar' })
vehicleType!: VehicleType;
@Column({ name: 'vehicle_type', type: 'varchar', nullable: true })
vehicleType?: VehicleType;
@Column()
manufacturer!: string;
@Column({ nullable: true })
manufacturer?: string;
@Column()
model!: string;
@Column({ nullable: true })
model?: string;
@Column()
year!: number;
@Column({ nullable: true })
year?: number;
@Column({ name: 'fuel_type', type: 'varchar' })
fuelType!: FuelType;
@Column({ name: 'fuel_type', type: 'varchar', nullable: true })
fuelType?: FuelType;
@Column()
capacity!: number;
@Column({ nullable: true })
capacity?: number;
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE })
status!: VehicleStatus;
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true })
status?: VehicleStatus;
@Column({ type: 'text', nullable: true })
description!: string | null;
description?: string | null;
@Column({ name: 'assigned_driver_id', type: 'uuid', nullable: true })
assignedDriverId?: string;
@Column({ name: 'assigned_driver_name', nullable: true })
assignedDriverName?: string;
}

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { Vehicle } from './entities/vehicle.entity';
@Injectable()

View File

@@ -0,0 +1,23 @@
import { AppDataSource } from "../data-source";
import { FileUploadSettingsSeeder } from "../seed/file-upload-settings.seeder";
/**
* Idempotently (re)seed the company onboarding file-upload settings, including
* the nationality-based document sets (ethiopian / foreign). Run on demand:
* pnpm --filter @edr/freight-api seed:file-upload-settings
*/
async function run() {
await AppDataSource.initialize();
try {
const seeder = new FileUploadSettingsSeeder(AppDataSource);
await seeder.run();
console.log("Seeded company onboarding file-upload settings.");
} finally {
await AppDataSource.destroy();
}
}
run().catch((error) => {
console.error("Failed to seed file-upload settings:", error);
process.exit(1);
});

View File

@@ -4,33 +4,107 @@ import { DataSource } from "typeorm";
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
const COMPANY_ONBOARDING_DOCUMENTS = [
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
},
] as const;
interface OnboardingField {
fileKey: string;
fileLabel: string;
helpText: string;
isRequired: boolean;
isMultiple: boolean;
maxFiles: number;
allowedExtensions: string[];
maxSizeMb: number;
displayOrder: number;
}
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"];
const COMPANY_ONBOARDING_FIELDS = [
/** Documents required from an Ethiopian company at onboarding. */
const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "commercial_license",
fileLabel: "Commercial License",
helpText: "Verified against the government trade system during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "Verified against the National ID API during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
];
/** Documents required from a Foreign company at onboarding. */
const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Verified against the TIN registry during registration.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
{
fileKey: "investment_license",
fileLabel: "Investment License",
helpText: "Investment license issued for operating in Ethiopia.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
{
fileKey: "national_id",
fileLabel: "National ID",
helpText: "National ID of the company's authorized representative.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
{
fileKey: "passport",
fileLabel: "Passport",
helpText: "Passport of the company's authorized representative.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 4,
},
];
/** Legacy combined set, kept for the older per-company-type codes. */
const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [
{
fileKey: "business_license",
fileLabel: "Business License / Trade License",
@@ -38,7 +112,7 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 1,
},
@@ -49,7 +123,7 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 2,
},
@@ -60,11 +134,63 @@ const COMPANY_ONBOARDING_FIELDS = [
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
allowedExtensions: DOC_EXTENSIONS,
maxSizeMb: 10,
displayOrder: 3,
},
] as const;
];
interface OnboardingDocumentSetting {
code: string;
label: string;
entity: string;
fields: OnboardingField[];
}
const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [
// Nationality-based sets — the document requirements depend only on whether
// the company is Ethiopian or Foreign (same for importer/exporter/forwarder).
{
code: "company_onboarding_documents_ethiopian",
label: "Ethiopian company onboarding documents",
entity: "customer",
fields: ETHIOPIAN_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_foreign",
label: "Foreign company onboarding documents",
entity: "customer",
fields: FOREIGN_ONBOARDING_FIELDS,
},
// Legacy per-company-type codes (kept for back-compat; no longer used by the portal).
{
code: "company_onboarding_documents_customer",
label: "Customer onboarding documents",
entity: "customer",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_forwarder",
label: "Forwarder onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_transporter",
label: "Transporter onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
{
code: "company_onboarding_documents_forwarder_dj",
label: "Djibouti forwarder onboarding documents",
entity: "other",
fields: LEGACY_ONBOARDING_FIELDS,
},
];
const COMPANY_ONBOARDING_DESCRIPTION =
"Required documents for external company onboarding, by company nationality.";
@Injectable()
export class FileUploadSettingsSeeder {
@@ -102,7 +228,7 @@ export class FileUploadSettingsSeeder {
await fieldRepository.delete({ settingId: setting.id });
await fieldRepository.insert(
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
documentSetting.fields.map((field, index) => ({
settingId: setting.id,
fileKey: field.fileKey,
fileLabel: field.fileLabel,

View File

@@ -1,6 +1,24 @@
@import "tailwindcss";
@import "@edr/ui-common/theme.css" layer(theme);
/* Bridge the central Mantine theme into Tailwind. freightMantineTheme
(createTheme) is the single source of truth; these just alias its generated
CSS variables so `bg-edr-*`, `text-edr-*`, `border-edr-*` utilities resolve
to the same tokens used by Mantine props. Mirrors edr-freight-web/portal. */
@theme {
--color-edr-primary: var(--mantine-color-edr-green-5);
--color-edr-primary-dark: var(--mantine-color-edr-green-7);
--color-edr-bg: var(--mantine-color-edr-bg-6);
--color-edr-card: var(--mantine-color-edr-card-6);
--color-edr-border: var(--mantine-color-edr-border-6);
--color-edr-divider: var(--mantine-color-edr-divider-6);
--color-edr-text: var(--mantine-color-edr-text-6);
--color-edr-muted: var(--mantine-color-edr-muted-6);
--color-edr-soft: var(--mantine-color-edr-soft-6);
--color-edr-ink: var(--mantine-color-edr-ink-6);
--color-edr-accent: var(--mantine-color-edr-accent-6);
}
:root {
--freight-brand: #1B9E7A;
--freight-brand-dark: #15805F;

View File

@@ -9,7 +9,7 @@
"preview": "vite preview --port 5183",
"lint": "eslint src",
"test": "vitest run",
"type-check": "tsc --noEmit"
"type-check": "tsc -b"
},
"dependencies": {
"@edr/types": "workspace:*",
@@ -19,7 +19,7 @@
"@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui": "0.0.3",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -32,6 +32,7 @@
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"sonner": "^2.0.7",
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
"tinymce": "^8.6.0",
"zustand": "^5.0.0"

Some files were not shown because too many files have changed in this diff Show More