mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,7 +24,6 @@ coverage/
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
\#*\#
|
||||
|
||||
6
.gitmodules
vendored
6
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
@@ -44,6 +45,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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";
|
||||
@@ -47,6 +48,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -59,6 +64,9 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
|
||||
import { FacilitiesModule } from './modules/facilities/facilities.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: [
|
||||
@@ -87,6 +95,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
permissions: EDR_FREIGHT_PERMISSIONS,
|
||||
}),
|
||||
BookingsModule,
|
||||
BookingOrdersModule,
|
||||
SignaturesModule,
|
||||
FilesModule,
|
||||
ConsignmentsModule,
|
||||
@@ -118,6 +127,9 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
WarehousesModule,
|
||||
OverviewModule,
|
||||
VehiclesModule,
|
||||
DriversModule,
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -129,6 +141,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
DemoFreightDataSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -137,6 +153,14 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -147,6 +171,16 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
await this.batch5TestDataSeeder.run();
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -39,7 +39,16 @@ async function bootstrap() {
|
||||
});
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
app.useGlobalPipes(createValidationPipe());
|
||||
// enableImplicitConversion is OFF: class-transformer's implicit boolean
|
||||
// coercion turns any non-empty multipart/form-data string (including the
|
||||
// literal "false") into `true`, silently corrupting flags like isHazardous
|
||||
// and isGovernment. With it off, only explicit @Transform/@Type decorators
|
||||
// coerce values — every numeric/boolean DTO field in this API already has one.
|
||||
app.useGlobalPipes(
|
||||
createValidationPipe({
|
||||
transformOptions: { enableImplicitConversion: false },
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseTransformInterceptor());
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateDriversTable1775000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'drivers' AND table_schema = 'freight') THEN
|
||||
CREATE TABLE freight.drivers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
license_number VARCHAR NOT NULL UNIQUE,
|
||||
first_name VARCHAR NOT NULL,
|
||||
last_name VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL UNIQUE,
|
||||
phone_number VARCHAR NOT NULL UNIQUE,
|
||||
date_of_birth DATE NOT NULL,
|
||||
license_expiry_date DATE NOT NULL,
|
||||
status VARCHAR DEFAULT 'ACTIVE' NOT NULL,
|
||||
vehicle_types_authorized VARCHAR[],
|
||||
address TEXT,
|
||||
emergency_contact VARCHAR,
|
||||
notes TEXT,
|
||||
total_trips INTEGER DEFAULT 0,
|
||||
rating NUMERIC(3, 2),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
deleted_at TIMESTAMP NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_drivers_license_number ON freight.drivers(license_number);
|
||||
CREATE INDEX idx_drivers_email ON freight.drivers(email);
|
||||
CREATE INDEX idx_drivers_phone_number ON freight.drivers(phone_number);
|
||||
CREATE INDEX idx_drivers_status ON freight.drivers(status);
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.drivers CASCADE;`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
|
||||
* and demurrage lifecycle timestamps on inventory.
|
||||
* and demurrage lifecycle timestamps on inventory. Idempotent.
|
||||
*/
|
||||
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
|
||||
export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. */
|
||||
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */
|
||||
export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Import pickup branch on warehouse_inventory:
|
||||
* - release_order_reference: DO / release order number sent to the customer
|
||||
* - delivered_at: when the goods were handed over (proof of delivery)
|
||||
*
|
||||
* Idempotent: the shared dev DB may already carry some of these columns
|
||||
* (added by another checkout), so only add what is missing.
|
||||
*/
|
||||
export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }),
|
||||
);
|
||||
}
|
||||
if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'release_order_reference')) {
|
||||
await queryRunner.dropColumn(this.table, 'release_order_reference');
|
||||
}
|
||||
if (await queryRunner.hasColumn(this.table, 'delivered_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'delivered_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 8 — train-arrival unload landing state on warehouse_inventory:
|
||||
* - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection)
|
||||
*
|
||||
* The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change.
|
||||
* Idempotent: the shared dev DB may already carry this column (added by another checkout).
|
||||
*/
|
||||
export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'unloaded_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Consolidation is now system-managed: the backend consolidates partial-wagon
|
||||
* container bookings automatically, derived from the container quantities. The
|
||||
* `allow_consolidation` opt-in flag is therefore redundant and is dropped.
|
||||
* `consolidation_partner_id` (the actual pairing link) is unaffected.
|
||||
*/
|
||||
export class DropAllowConsolidation1820000000000 implements MigrationInterface {
|
||||
name = 'DropAllowConsolidation1820000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS allow_consolidation;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS allow_consolidation BOOLEAN NOT NULL DEFAULT false;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Multi-route general contracts: a contract may reserve quantity across several
|
||||
* routes. Each (contract, route, container type) is a row here; drawdown orders
|
||||
* reference the route line they drew from via booking_orders.route_line_id.
|
||||
*/
|
||||
export class CreateContractRouteLines1820000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateContractRouteLines1820000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'contract_route_lines',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'contract_booking_id', type: 'uuid' },
|
||||
{ name: 'origin_yard_id', type: 'uuid' },
|
||||
{ name: 'destination_yard_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: ['contract_booking_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'bookings',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.contract_route_lines',
|
||||
new TableIndex({
|
||||
name: 'idx_contract_route_lines_contract',
|
||||
columnNames: ['contract_booking_id'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_orders ADD COLUMN IF NOT EXISTS route_line_id uuid;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_orders DROP COLUMN IF EXISTS route_line_id;`,
|
||||
);
|
||||
await queryRunner.dropTable('freight.contract_route_lines', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Per-document GL review for the post-counter-sign clearance gate. One row per
|
||||
* required clearance document; GL marks each APPROVED or QUERIED before the
|
||||
* booking can proceed to operations.
|
||||
*/
|
||||
export class CreateBookingDocumentReview1820000000002
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateBookingDocumentReview1820000000002';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'booking_document_review',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'setting_code', type: 'varchar', length: '128' },
|
||||
{ name: 'file_key', type: 'varchar', length: '128' },
|
||||
{ name: 'file_record_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'PENDING'" },
|
||||
{ name: 'note', type: 'text', isNullable: true },
|
||||
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['booking_id'],
|
||||
referencedSchema: 'freight',
|
||||
referencedTableName: 'bookings',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_document_review',
|
||||
new TableIndex({ name: 'idx_booking_document_review_booking', columnNames: ['booking_id'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_document_review',
|
||||
new TableIndex({ name: 'idx_booking_document_review_status', columnNames: ['status'] }),
|
||||
);
|
||||
await queryRunner.createIndex(
|
||||
'freight.booking_document_review',
|
||||
new TableIndex({
|
||||
name: 'uq_booking_document_review_doc',
|
||||
columnNames: ['booking_id', 'setting_code', 'file_key'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.booking_document_review', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Staff price adjustment: an optional override of a booking's computed total,
|
||||
* with who/when/why. When set, the customer sees the adjusted total + a badge.
|
||||
*/
|
||||
export class AddPriceAdjustment1820000000003 implements MigrationInterface {
|
||||
name = 'AddPriceAdjustment1820000000003';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_total_amount numeric(14,2);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_by_staff_id uuid;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjusted_at timestamptz;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS adjustment_reason text;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjustment_reason;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_by_staff_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS adjusted_total_amount;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Fold the `surcharge_types` table into self-describing rates.
|
||||
*
|
||||
* Previously a surcharge was a separate row {trigger_condition, rate_id}. Now
|
||||
* each rate carries its own `applies_to` (friendly category) and `trigger`
|
||||
* (ALWAYS = base freight, otherwise a surcharge condition), plus an optional
|
||||
* `cargo_type_id` for bulk leaf commodities. The rule engine reads triggers
|
||||
* directly off LIVE rates, so the join table is no longer needed.
|
||||
*
|
||||
* This migration:
|
||||
* 1. adds applies_to / trigger / cargo_type_id to rates and backfills them
|
||||
* from the existing rate_type matrix,
|
||||
* 2. repoints booking_cargo_modifier from surcharge_type_id → rate_id
|
||||
* (backfilled via surcharge_types.rate_id),
|
||||
* 3. drops surcharge_types and its FK.
|
||||
*/
|
||||
export class FoldSurchargeTypesIntoRates1820000000004 implements MigrationInterface {
|
||||
name = 'FoldSurchargeTypesIntoRates1820000000004';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── 1. New rate columns ────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD COLUMN IF NOT EXISTS applies_to varchar(20) NOT NULL DEFAULT 'OTHER',
|
||||
ADD COLUMN IF NOT EXISTS "trigger" varchar(20) NOT NULL DEFAULT 'ALWAYS',
|
||||
ADD COLUMN IF NOT EXISTS cargo_type_id uuid NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
ADD CONSTRAINT "FK_rates_cargo_type_id"
|
||||
FOREIGN KEY (cargo_type_id) REFERENCES freight.cargo_types(id)
|
||||
ON DELETE SET NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_rates_trigger" ON freight.rates ("trigger");`,
|
||||
);
|
||||
|
||||
// ── 1a. Backfill applies_to from the legacy rate_type matrix ────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates SET applies_to = CASE
|
||||
WHEN rate_type IN ('CONTAINER_IMPORT','CONTAINER_EXPORT','CONTAINER_WITH_RETURN') THEN 'CONTAINER'
|
||||
WHEN rate_type IN ('BULK_IMPORT','BULK_EXPORT') THEN 'BULK'
|
||||
WHEN rate_type IN ('INTERCITY_CONTAINER','INTERCITY_BULK') THEN 'INTERCITY'
|
||||
WHEN rate_type = 'FIRST_MILE' THEN 'FIRST_MILE'
|
||||
WHEN rate_type = 'LAST_MILE' THEN 'LAST_MILE'
|
||||
ELSE 'OTHER'
|
||||
END;
|
||||
`);
|
||||
|
||||
// ── 1b. Backfill trigger from the legacy rate_type matrix ───────────────
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates SET "trigger" = CASE
|
||||
WHEN rate_type = 'HAZARD_SURCHARGE' THEN 'HAZARDOUS'
|
||||
WHEN rate_type = 'REEFER_SURCHARGE' THEN 'REEFER'
|
||||
WHEN rate_type = 'OVERWEIGHT_PER_TON' THEN 'OVERWEIGHT'
|
||||
WHEN rate_type = 'DOUBLE_HANDLING' THEN 'SHIPPING_LINE'
|
||||
WHEN rate_type = 'LASHING' THEN 'CONSOLIDATION'
|
||||
WHEN rate_type = 'CANCELLATION_FEE' THEN 'CANCELLATION'
|
||||
WHEN rate_type = 'DEMURRAGE' THEN 'DEMURRAGE'
|
||||
WHEN rate_type = 'PIL_EXTRA_FEE' THEN 'PIL_EXTRA_FEE'
|
||||
ELSE 'ALWAYS'
|
||||
END;
|
||||
`);
|
||||
|
||||
// Align the trigger to the actual surcharge_types mapping where one exists
|
||||
// (covers any rate wired as a surcharge with a non-obvious rate_type).
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.rates r SET "trigger" = m.trig
|
||||
FROM (
|
||||
SELECT st.rate_id, CASE st.trigger_condition
|
||||
WHEN 'CARGO_FLAG_HAZARDOUS' THEN 'HAZARDOUS'
|
||||
WHEN 'CARGO_FLAG_REEFER' THEN 'REEFER'
|
||||
WHEN 'VGM_EXCEEDS_LIMIT' THEN 'OVERWEIGHT'
|
||||
WHEN 'SHIPPING_LINE_MAPPED' THEN 'SHIPPING_LINE'
|
||||
WHEN 'CONSOLIDATION_ENABLED' THEN 'CONSOLIDATION'
|
||||
ELSE 'ALWAYS'
|
||||
END AS trig
|
||||
FROM freight.surcharge_types st
|
||||
WHERE st.rate_id IS NOT NULL AND st.deleted_at IS NULL
|
||||
) m
|
||||
WHERE r.id = m.rate_id AND m.trig <> 'ALWAYS';
|
||||
`);
|
||||
|
||||
// ── 2. Repoint booking_cargo_modifier to rate_id ────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD COLUMN IF NOT EXISTS rate_id uuid NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.booking_cargo_modifier bcm
|
||||
SET rate_id = st.rate_id
|
||||
FROM freight.surcharge_types st
|
||||
WHERE bcm.surcharge_type_id = st.id AND st.rate_id IS NOT NULL;
|
||||
`);
|
||||
|
||||
// Rows whose surcharge lost its rate can't be repointed — they reference a
|
||||
// now-defunct surcharge. Remove them so the NOT NULL + FK can be enforced.
|
||||
await queryRunner.query(`
|
||||
DELETE FROM freight.booking_cargo_modifier WHERE rate_id IS NULL;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ALTER COLUMN rate_id SET NOT NULL;
|
||||
`);
|
||||
|
||||
// Drop the old FK + column + index for surcharge_type_id.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_surcharge_type_id";
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_surcharge_type_id";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP COLUMN IF EXISTS surcharge_type_id;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD CONSTRAINT "FK_booking_cargo_modifier_rate_id"
|
||||
FOREIGN KEY (rate_id) REFERENCES freight.rates(id);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_booking_cargo_modifier_rate_id" ON freight.booking_cargo_modifier (rate_id);`,
|
||||
);
|
||||
|
||||
// ── 3. Drop the surcharge_types table ───────────────────────────────────
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.surcharge_types;`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Recreate surcharge_types (structure only — data is not restored).
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.surcharge_types (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code varchar(40) NOT NULL,
|
||||
label varchar(100),
|
||||
trigger_condition varchar(50),
|
||||
rate_id uuid,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_surcharge_types_code" ON freight.surcharge_types (code);`,
|
||||
);
|
||||
|
||||
// Restore booking_cargo_modifier.surcharge_type_id (nullable; not backfilled).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
DROP CONSTRAINT IF EXISTS "FK_booking_cargo_modifier_rate_id";
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_booking_cargo_modifier_rate_id";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier
|
||||
ADD COLUMN IF NOT EXISTS surcharge_type_id uuid NULL;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_cargo_modifier DROP COLUMN IF EXISTS rate_id;
|
||||
`);
|
||||
|
||||
// Drop the new rate columns.
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight."IDX_rates_trigger";`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "FK_rates_cargo_type_id";
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates
|
||||
DROP COLUMN IF EXISTS cargo_type_id,
|
||||
DROP COLUMN IF EXISTS "trigger",
|
||||
DROP COLUMN IF EXISTS applies_to;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Contract validity window. When the backoffice accepts a price-confirmed
|
||||
* booking, staff define how many days the contract stays valid. The window runs
|
||||
* from the accept moment (valid_from) through valid_from + N days (valid_until).
|
||||
* Outside that window the contract is considered expired.
|
||||
*/
|
||||
export class AddContractValidityWindow1820000000005
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddContractValidityWindow1820000000005';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_validity_days integer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_from timestamptz;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_valid_until timestamptz;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_until;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_valid_from;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_validity_days;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Booking now captures:
|
||||
* - customs clearing as an explicit flag + the customs clearing agent name
|
||||
* (shown when the service includes customs), and
|
||||
* - first/last-mile pickup & delivery coordinates (lat/lng) alongside the
|
||||
* existing address text, so the map picker can store and restore the pin.
|
||||
*
|
||||
* Shipping line is no longer collected from the booking form; the column stays
|
||||
* for historical data and the (now dormant) shipping-line pricing trigger.
|
||||
*/
|
||||
export class AddCustomsAgentAndMileCoordinates1820000000006
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddCustomsAgentAndMileCoordinates1820000000006';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS first_mile_pickup_lat numeric(10,7) NULL,
|
||||
ADD COLUMN IF NOT EXISTS first_mile_pickup_lng numeric(10,7) NULL,
|
||||
ADD COLUMN IF NOT EXISTS last_mile_delivery_lat numeric(10,7) NULL,
|
||||
ADD COLUMN IF NOT EXISTS last_mile_delivery_lng numeric(10,7) NULL,
|
||||
ADD COLUMN IF NOT EXISTS customs_clearing_enabled boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS customs_clearing_agent varchar(200) NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP COLUMN IF EXISTS customs_clearing_agent,
|
||||
DROP COLUMN IF EXISTS customs_clearing_enabled,
|
||||
DROP COLUMN IF EXISTS last_mile_delivery_lng,
|
||||
DROP COLUMN IF EXISTS last_mile_delivery_lat,
|
||||
DROP COLUMN IF EXISTS first_mile_pickup_lng,
|
||||
DROP COLUMN IF EXISTS first_mile_pickup_lat;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* General-contract drawdown order fields:
|
||||
* - booking_order_lines.hazardous_quantity / reefer_quantity — per-order counts
|
||||
* the customer enters when toggling hazardous/reefer; drive the surcharge
|
||||
* rates on the spawned child booking.
|
||||
* - bookings.is_reefer — booking-level refrigerated flag so REEFER_SURCHARGE
|
||||
* applies to a contract order even when the container type is not a reefer.
|
||||
* - contract_route_lines.km — road distance configured with the route; road
|
||||
* orders bill KM × the PER_KM rate.
|
||||
*
|
||||
* NOTE: the shared dev DB has no applied migration history, so these columns
|
||||
* are also hand-applied there. ADD COLUMN IF NOT EXISTS keeps that idempotent.
|
||||
*/
|
||||
export class AddGeneralContractOrderFields1820000000010
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddGeneralContractOrderFields1820000000010';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS reefer_quantity numeric(12,3) NOT NULL DEFAULT 0;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS is_reefer boolean NOT NULL DEFAULT false;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_route_lines ADD COLUMN IF NOT EXISTS km numeric(10,2);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_route_lines DROP COLUMN IF EXISTS km;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_reefer;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS reefer_quantity;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS hazardous_quantity;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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('contract/:id/routes')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.',
|
||||
})
|
||||
async routes(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.generalContractService.getRouteLines(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single booking order' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.ordersService.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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 { RuleEngineModule } from '../rule-engine/rule-engine.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 { ContractRouteLine } from './entities/contract-route-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]),
|
||||
BookingsModule,
|
||||
CompaniesModule,
|
||||
DropdownSettingsModule,
|
||||
RuleEngineModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
],
|
||||
controllers: [BookingOrdersController],
|
||||
providers: [
|
||||
BookingOrdersService,
|
||||
BookingOrdersRepository,
|
||||
GeneralContractService,
|
||||
],
|
||||
exports: [BookingOrdersService, GeneralContractService],
|
||||
})
|
||||
export class BookingOrdersModule {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
|
||||
/**
|
||||
* Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that
|
||||
* waits for Marketing review (or the customs clearance gate first) — it does
|
||||
* NOT auto-enter the train batch pool, and the contract is not charged.
|
||||
*/
|
||||
describe('BookingOrdersService — child spawn on order create', () => {
|
||||
function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
bookingType: 'GENERAL_CONTRACT',
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
expiresAt: new Date('2030-01-01T00:00:00.000Z'),
|
||||
freightType: 'BULK',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
companyId: null,
|
||||
paymentCurrency: 'ETB',
|
||||
serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' },
|
||||
bookingContainers: [],
|
||||
};
|
||||
|
||||
// Capture what status the child is created with.
|
||||
const created: Record<string, unknown>[] = [];
|
||||
const managerUpdates: Record<string, unknown>[] = [];
|
||||
const fakeManager = {
|
||||
create: (_entity: unknown, data: Record<string, unknown>) => {
|
||||
created.push(data);
|
||||
return { id: 'child-1', ...data };
|
||||
},
|
||||
save: async (row: Record<string, unknown>) => ({ id: 'child-1', ...row }),
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }),
|
||||
update: async (_id: string, data: Record<string, unknown>) => {
|
||||
managerUpdates.push(data);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
transaction: async (cb: (m: unknown) => Promise<unknown>) => cb(fakeManager),
|
||||
getRepository: () => ({ update: jest.fn() }),
|
||||
};
|
||||
const ordersRepository = {
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }),
|
||||
};
|
||||
const bookingsRepository = {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
countByYear: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
const generalContractService = {
|
||||
isGeneralContract: () => true,
|
||||
getRouteLines: jest.fn().mockResolvedValue([]),
|
||||
getQuantityLines: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ containerTypeId: null, remainingQuantity: 100, containerTypeName: null },
|
||||
]),
|
||||
isExhausted: jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const pricingService = {
|
||||
computePriceForBooking: jest.fn().mockResolvedValue({
|
||||
totalAmount: 500,
|
||||
priorityScore: 10,
|
||||
lineItems: [],
|
||||
currency: 'ETB',
|
||||
}),
|
||||
};
|
||||
const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) };
|
||||
const trainSchedulingService = {
|
||||
existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const companiesService = {};
|
||||
|
||||
const service = new BookingOrdersService(
|
||||
dataSource as never,
|
||||
ordersRepository as never,
|
||||
bookingsRepository as never,
|
||||
companiesService as never,
|
||||
generalContractService as never,
|
||||
pricingService as never,
|
||||
ratesService as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
return { service, created, managerUpdates, pricingService };
|
||||
}
|
||||
|
||||
const dto = {
|
||||
contractBookingId: 'c-1',
|
||||
scheduledDate: '2026-07-01T00:00:00.000Z',
|
||||
lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }],
|
||||
};
|
||||
|
||||
it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => {
|
||||
const { service, created, managerUpdates, pricingService } = makeService({
|
||||
includesCustoms: false,
|
||||
});
|
||||
await service.create(dto as never);
|
||||
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('OPERATION_REQUEST_PENDING');
|
||||
expect(child.paymentStatus).toBe('PENDING');
|
||||
expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0
|
||||
expect(pricingService.computePriceForBooking).toHaveBeenCalled();
|
||||
// The computed price is persisted onto the child.
|
||||
expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true);
|
||||
});
|
||||
|
||||
it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => {
|
||||
const { service, created } = makeService({ includesCustoms: true });
|
||||
await service.create(dto as never);
|
||||
const child = created.find((c) => c.bookingType === 'ONE_TIME')!;
|
||||
expect(child.status).toBe('AWAITING_DOCUMENTS');
|
||||
});
|
||||
|
||||
it('rejects when hazardous quantity exceeds the line quantity', async () => {
|
||||
const { service } = makeService({ includesCustoms: false });
|
||||
await expect(
|
||||
service.create({
|
||||
...dto,
|
||||
lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }],
|
||||
} as never),
|
||||
).rejects.toThrow(/exceed the line quantity/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
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 { RatesService } from '../rule-engine/services/rates.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';
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
@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,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly ratesService: RatesService,
|
||||
@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');
|
||||
}
|
||||
|
||||
// Resolve the route the order ships on: a chosen contract route line for a
|
||||
// multi-route contract, else the contract's own origin/destination.
|
||||
const routeLines = await this.generalContractService.getRouteLines(
|
||||
contract.id,
|
||||
);
|
||||
let originYardId = contract.originYardId;
|
||||
let destinationYardId = contract.destinationYardId;
|
||||
let routeLineId: string | null = null;
|
||||
let routeKm: number | null = null;
|
||||
|
||||
if (routeLines.length > 0) {
|
||||
if (!dto.routeLineId) {
|
||||
throw new BadRequestException(
|
||||
'This contract has multiple routes — select a route to draw from',
|
||||
);
|
||||
}
|
||||
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
|
||||
if (!chosen) {
|
||||
throw new BadRequestException(
|
||||
'Selected route is not part of this contract',
|
||||
);
|
||||
}
|
||||
originYardId = chosen.originYardId;
|
||||
destinationYardId = chosen.destinationYardId;
|
||||
routeLineId = chosen.routeLineId;
|
||||
routeKm = chosen.km ?? null;
|
||||
}
|
||||
|
||||
// Validate the route has a departure on the chosen day.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
|
||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||
// belong to. Validated for every order regardless of routing.
|
||||
for (const line of dto.lines) {
|
||||
const haz = line.hazardousQuantity ?? 0;
|
||||
const reefer = line.reeferQuantity ?? 0;
|
||||
if (haz < 0 || reefer < 0) {
|
||||
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
|
||||
}
|
||||
if (haz > line.quantity || reefer > line.quantity) {
|
||||
throw new BadRequestException(
|
||||
'Hazardous/reefer quantity cannot exceed the line quantity',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (routeLineId) {
|
||||
// Multi-route: validate against the chosen route line's remaining pool.
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
}
|
||||
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
|
||||
if (orderTotal > chosen.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Single-route: validate each line against the per-container-type pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
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,
|
||||
{ originYardId, destinationYardId, km: routeKm },
|
||||
manager,
|
||||
);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const orderRow = manager.create(BookingOrder, {
|
||||
reference,
|
||||
contractBookingId: contract.id,
|
||||
bookingId: childBooking.id,
|
||||
routeLineId,
|
||||
companyId: contract.companyId ?? null,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// The order is a ledger row; the child booking drives the workflow
|
||||
// (review → pay → allocate), so the order tracks PENDING until done.
|
||||
status: 'PENDING',
|
||||
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,
|
||||
hazardousQuantity: l.hazardousQuantity ?? 0,
|
||||
reeferQuantity: l.reeferQuantity ?? 0,
|
||||
}),
|
||||
);
|
||||
await manager.save(lines);
|
||||
savedOrder.lines = lines;
|
||||
return savedOrder;
|
||||
});
|
||||
|
||||
// The child does NOT enter the train batch pool here. It is priced and
|
||||
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
|
||||
// clearance first; the batch enqueue happens only on accept.
|
||||
|
||||
// Close the contract once its pool is exhausted (pending orders count, so
|
||||
// the pool reserves quantity as soon as an order is placed).
|
||||
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. Unlike the contract (which is no longer paid up front),
|
||||
* the child is PRICED and UNPAID and waits for Marketing review — going
|
||||
* through the customs clearance gate first when the service includes customs,
|
||||
* mirroring a one-time booking. It only enters the train pool on accept.
|
||||
*/
|
||||
private async spawnChildBooking(
|
||||
contract: Booking,
|
||||
dto: CreateBookingOrderDto,
|
||||
route: { originYardId: string; destinationYardId: string; km: number | null },
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<Booking> {
|
||||
const reference = await this.generateChildBookingReference();
|
||||
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);
|
||||
}
|
||||
|
||||
// Per-order hazardous/reefer: set the child flags from the order's line
|
||||
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
|
||||
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
|
||||
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
|
||||
|
||||
// Customs orders flow through the one-time clearance gate first; others go
|
||||
// straight to operations review with the chosen shipment day.
|
||||
const { includesCustoms } = clearanceCodesForBooking(contract);
|
||||
const spawnStatus = includesCustoms
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
: 'OPERATION_REQUEST_PENDING';
|
||||
|
||||
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: route.originYardId,
|
||||
destinationYardId: route.destinationYardId,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
cargoTypeId: contract.cargoTypeId ?? null,
|
||||
cargoFreeText: contract.cargoFreeText ?? null,
|
||||
shippingLineId: contract.shippingLineId ?? null,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: hasHazardous,
|
||||
isReefer: hasReefer,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
bookingType: 'ONE_TIME',
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// Priced + unpaid: the customer pays this order on its own.
|
||||
status: spawnStatus,
|
||||
paymentStatus: 'PENDING',
|
||||
priorityScore: contract.priorityScore,
|
||||
totalAmount: 0,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Price the order: base freight for the drawn quantity + haz/reefer
|
||||
// surcharges, plus a road KM charge when the service ships by road.
|
||||
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
|
||||
await this.priceChildBooking(savedChild.id, roadKm, manager);
|
||||
|
||||
return savedChild;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and persist the child order's price (base + surcharges) inside the
|
||||
* order transaction. The contract is no longer paid up front, so each order
|
||||
* carries its own total that the customer pays.
|
||||
*/
|
||||
private async priceChildBooking(
|
||||
childId: string,
|
||||
roadKm: number | null,
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<void> {
|
||||
const child = await manager.getRepository(Booking).findOne({
|
||||
where: { id: childId },
|
||||
relations: { bookingContainers: true },
|
||||
});
|
||||
if (!child) return;
|
||||
|
||||
try {
|
||||
const computed = await this.pricingService.computePriceForBooking(child);
|
||||
const lineItems = [...computed.lineItems];
|
||||
let total = computed.totalAmount;
|
||||
|
||||
// Road KM charge: distance × the live PER_KM rate, added as its own line.
|
||||
if (roadKm && roadKm > 0) {
|
||||
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
|
||||
const kmAmount = roadKmPrice(roadKm, perKmRate);
|
||||
if (kmAmount > 0) {
|
||||
lineItems.push({
|
||||
code: 'ROAD_KM',
|
||||
description: `Road transport (${roadKm} km)`,
|
||||
amount: kmAmount,
|
||||
unitAmount: perKmRate!,
|
||||
unit: 'PER_KM',
|
||||
quantity: roadKm,
|
||||
currency: child.paymentCurrency,
|
||||
});
|
||||
total += kmAmount;
|
||||
}
|
||||
}
|
||||
|
||||
await manager.getRepository(Booking).update(childId, {
|
||||
totalAmount: total,
|
||||
priorityScore: computed.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: computed.currency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The live PER_KM rate value for road billing, in the given currency. */
|
||||
private async findPerKmRate(currency: string): Promise<number | null> {
|
||||
const rates = await this.ratesService.findLiveRates();
|
||||
const rate = rates.find(
|
||||
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
|
||||
);
|
||||
return rate ? Number(rate.rateValue) : null;
|
||||
}
|
||||
|
||||
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')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** A contracted/ordered/remaining pool line for one route of a general contract. */
|
||||
export class ContractRouteLineView {
|
||||
@ApiProperty({ description: 'Contract route line id' })
|
||||
routeLineId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
originYardName!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
destinationYardName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||
km!: number | null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { CreateBookingOrderLineDto } from './create-booking-order.dto';
|
||||
|
||||
/**
|
||||
* Order line haz/reefer quantities arrive as JSON numbers but must default to 0
|
||||
* when omitted and coerce string inputs (defensive) to numbers.
|
||||
*/
|
||||
describe('CreateBookingOrderLineDto — haz/reefer coercion', () => {
|
||||
const toDto = (plain: Record<string, unknown>) =>
|
||||
plainToInstance(CreateBookingOrderLineDto, plain, {
|
||||
enableImplicitConversion: false,
|
||||
exposeDefaultValues: true,
|
||||
}) as unknown as CreateBookingOrderLineDto;
|
||||
|
||||
it('defaults hazardous/reefer quantities to 0 when omitted', () => {
|
||||
const dto = toDto({ quantity: 5 });
|
||||
expect(dto.hazardousQuantity).toBe(0);
|
||||
expect(dto.reeferQuantity).toBe(0);
|
||||
});
|
||||
|
||||
it('coerces provided string quantities to numbers', () => {
|
||||
const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' });
|
||||
expect(dto.quantity).toBe(5);
|
||||
expect(dto.hazardousQuantity).toBe(2);
|
||||
expect(dto.reeferQuantity).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
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;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
hazardousQuantity?: number = 0;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
reeferQuantity?: number = 0;
|
||||
}
|
||||
|
||||
export class CreateBookingOrderDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
||||
@IsUUID()
|
||||
contractBookingId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'For multi-route contracts: the contract route line being drawn from. ' +
|
||||
'Determines the shipment origin/destination. Omit for single-route contracts.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
routeLineId?: 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[];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* How much of this line is hazardous / refrigerated, entered per order by the
|
||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
||||
*/
|
||||
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
hazardousQuantity!: number;
|
||||
|
||||
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
reeferQuantity!: number;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* The contract route line this order drew down (multi-route general contracts).
|
||||
* Null for legacy/single-route contracts that have no route lines — the order
|
||||
* then uses the contract's own origin/destination.
|
||||
*/
|
||||
@Column({ name: 'route_line_id', type: 'uuid', nullable: true })
|
||||
routeLineId?: string | 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[];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
|
||||
/**
|
||||
* One contracted route+quantity line of a GENERAL contract. A general contract
|
||||
* may span several routes (e.g. Addis→Dire Dawa: 10, Modjo→Djibouti: 5); each
|
||||
* route reserves its own quantity pool. Drawdown orders pick one of these routes
|
||||
* and decrement that route's pool. One-time bookings do not use this — they keep
|
||||
* the single origin/destination on the booking itself.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'contract_route_lines' })
|
||||
@Index(['contractBookingId'])
|
||||
export class ContractRouteLine extends BaseEntity {
|
||||
/** 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;
|
||||
|
||||
@Column({ name: 'origin_yard_id', type: 'uuid' })
|
||||
originYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'origin_yard_id' })
|
||||
originYard?: Yard;
|
||||
|
||||
@Column({ name: 'destination_yard_id', type: 'uuid' })
|
||||
destinationYardId!: string;
|
||||
|
||||
@ManyToOne(() => Yard)
|
||||
@JoinColumn({ name: 'destination_yard_id' })
|
||||
destinationYard?: Yard;
|
||||
|
||||
/**
|
||||
* Container type this route line reserves (CONTAINER contracts); null for
|
||||
* BULK/BREAK_BULK, where the quantity is tons/items.
|
||||
*/
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Contracted quantity for this (route, container type): containers, tons, or items. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* Road distance for this route, configured with the route. Road (truck)
|
||||
* drawdown orders bill KM × the PER_KM rate from this value. Null for
|
||||
* rail-only routes where KM is not billed.
|
||||
*/
|
||||
@Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
km?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
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 { ContractRouteLine } from './entities/contract-route-line.entity';
|
||||
import {
|
||||
ContractQuantityLineView,
|
||||
ContractRouteLineView,
|
||||
} 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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route drawdown pool for a multi-route general contract: contracted vs.
|
||||
* ordered vs. remaining, one entry per contracted route line. Returns [] for
|
||||
* single-route contracts (no route lines) — callers fall back to
|
||||
* {@link getQuantityLines}.
|
||||
*/
|
||||
async getRouteLines(
|
||||
contractBookingId: string,
|
||||
): Promise<ContractRouteLineView[]> {
|
||||
const routeLines = await this.dataSource
|
||||
.getRepository(ContractRouteLine)
|
||||
.find({
|
||||
where: { contractBookingId },
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
containerType: true,
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (routeLines.length === 0) return [];
|
||||
|
||||
const ordered = await this.orderedByRouteLine(contractBookingId);
|
||||
|
||||
return routeLines.map((rl) => {
|
||||
const orderedQty = ordered.get(rl.id) ?? 0;
|
||||
const contracted = Number(rl.quantity);
|
||||
return {
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
containerTypeId: rl.containerTypeId ?? null,
|
||||
containerTypeName: rl.containerType?.label ?? null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
|
||||
private async orderedByRouteLine(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('o.route_line_id', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere('o.route_line_id IS NOT NULL')
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.groupBy('o.route_line_id')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
// Multi-route contracts are exhausted when every route line is drawn down;
|
||||
// single-route contracts fall back to the per-container-type pool.
|
||||
const routeLines = await this.getRouteLines(contractBookingId);
|
||||
if (routeLines.length > 0) {
|
||||
return routeLines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { isRoadService, roadKmPrice } from './road.util';
|
||||
|
||||
describe('road.util', () => {
|
||||
describe('isRoadService', () => {
|
||||
it('treats ROAD/TRUCK codes (and prefixes) as road', () => {
|
||||
expect(isRoadService({ code: 'ROAD' })).toBe(true);
|
||||
expect(isRoadService({ code: 'TRUCK' })).toBe(true);
|
||||
expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true);
|
||||
expect(isRoadService({ code: 'truck_forwarding' })).toBe(true);
|
||||
});
|
||||
|
||||
it('treats rail / unknown / missing services as not road', () => {
|
||||
expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false);
|
||||
expect(isRoadService({ code: 'OFFROADING' })).toBe(false);
|
||||
expect(isRoadService(null)).toBe(false);
|
||||
expect(isRoadService(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roadKmPrice', () => {
|
||||
it('multiplies distance by the per-km rate', () => {
|
||||
expect(roadKmPrice(120, 5)).toBe(600);
|
||||
});
|
||||
|
||||
it('returns 0 when km or rate is missing/non-positive', () => {
|
||||
expect(roadKmPrice(null, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, null)).toBe(0);
|
||||
expect(roadKmPrice(0, 5)).toBe(0);
|
||||
expect(roadKmPrice(120, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
35
apps/edr-freight-api/src/modules/booking-orders/road.util.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
|
||||
/**
|
||||
* Road (truck) services are distinguished by their ServiceType.code. Rail
|
||||
* services are seeded as RAIL_* and go through the train batch pool; a road
|
||||
* service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills
|
||||
* by distance and dispatches a truck. Prefix-matching keeps this resilient to
|
||||
* the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING).
|
||||
*/
|
||||
export function isRoadService(
|
||||
serviceType?: Pick<ServiceType, 'code'> | null,
|
||||
): boolean {
|
||||
const code = serviceType?.code?.toUpperCase() ?? '';
|
||||
return (
|
||||
code === 'ROAD' ||
|
||||
code === 'TRUCK' ||
|
||||
code.startsWith('ROAD_') ||
|
||||
code.startsWith('TRUCK_')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Road freight charge for an order: distance (km, from the route line) × the
|
||||
* per-km rate. Returns 0 when either input is missing so callers can add it to
|
||||
* a total without guarding.
|
||||
*/
|
||||
export function roadKmPrice(
|
||||
km: number | null | undefined,
|
||||
perKmRate: number | null | undefined,
|
||||
): number {
|
||||
const distance = Number(km ?? 0);
|
||||
const rate = Number(perKmRate ?? 0);
|
||||
if (!(distance > 0) || !(rate > 0)) return 0;
|
||||
return distance * rate;
|
||||
}
|
||||
@@ -19,9 +19,17 @@ import { FileRecord } from '../files/entities/file.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceSettingCode } from './clearance.util';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||
|
||||
/**
|
||||
* Default ordering window (months) for a general contract activated on
|
||||
* counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
* defined locally to avoid a circular module dependency on booking-orders.
|
||||
*/
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
|
||||
@@ -222,19 +230,45 @@ export class BookingContractService {
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
// Whether a document-clearance gate applies (IMPORT/EXPORT bookings). When it
|
||||
// does, the counter-signed booking goes to AWAITING_DOCUMENTS for the customer
|
||||
// to upload clearance documents instead of straight into the batch pipeline.
|
||||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||
const clearanceCode = clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
booking.freightType,
|
||||
includesCustoms,
|
||||
);
|
||||
|
||||
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
if (role === 'CUSTOMER') {
|
||||
updates.status = 'SIGNED_CUSTOMER';
|
||||
updates.customerSignedAt = now;
|
||||
} else {
|
||||
updates.status = 'FULLY_EXECUTED';
|
||||
} else if (isGeneralContract) {
|
||||
// A general contract is NOT paid up front — each drawdown order is priced
|
||||
// and paid on its own. So on counter-sign it becomes ACTIVE directly and
|
||||
// opens its ordering window; orders spawn their own priced child bookings.
|
||||
const expiresAt = new Date(now);
|
||||
expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS);
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = 'CONTRACT_ACTIVE';
|
||||
updates.expiresAt = expiresAt;
|
||||
} else {
|
||||
updates.fullyExecutedAt = now;
|
||||
updates.marketingApprovedAt = now;
|
||||
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||
updates.lockedAt = now;
|
||||
updates.status = clearanceCode ? 'AWAITING_DOCUMENTS' : 'FULLY_EXECUTED';
|
||||
}
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||
if (role === 'STAFF' && updated?.trainScheduleId) {
|
||||
// Only the non-clearance (legacy/domestic) path enters the batch pipeline now;
|
||||
// clearance bookings enter operations after the GL document gate.
|
||||
if (role === 'STAFF' && !clearanceCode && updated?.trainScheduleId) {
|
||||
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -57,6 +57,45 @@ export function computeNextStep(
|
||||
action: 'AWAIT_PAYMENT',
|
||||
description: 'Awaiting customer payment',
|
||||
};
|
||||
case 'AWAITING_DOCUMENTS':
|
||||
return {
|
||||
action: 'UPLOAD_DOCUMENTS',
|
||||
description: 'Upload the clearance documents for your shipment',
|
||||
};
|
||||
case 'DOCUMENTS_UNDER_REVIEW':
|
||||
return {
|
||||
action: 'AWAIT_DOCUMENT_REVIEW',
|
||||
description: 'Global Logistics is reviewing your documents',
|
||||
};
|
||||
case 'CLEARANCE_READY':
|
||||
return {
|
||||
action: 'PROCEED_TO_OPERATION',
|
||||
description:
|
||||
'Clearance is ready — pick a schedule day and request operation',
|
||||
};
|
||||
case 'OPERATION_REQUEST_PENDING':
|
||||
return {
|
||||
action: 'AWAIT_OPERATION_REVIEW',
|
||||
description:
|
||||
'Operations is reviewing your request (capacity, documents, route)',
|
||||
};
|
||||
case 'OPERATION_CHANGES_REQUESTED':
|
||||
return {
|
||||
action: 'RESUBMIT_OPERATION',
|
||||
description:
|
||||
'Operations requested changes — update and resubmit your operation request',
|
||||
};
|
||||
case 'OPERATION_PRICE_PENDING_CONFIRM':
|
||||
return {
|
||||
action: 'CONFIRM_OPERATION_PRICE',
|
||||
description:
|
||||
'Operations adjusted the price — confirm the new total to proceed',
|
||||
};
|
||||
case 'OPERATION_REQUESTED':
|
||||
return {
|
||||
action: 'AWAIT_OPERATION',
|
||||
description: 'Operation requested; an operator will take it forward',
|
||||
};
|
||||
case 'PAID':
|
||||
return {
|
||||
action: 'START_TRANSIT',
|
||||
|
||||
@@ -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(
|
||||
@@ -44,8 +44,7 @@ describe('BookingPricingService — domestic corridor', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
ratesService as never,
|
||||
{} as never,
|
||||
cbeExchangeService as never,
|
||||
exchangeService as never,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
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,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
containersPerWagon,
|
||||
wagonRemainder,
|
||||
} from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -33,6 +36,29 @@ type StoredPricingBreakdown = {
|
||||
generatedAt?: string;
|
||||
} | null;
|
||||
|
||||
/** Friendly labels for the per-unit rate card shown at the confirm step. */
|
||||
const SURCHARGE_LABELS: Record<string, string> = {
|
||||
HAZARD_SURCHARGE: 'Hazardous cargo',
|
||||
HAZARDOUS_CARGO: 'Hazardous cargo',
|
||||
REEFER_SURCHARGE: 'Refrigerated (reefer)',
|
||||
REEFER_CARGO: 'Refrigerated (reefer)',
|
||||
OVERWEIGHT_PER_TON: 'Overweight excess',
|
||||
DOUBLE_HANDLING: 'Double handling',
|
||||
LASHING: 'Lashing',
|
||||
PIL_EXTRA_FEE: 'Shipping line fee',
|
||||
};
|
||||
|
||||
function surchargeLabel(code: string): string {
|
||||
return (
|
||||
SURCHARGE_LABELS[code] ??
|
||||
code
|
||||
.toLowerCase()
|
||||
.split('_')
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(' ')
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingPricingService {
|
||||
constructor(
|
||||
@@ -40,8 +66,7 @@ export class BookingPricingService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
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 +109,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;
|
||||
@@ -103,16 +128,35 @@ export class BookingPricingService {
|
||||
for (const mod of ruleResult.appliedModifiers) {
|
||||
const usdAmount = mod.calculatedAmount;
|
||||
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
const unit = rate?.rateUnit ?? 'FLAT';
|
||||
const unitUsd = rate ? Number(rate.rateValue) : usdAmount;
|
||||
const unitAmount = isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd;
|
||||
// Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an
|
||||
// explicit trigger (e.g. overweight tons) wins when present; otherwise
|
||||
// derive from total ÷ unit price.
|
||||
const quantity =
|
||||
unit === 'FLAT' || unit === 'PER_INVOICE'
|
||||
? 1
|
||||
: mod.triggerValue != null && mod.triggerValue > 0
|
||||
? mod.triggerValue
|
||||
: unitUsd > 0
|
||||
? Math.max(1, Math.round(usdAmount / unitUsd))
|
||||
: 1;
|
||||
|
||||
const item: PriceLineItemDto = {
|
||||
code: mod.surchargeTypeCode,
|
||||
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||
code: mod.surchargeCode,
|
||||
description: surchargeLabel(mod.surchargeCode),
|
||||
amount: convertedAmount,
|
||||
unitAmount,
|
||||
unit,
|
||||
quantity,
|
||||
currency: paymentCurrency,
|
||||
};
|
||||
lineItems.push(item);
|
||||
total += convertedAmount;
|
||||
|
||||
const rate = rateById.get(mod.rateId);
|
||||
if (rate) usedRatesMap.set(rate.id, rate);
|
||||
}
|
||||
|
||||
@@ -152,7 +196,7 @@ export class BookingPricingService {
|
||||
if (!snapshotId) return null;
|
||||
return {
|
||||
bookingId,
|
||||
surchargeTypeId: m.surchargeTypeId,
|
||||
rateId: m.rateId,
|
||||
triggerValue: m.triggerValue,
|
||||
calculatedAmount: m.calculatedAmount,
|
||||
rateSnapshotId: snapshotId,
|
||||
@@ -166,7 +210,7 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||
const containers = await Promise.all(
|
||||
const lines = await Promise.all(
|
||||
(booking.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map(async (bc) => {
|
||||
@@ -174,14 +218,19 @@ export class BookingPricingService {
|
||||
const vgm = Number(bc.vgmPerUnitTons);
|
||||
const qty = bc.quantity;
|
||||
return {
|
||||
containerTypeId: bc.containerTypeId,
|
||||
container: {
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
},
|
||||
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const containers = lines.map((l) => l.container);
|
||||
// Wagon count is persisted per container line at booking creation; sum it.
|
||||
const totalWagons =
|
||||
booking.freightType === 'CONTAINER'
|
||||
@@ -193,15 +242,38 @@ export class BookingPricingService {
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever
|
||||
// a container type leaves a wagon partially filled. Aggregate by type first —
|
||||
// two lines of the same type share wagons, so 2× 20FT (= one full wagon) must
|
||||
// NOT count as a partial wagon. Mirrors ConsolidationService.slotsFromContainerLines.
|
||||
const remainderByType = new Map<string, { quantity: number; perWagon: number }>();
|
||||
for (const l of lines) {
|
||||
const prev = remainderByType.get(l.container.containerTypeId);
|
||||
remainderByType.set(l.container.containerTypeId, {
|
||||
quantity: (prev?.quantity ?? 0) + Number(l.quantity || 0),
|
||||
perWagon: l.perWagon,
|
||||
});
|
||||
}
|
||||
const allowConsolidation =
|
||||
booking.freightType === 'CONTAINER' &&
|
||||
[...remainderByType.values()].some(
|
||||
(t) => wagonRemainder(t.quantity, t.perWagon) > 0,
|
||||
);
|
||||
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
serviceTypeId: booking.serviceTypeId,
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
isHazardous: booking.isHazardous,
|
||||
// Coerce defensively in case the stored flag is a string ("true"/"false").
|
||||
isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true',
|
||||
// Booking-level reefer flag (set by contract drawdown orders that carry a
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
@@ -240,6 +312,9 @@ export class BookingPricingService {
|
||||
code: 'TOTAL',
|
||||
description: 'Contract total',
|
||||
amount: total,
|
||||
unitAmount: total,
|
||||
unit: 'FLAT',
|
||||
quantity: 1,
|
||||
currency: booking.paymentCurrency,
|
||||
},
|
||||
],
|
||||
@@ -255,27 +330,18 @@ export class BookingPricingService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Recompute priority on submit (USD + service tier). */
|
||||
/**
|
||||
* Recompute priority on submit.
|
||||
*
|
||||
* The full priority model is additive and capped at 100:
|
||||
* service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35).
|
||||
* All three components are produced by RuleEngineService.evaluate, so submit
|
||||
* simply re-runs the engine — there is no extra submit-time inflation.
|
||||
*/
|
||||
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
let score = ruleResult.priorityScore;
|
||||
|
||||
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
|
||||
if (booking.paymentCurrency === 'USD' && serviceType) {
|
||||
const code = (serviceType.code ?? '').toUpperCase();
|
||||
const hasForwarding =
|
||||
serviceType.includesFirstMile ||
|
||||
serviceType.includesLastMile ||
|
||||
code.includes('FORWARD') ||
|
||||
code.includes('Y');
|
||||
const railOnly = code.includes('RAIL') && !hasForwarding;
|
||||
|
||||
if (hasForwarding) score += 1000;
|
||||
else if (railOnly || code.includes('X')) score += 500;
|
||||
}
|
||||
|
||||
return score;
|
||||
return ruleResult.priorityScore;
|
||||
}
|
||||
|
||||
private async computeBaseRailLinesWithRates(
|
||||
@@ -285,7 +351,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 =
|
||||
@@ -312,10 +378,15 @@ export class BookingPricingService {
|
||||
usedRatesMap.set(rate.id, rate);
|
||||
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(rate.rateValue);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
description: `${label} rail freight`,
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unit: rate.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(rate.rateUnit, container.quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
@@ -331,10 +402,14 @@ export class BookingPricingService {
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
|
||||
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
lines.push({
|
||||
code: rateType,
|
||||
description: `Base rail (${rateType})`,
|
||||
description: isBulk ? 'Bulk rail freight' : 'Container rail freight',
|
||||
amount,
|
||||
unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd,
|
||||
unit: fallback.rateUnit,
|
||||
quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount),
|
||||
currency: paymentCurrency,
|
||||
});
|
||||
}
|
||||
@@ -343,6 +418,34 @@ export class BookingPricingService {
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||
try {
|
||||
const ct = await this.containerTypesService?.findById?.(containerTypeId);
|
||||
return ct?.label ?? 'Container';
|
||||
} catch {
|
||||
return 'Container';
|
||||
}
|
||||
}
|
||||
|
||||
/** How many units a rate's total is divided into, by rate unit (for the per-unit card). */
|
||||
private effectiveUnitQuantity(
|
||||
rateUnit: string,
|
||||
quantity: number,
|
||||
wagonCount: number,
|
||||
): number {
|
||||
switch (rateUnit) {
|
||||
case 'PER_WAGON':
|
||||
return wagonCount;
|
||||
case 'FLAT':
|
||||
return 1;
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_TON':
|
||||
default:
|
||||
return quantity;
|
||||
}
|
||||
}
|
||||
|
||||
private pickRate(
|
||||
rates: Rate[],
|
||||
rateType: string,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
* Focused tests for the contract validity window set at the accept step.
|
||||
* The backoffice must supply a number of days; the window runs from the accept
|
||||
* moment through accept + N days.
|
||||
*/
|
||||
describe('BookingTransitionService — acceptIntake validity window', () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
status: 'SUBMITTED',
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const ruleEngineService = {
|
||||
instantiateApprovalSteps: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
ruleEngineService as never,
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository, ruleEngineService };
|
||||
}
|
||||
|
||||
it('rejects accept when validity days is missing or non-positive', async () => {
|
||||
const { service } = makeService();
|
||||
await expect(
|
||||
service.acceptIntake('b-1', 'staff-1', 0),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.acceptIntake('b-1', 'staff-1', -5),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.acceptIntake('b-1', 'staff-1', 1.5),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('sets a validity window of validFrom..validFrom + N days', async () => {
|
||||
const { service, bookingsRepository } = makeService();
|
||||
await service.acceptIntake('b-1', 'staff-1', 10);
|
||||
|
||||
expect(bookingsRepository.update).toHaveBeenCalledTimes(1);
|
||||
const [id, updates] = bookingsRepository.update.mock.calls[0];
|
||||
expect(id).toBe('b-1');
|
||||
expect(updates).toMatchObject({
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: 'staff-1',
|
||||
contractValidityDays: 10,
|
||||
});
|
||||
|
||||
const from = updates.contractValidFrom as Date;
|
||||
const until = updates.contractValidUntil as Date;
|
||||
const diffDays = Math.round(
|
||||
(until.getTime() - from.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
expect(diffDays).toBe(10);
|
||||
// The accept timestamp and the validity start are the same moment.
|
||||
expect((updates.approvedByStaffAt as Date).getTime()).toBe(from.getTime());
|
||||
});
|
||||
|
||||
it('instantiates the approval chain when accepting', async () => {
|
||||
const { service, ruleEngineService } = makeService();
|
||||
await service.acceptIntake('b-1', 'staff-1', 30);
|
||||
expect(ruleEngineService.instantiateApprovalSteps).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ freightType: 'CONTAINER' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
* Focused tests for the clearance 100%-approved gate in finalizeClearance.
|
||||
* Uses minimal stubs for the service's collaborators.
|
||||
*/
|
||||
describe('BookingTransitionService — finalizeClearance gate', () => {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: 'CONTAINER',
|
||||
serviceType: { includesCustoms: false }, // no output set → only the input gate
|
||||
};
|
||||
|
||||
// Input set has two required docs.
|
||||
const inputSetting = {
|
||||
code: 'clearance_import_container_without_customs',
|
||||
fields: [
|
||||
{ fileKey: 'commercial_invoice', isRequired: true },
|
||||
{ fileKey: 'packing_list', isRequired: true },
|
||||
],
|
||||
};
|
||||
|
||||
function makeService(reviews: Array<{ settingCode: string; fileKey: string; status: string }>) {
|
||||
const bookingsRepository = {
|
||||
findDocumentReviews: jest.fn().mockResolvedValue(reviews),
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const fileUploadSettingsService = {
|
||||
getByCode: jest.fn().mockResolvedValue(inputSetting),
|
||||
};
|
||||
const filesService = { findByResource: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
filesService as never,
|
||||
fileUploadSettingsService as never,
|
||||
{} as never, // bookingBatchService
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository };
|
||||
}
|
||||
|
||||
it('rejects when a required document is not APPROVED', async () => {
|
||||
const { service } = makeService([
|
||||
{
|
||||
settingCode: inputSetting.code,
|
||||
fileKey: 'commercial_invoice',
|
||||
status: 'APPROVED',
|
||||
},
|
||||
// packing_list is still PENDING (missing approval)
|
||||
]);
|
||||
await expect(service.finalizeClearance('b-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('moves to CLEARANCE_READY when all required documents are APPROVED', async () => {
|
||||
const { service, bookingsRepository } = makeService([
|
||||
{ settingCode: inputSetting.code, fileKey: 'commercial_invoice', status: 'APPROVED' },
|
||||
{ settingCode: inputSetting.code, fileKey: 'packing_list', status: 'APPROVED' },
|
||||
]);
|
||||
await service.finalizeClearance('b-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'CLEARANCE_READY' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
|
||||
/**
|
||||
* Operation-request review for general-contract drawdown orders:
|
||||
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
|
||||
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
|
||||
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
|
||||
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
|
||||
*/
|
||||
describe('BookingTransitionService — operation review', () => {
|
||||
function makeService(serviceTypeCode: string) {
|
||||
const booking = {
|
||||
id: 'b-1',
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
originYardId: 'o-1',
|
||||
destinationYardId: 'd-1',
|
||||
scheduledDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||
serviceType: { code: serviceTypeCode },
|
||||
};
|
||||
const bookingsRepository = {
|
||||
update: jest.fn().mockResolvedValue({ id: 'b-1' }),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
};
|
||||
const bookingBatchService = {
|
||||
enqueueRouteDayProcessing: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new BookingTransitionService(
|
||||
bookingsRepository as never,
|
||||
{} as never, // ruleEngineService
|
||||
{} as never, // pricingService
|
||||
{} as never, // contractService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
bookingBatchService as never,
|
||||
bookingsService as never,
|
||||
);
|
||||
return { service, bookingsRepository, bookingBatchService };
|
||||
}
|
||||
|
||||
it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'FULLY_EXECUTED' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => {
|
||||
const { service, bookingsRepository, bookingBatchService } =
|
||||
makeService('ROAD_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1');
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }),
|
||||
);
|
||||
expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await expect(
|
||||
service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {
|
||||
note: 'Fix the schedule',
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
|
||||
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
|
||||
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
|
||||
amount: 1500,
|
||||
});
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'b-1',
|
||||
expect.objectContaining({
|
||||
adjustedTotalAmount: 1500,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,17 @@ import {
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { isRoadService } from '../booking-orders/road.util';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { clearanceCodesForBooking } from './clearance.util';
|
||||
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
@@ -25,6 +31,10 @@ export class BookingTransitionService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingsService))
|
||||
private readonly bookingsService: BookingsService,
|
||||
) {}
|
||||
@@ -193,13 +203,31 @@ export class BookingTransitionService {
|
||||
});
|
||||
}
|
||||
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
async acceptIntake(
|
||||
bookingId: string,
|
||||
actorId: string,
|
||||
validityDays: number,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
// The backoffice must define how long the accepted contract stays valid.
|
||||
// Without a window the contract has no end date and cannot be relied on, so
|
||||
// accept is blocked until a positive number of days is supplied.
|
||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||
throw new BadRequestException(
|
||||
'A contract validity (in days) is required to accept this booking.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validity runs from the accept moment through accept + N days.
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
@@ -208,7 +236,10 @@ export class BookingTransitionService {
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: new Date(),
|
||||
approvedByStaffAt: validFrom,
|
||||
contractValidityDays: validityDays,
|
||||
contractValidFrom: validFrom,
|
||||
contractValidUntil: validUntil,
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -420,6 +451,472 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer rejects the priced booking at the confirm step. The booking becomes
|
||||
* REJECTED (terminal) — the customer starts a new booking rather than editing
|
||||
* this one. Only a not-yet-committed booking can be rejected this way.
|
||||
*/
|
||||
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
'DRAFT',
|
||||
'SUBMITTED',
|
||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
||||
'PENDING_CONSOLIDATION',
|
||||
]);
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
reason?.trim() || 'Customer rejected the price estimate.',
|
||||
'REJECTION',
|
||||
);
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'REJECTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
|
||||
* that supersedes the computed total for the customer, who sees an
|
||||
* "Adjusted by EDR" badge. Passing null clears the adjustment.
|
||||
*/
|
||||
async adjustPrice(
|
||||
bookingId: string,
|
||||
amount: number | null,
|
||||
staffId: string,
|
||||
reason?: string,
|
||||
): Promise<Booking> {
|
||||
await this.bookingsService.findById(bookingId);
|
||||
if (amount != null && amount < 0) {
|
||||
throw new BadRequestException('Adjusted amount cannot be negative');
|
||||
}
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
adjustedTotalAmount: amount,
|
||||
adjustedByStaffId: amount == null ? null : staffId,
|
||||
adjustedAt: amount == null ? null : new Date(),
|
||||
adjustmentReason: amount == null ? null : (reason ?? null),
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
// ── Document clearance gate (post counter-sign) ───────────────────────────
|
||||
|
||||
/**
|
||||
* The clearance document grid for a booking: each required field from the
|
||||
* resolved customer-input set (and the GL-output set for customs) with its
|
||||
* uploaded file and GL review status. Drives both portals' clearance UI.
|
||||
*/
|
||||
async getClearanceView(bookingId: string): Promise<{
|
||||
status: string;
|
||||
includesCustoms: boolean;
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
documents: Array<{
|
||||
fileKey: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
uploadedBy: 'customer' | 'gl';
|
||||
settingCode: string;
|
||||
file: { id: string; name: string; url: string } | null;
|
||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
||||
note: string | null;
|
||||
}>;
|
||||
allApproved: boolean;
|
||||
}> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
const { inputCode, outputCode, includesCustoms } =
|
||||
clearanceCodesForBooking(booking);
|
||||
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const reviewByKey = new Map(
|
||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||
);
|
||||
|
||||
const documents: Awaited<
|
||||
ReturnType<BookingTransitionService['getClearanceView']>
|
||||
>['documents'] = [];
|
||||
|
||||
const pushSetting = async (
|
||||
code: string | null,
|
||||
uploadedBy: 'customer' | 'gl',
|
||||
) => {
|
||||
if (!code) return;
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(code);
|
||||
} catch {
|
||||
return; // setting not seeded — skip gracefully
|
||||
}
|
||||
for (const field of setting.fields ?? []) {
|
||||
const file = fileByCode.get(field.fileKey) ?? null;
|
||||
const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: field.fileKey,
|
||||
label: field.fileLabel,
|
||||
required: field.isRequired,
|
||||
uploadedBy,
|
||||
settingCode: code,
|
||||
file: file
|
||||
? { id: file.id, name: file.name, url: file.url }
|
||||
: null,
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await pushSetting(inputCode, 'customer');
|
||||
await pushSetting(outputCode, 'gl');
|
||||
|
||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||
for (const f of files) {
|
||||
if (!f.code?.startsWith('custom_')) continue;
|
||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||
documents.push({
|
||||
fileKey: f.code,
|
||||
label: f.name,
|
||||
required: false,
|
||||
uploadedBy: 'customer',
|
||||
settingCode: 'custom',
|
||||
file: { id: f.id, name: f.name, url: f.url },
|
||||
reviewStatus: review?.status ?? null,
|
||||
note: review?.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const allApproved = await this.isClearanceFullyApproved(booking);
|
||||
|
||||
return {
|
||||
status: booking.status,
|
||||
includesCustoms,
|
||||
inputCode,
|
||||
outputCode,
|
||||
documents,
|
||||
allApproved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED field of the booking's customer-input clearance set
|
||||
* has an APPROVED review row. The 100% gate before clearance can be finalized.
|
||||
*/
|
||||
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) return true; // no gate applies (e.g. domestic)
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.fileUploadSettingsService.getByCode(inputCode);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||
if (required.length === 0) return true;
|
||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
||||
return required.every((field) =>
|
||||
reviews.some(
|
||||
(r) =>
|
||||
r.settingCode === inputCode &&
|
||||
r.fileKey === field.fileKey &&
|
||||
r.status === 'APPROVED',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer uploads clearance documents. Each multipart file's fieldname is the
|
||||
* field's fileKey (or custom_<n> for ad-hoc). Saves FileRecords, refreshes the
|
||||
* per-document review rows to PENDING, and moves the booking into review.
|
||||
*/
|
||||
async submitClearanceDocuments(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
|
||||
const { inputCode } = clearanceCodesForBooking(booking);
|
||||
if (!inputCode) {
|
||||
throw new BadRequestException('This booking has no document-clearance step');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const record = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||
const settingCode = file.fieldname.startsWith('custom_')
|
||||
? 'custom'
|
||||
: inputCode;
|
||||
await this.bookingsRepository.upsertDocumentReviewPending({
|
||||
bookingId,
|
||||
settingCode,
|
||||
fileKey: file.fieldname,
|
||||
fileRecordId: record.id,
|
||||
});
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'DOCUMENTS_UNDER_REVIEW',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/** GL reviews a single document: APPROVED or QUERIED (with a note). */
|
||||
async reviewDocument(
|
||||
bookingId: string,
|
||||
fileKey: string,
|
||||
status: 'APPROVED' | 'QUERIED',
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||
|
||||
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||
const match = existing.find((r) => r.fileKey === fileKey);
|
||||
const settingCode =
|
||||
match?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
|
||||
if (status === 'QUERIED' && !note?.trim()) {
|
||||
throw new BadRequestException('A note is required when querying a document');
|
||||
}
|
||||
|
||||
await this.bookingsRepository.setDocumentReviewStatus(
|
||||
bookingId,
|
||||
settingCode,
|
||||
fileKey,
|
||||
status,
|
||||
staffId,
|
||||
note,
|
||||
);
|
||||
if (status === 'QUERIED') {
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
`Document "${fileKey}" queried: ${note}`,
|
||||
'CHANGES_REQUESTED',
|
||||
staffId,
|
||||
);
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
|
||||
async uploadClearanceOutputDocuments(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (!outputCode) {
|
||||
throw new BadRequestException('This booking has no customs output documents');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No documents uploaded');
|
||||
}
|
||||
for (const file of files) {
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: file.fieldname,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL confirms clearance: requires every customer document APPROVED (100% gate)
|
||||
* and, for customs, the required output documents present → CLEARANCE_READY.
|
||||
*/
|
||||
async finalizeClearance(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||
|
||||
const approved = await this.isClearanceFullyApproved(booking);
|
||||
if (!approved) {
|
||||
throw new BadRequestException(
|
||||
'All required documents must be approved before clearance can be finalized',
|
||||
);
|
||||
}
|
||||
|
||||
const { outputCode } = clearanceCodesForBooking(booking);
|
||||
if (outputCode) {
|
||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||||
const uploaded = new Set(files.map((f) => f.code));
|
||||
const missing = (setting.fields ?? []).filter(
|
||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Upload all required customs output documents first: ${missing
|
||||
.map((m) => m.fileLabel)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CLEARANCE_READY',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer proceeds to operation once clearance is ready. They pick the
|
||||
* schedule day (the train departure day) for the shipment; the request then
|
||||
* sits at OPERATION_REQUEST_PENDING for the operations team to review
|
||||
* (capacity, documents, route) before it enters the batch holding pool.
|
||||
*
|
||||
* Allowed from CLEARANCE_READY (first request) and OPERATION_CHANGES_REQUESTED
|
||||
* (resubmit after the operations team returned it for changes).
|
||||
*/
|
||||
async requestOperation(
|
||||
bookingId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
|
||||
|
||||
const date = new Date(scheduledDate);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException('A valid schedule date is required');
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
scheduledDate: date,
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations team reviews a pending operation request (capacity, documents,
|
||||
* route). Three outcomes:
|
||||
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
|
||||
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
|
||||
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
|
||||
* before the booking can enter the pool.
|
||||
*/
|
||||
async reviewOperationRequest(
|
||||
bookingId: string,
|
||||
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
|
||||
actorId: string,
|
||||
options: { note?: string; amount?: number } = {},
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
||||
|
||||
if (decision === 'REQUEST_CHANGES') {
|
||||
if (!options.note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'A note is required when requesting changes',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
options.note,
|
||||
'CHANGES_REQUESTED',
|
||||
actorId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
if (decision === 'ADJUST_PRICE') {
|
||||
if (options.amount == null || options.amount < 0) {
|
||||
throw new BadRequestException(
|
||||
'A non-negative adjusted amount is required to adjust the price',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
adjustedTotalAmount: options.amount,
|
||||
adjustedByStaffId: actorId,
|
||||
adjustedAt: new Date(),
|
||||
adjustmentReason: options.note ?? null,
|
||||
status: 'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
// ACCEPT — enter the batch holding pool.
|
||||
return this.acceptOperationRequest(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
|
||||
* pushes the booking into the pool; rejecting returns it to the customer as an
|
||||
* operation change request so they can resubmit or cancel.
|
||||
*/
|
||||
async confirmOperationPrice(
|
||||
bookingId: string,
|
||||
accept: boolean,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
|
||||
|
||||
if (!accept) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
} as never);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
return this.acceptOperationRequest(booking);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reviewed operation request forward after Marketing accepts.
|
||||
*
|
||||
* - Train services enter the batch holding pool: the pool query
|
||||
* (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we
|
||||
* set those and kick the day-level fill immediately instead of waiting for
|
||||
* cron.
|
||||
* - Road (truck) services skip the train batch entirely and wait for truck
|
||||
* dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons.
|
||||
*/
|
||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||
const now = new Date();
|
||||
|
||||
if (isRoadService(booking.serviceType)) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'ROAD_DISPATCH_PENDING',
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'FULLY_EXECUTED',
|
||||
fullyExecutedAt: now,
|
||||
lockedAt: booking.lockedAt ?? now,
|
||||
} as never);
|
||||
|
||||
if (booking.scheduledDate) {
|
||||
this.bookingBatchService.enqueueRouteDayProcessing(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
eatDay(new Date(booking.scheduledDate)),
|
||||
);
|
||||
}
|
||||
return this.bookingsService.findById(booking.id);
|
||||
}
|
||||
|
||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||
latestChangeRequestNote?: string | null;
|
||||
contractSummary?: string | null;
|
||||
|
||||
@@ -42,10 +42,17 @@ import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
||||
import {
|
||||
AcceptIntakeDto,
|
||||
AdjustPriceDto,
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectBookingDto,
|
||||
RejectStepDto,
|
||||
RequestChangesDto,
|
||||
ReviewDocumentDto,
|
||||
RequestOperationDto,
|
||||
OperationReviewDto,
|
||||
ConfirmOperationPriceDto,
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
@@ -132,10 +139,36 @@ 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 };
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Company-wide by default; the optional filter.companyProfileId (per-page
|
||||
// service filter) narrows within the company. The company guard always
|
||||
// applies, so a customer can only ever see their own company's bookings.
|
||||
return this.bookingsService.findAll(filter, companyId);
|
||||
}
|
||||
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||
@@ -287,6 +320,146 @@ export class BookingsController {
|
||||
return this.transitionService.confirmSubmit(id);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@ApiOperation({
|
||||
summary: 'Customer reject price estimate',
|
||||
description:
|
||||
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
|
||||
})
|
||||
async reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.reject(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||
|
||||
@Get(':id/clearance')
|
||||
@ApiOperation({
|
||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
||||
})
|
||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitionService.getClearanceView(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
||||
})
|
||||
async submitClearanceDocuments(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.submitClearanceDocuments(
|
||||
id,
|
||||
files ?? [],
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/proceed')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests operation with a schedule day ' +
|
||||
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
|
||||
})
|
||||
async proceedToOperation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestOperationDto,
|
||||
) {
|
||||
const booking = await this.transitionService.requestOperation(
|
||||
id,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/review')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
|
||||
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
|
||||
})
|
||||
async reviewOperationRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: OperationReviewDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.reviewOperationRequest(
|
||||
id,
|
||||
dto.decision,
|
||||
resolveAuthUserId(user),
|
||||
{ note: dto.note, amount: dto.amount },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/operation/confirm-price')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer confirms or rejects an operations price adjustment ' +
|
||||
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
|
||||
})
|
||||
async confirmOperationPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ConfirmOperationPriceDto,
|
||||
) {
|
||||
const booking = await this.transitionService.confirmOperationPrice(
|
||||
id,
|
||||
dto.accept,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/review')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
||||
async reviewClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ReviewDocumentDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.reviewDocument(
|
||||
id,
|
||||
dto.fileKey,
|
||||
dto.status,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/output-documents')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
||||
async uploadClearanceOutput(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||
id,
|
||||
files ?? [],
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/finalize')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||
@ApiOperation({
|
||||
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
||||
})
|
||||
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.finalizeClearance(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
|
||||
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||
@@ -305,14 +478,19 @@ export class BookingsController {
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Staff accept intake → set contract validity window + start approval chain',
|
||||
})
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AcceptIntakeDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.acceptIntake(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
dto.validityDays,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
@@ -333,6 +511,25 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/adjust-price')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({
|
||||
summary: 'Staff adjust booking total price (override; null clears it)',
|
||||
})
|
||||
async adjustPrice(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: AdjustPriceDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.adjustPrice(
|
||||
id,
|
||||
dto.amount ?? null,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
@@ -19,6 +22,7 @@ import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingDocumentReview } from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
@@ -31,7 +35,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: [
|
||||
@@ -40,6 +43,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
BookingContainer,
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingDocumentReview,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
@@ -51,7 +55,13 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
SignaturesModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [BookingsController, PayController],
|
||||
providers: [
|
||||
@@ -68,8 +78,7 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
CbeExchangeService,
|
||||
],
|
||||
exports: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQuer
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import {
|
||||
BookingDocumentReview,
|
||||
DocumentReviewStatus,
|
||||
} from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
@@ -25,15 +29,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;
|
||||
allowConsolidation?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
consolidationPaired?: string;
|
||||
}
|
||||
|
||||
@@ -175,7 +182,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
// Only pair bookings the customer has committed (SUBMITTED) or that are
|
||||
// already waiting (PENDING_CONSOLIDATION). DRAFT bookings are excluded so
|
||||
@@ -311,11 +317,87 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return pending === 0;
|
||||
}
|
||||
|
||||
// ── Clearance document reviews ────────────────────────────────────────────
|
||||
|
||||
findDocumentReviews(bookingId: string): Promise<BookingDocumentReview[]> {
|
||||
return this.dataSource.getRepository(BookingDocumentReview).find({
|
||||
where: { bookingId },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
findDocumentReview(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
fileKey: string,
|
||||
): Promise<BookingDocumentReview | null> {
|
||||
return this.dataSource.getRepository(BookingDocumentReview).findOne({
|
||||
where: { bookingId, settingCode, fileKey },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a document-review row to PENDING for a freshly uploaded file. Resets
|
||||
* any prior QUERIED/APPROVED state so the GL re-reviews the new upload.
|
||||
*/
|
||||
async upsertDocumentReviewPending(input: {
|
||||
bookingId: string;
|
||||
settingCode: string;
|
||||
fileKey: string;
|
||||
fileRecordId: string;
|
||||
}): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(BookingDocumentReview);
|
||||
const existing = await repo.findOne({
|
||||
where: {
|
||||
bookingId: input.bookingId,
|
||||
settingCode: input.settingCode,
|
||||
fileKey: input.fileKey,
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await repo.update(existing.id, {
|
||||
fileRecordId: input.fileRecordId,
|
||||
status: 'PENDING',
|
||||
note: null,
|
||||
reviewedByStaffId: null,
|
||||
reviewedAt: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await repo.save(repo.create({ ...input, status: 'PENDING' }));
|
||||
}
|
||||
|
||||
/** GL marks a document APPROVED or QUERIED (with an optional note). */
|
||||
async setDocumentReviewStatus(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
fileKey: string,
|
||||
status: DocumentReviewStatus,
|
||||
staffId: string,
|
||||
note?: string,
|
||||
): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(BookingDocumentReview);
|
||||
const existing = await repo.findOne({
|
||||
where: { bookingId, settingCode, fileKey },
|
||||
});
|
||||
const patch = {
|
||||
status,
|
||||
note: note ?? null,
|
||||
reviewedByStaffId: staffId,
|
||||
reviewedAt: new Date(),
|
||||
};
|
||||
if (existing) {
|
||||
await repo.update(existing.id, patch);
|
||||
return;
|
||||
}
|
||||
await repo.save(repo.create({ bookingId, settingCode, fileKey, ...patch }));
|
||||
}
|
||||
|
||||
/** Persist cargo modifiers linked to rate snapshots. */
|
||||
async createCargoModifiers(
|
||||
rows: Array<{
|
||||
bookingId: string;
|
||||
surchargeTypeId: string;
|
||||
rateId: string;
|
||||
triggerValue: number | null;
|
||||
calculatedAmount: number;
|
||||
rateSnapshotId: string;
|
||||
@@ -434,7 +516,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 +574,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 +667,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 +692,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,
|
||||
@@ -599,11 +728,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
excludePaymentStatus: options.excludePaymentStatus,
|
||||
});
|
||||
}
|
||||
if (options.allowConsolidation !== undefined) {
|
||||
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
|
||||
allowConsolidation: options.allowConsolidation,
|
||||
});
|
||||
}
|
||||
if (options.consolidationPaired === 'true') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
|
||||
} else if (options.consolidationPaired === 'false') {
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 { CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -24,6 +26,7 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
@@ -41,6 +44,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',
|
||||
@@ -110,7 +127,6 @@ export class BookingsService {
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
@@ -135,6 +151,14 @@ export class BookingsService {
|
||||
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
|
||||
);
|
||||
|
||||
// Consolidation is system-managed: the CONSOLIDATION_ENABLED rule trigger
|
||||
// fires whenever a container line leaves a wagon partially filled. There is
|
||||
// no customer opt-in — partial-wagon cargo always consolidates.
|
||||
const allowConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.needsConsolidation(dto.containers)
|
||||
: false;
|
||||
|
||||
return {
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId ?? null,
|
||||
@@ -143,8 +167,7 @@ export class BookingsService {
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
totalWagons,
|
||||
containers,
|
||||
@@ -152,26 +175,20 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable consolidation when any container line leaves a wagon partially filled
|
||||
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
|
||||
*
|
||||
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
|
||||
* half-empty wagon, so `explicit === false` is ignored when consolidation is
|
||||
* actually needed. The opt-in flag only matters for cargo that already fills
|
||||
* whole wagons (where consolidation is moot anyway).
|
||||
* True when any container line leaves a wagon partially filled (e.g. 1×20ft on
|
||||
* a 2-slot wagon). Partial-wagon cargo must consolidate before it can finalize;
|
||||
* cargo that already fills whole wagons never does. This is computed from the
|
||||
* container quantities alone — there is no customer-facing opt-in flag.
|
||||
*/
|
||||
private async resolveConsolidation(
|
||||
private async needsConsolidation(
|
||||
containers: CreateBookingContainerDto[],
|
||||
explicit?: boolean,
|
||||
): Promise<boolean> {
|
||||
const needs = await this.consolidationService.needsConsolidation(
|
||||
return this.consolidationService.needsConsolidation(
|
||||
containers.map((c) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
})),
|
||||
);
|
||||
if (needs) return true;
|
||||
return explicit ?? false;
|
||||
}
|
||||
|
||||
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
|
||||
@@ -181,10 +198,12 @@ export class BookingsService {
|
||||
}> {
|
||||
const messages: string[] = [];
|
||||
|
||||
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
|
||||
if (booking.consolidationPartnerId) {
|
||||
return { booking, messages };
|
||||
}
|
||||
|
||||
// Only partial-wagon container lines produce slots; full-wagon (and bulk)
|
||||
// bookings return none and need no consolidation.
|
||||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||||
if (slots.length === 0) {
|
||||
return { booking, messages };
|
||||
@@ -257,6 +276,7 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
@@ -271,6 +291,12 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
// A customer can only book once their company has been approved.
|
||||
if (company.status !== CompanyStatus.Active) {
|
||||
throw new ForbiddenException(
|
||||
"Your company is awaiting approval — you can't create bookings yet.",
|
||||
);
|
||||
}
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
@@ -291,11 +317,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,9 +350,32 @@ export class BookingsService {
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
// 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 needsConsolidation =
|
||||
dto.freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||
? await this.needsConsolidation(containers)
|
||||
: false;
|
||||
|
||||
const evalInput = await this.buildEvalInput({
|
||||
@@ -336,7 +386,6 @@ export class BookingsService {
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
});
|
||||
@@ -348,6 +397,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,
|
||||
@@ -356,7 +406,13 @@ export class BookingsService {
|
||||
previousContractId: dto.previousContractId,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: dto.firstMilePickupLng ?? null,
|
||||
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
customsClearingEnabled: dto.customsClearingEnabled ?? false,
|
||||
customsClearingAgent: dto.customsClearingAgent ?? null,
|
||||
equipmentReturn: dto.equipmentReturn,
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
@@ -370,11 +426,11 @@ 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',
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
totalAmount: 0,
|
||||
paymentStatus: 'PENDING',
|
||||
@@ -394,6 +450,25 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes + quantities.
|
||||
// Each drawdown order later draws from one of these route lines.
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
dto.routes.map((r) =>
|
||||
routeRepo.create({
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
await this.filesService.uploadMany(booking.id, 'bookings', files);
|
||||
@@ -402,9 +477,36 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the booking profile's onboarding documents instead of asking the
|
||||
// customer to re-upload. Snapshot them onto the booking now (by reference),
|
||||
// so a later active-profile switch never changes this booking's documents.
|
||||
if (companyProfileId) {
|
||||
try {
|
||||
const onboardingFiles =
|
||||
await this.companiesService.getProfileOnboardingFiles(companyProfileId);
|
||||
if (onboardingFiles.length > 0) {
|
||||
await this.filesService.attachExistingFiles(
|
||||
booking.id,
|
||||
'bookings',
|
||||
onboardingFiles.map((f, i) => ({
|
||||
code: `onboarding_document_${i + 1}`,
|
||||
name: f.name,
|
||||
url: f.url,
|
||||
size: f.size,
|
||||
mimeType: f.mimeType,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
warnings.push(
|
||||
'Could not attach onboarding documents — they can be added from the booking page.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let full = await this.findById(booking.id);
|
||||
|
||||
if (allowConsolidation) {
|
||||
if (needsConsolidation) {
|
||||
const consolidation = await this.tryAutoConsolidate(full);
|
||||
full = consolidation.booking;
|
||||
warnings.push(...consolidation.messages);
|
||||
@@ -463,12 +565,9 @@ export class BookingsService {
|
||||
dto.tradeDirection,
|
||||
);
|
||||
|
||||
const allowConsolidation =
|
||||
const needsConsolidation =
|
||||
freightType === 'CONTAINER'
|
||||
? await this.resolveConsolidation(
|
||||
containers,
|
||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
||||
)
|
||||
? await this.needsConsolidation(containers)
|
||||
: false;
|
||||
|
||||
const evalInput = await this.buildEvalInput({
|
||||
@@ -478,7 +577,6 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
|
||||
containers,
|
||||
});
|
||||
@@ -487,12 +585,11 @@ export class BookingsService {
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
warnings.push(...ruleResult.warnings);
|
||||
|
||||
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
|
||||
const pricingFieldsChanged = await this.pricingRelevantFieldsChanged(
|
||||
existing,
|
||||
dto,
|
||||
freightType,
|
||||
cargoTypeId,
|
||||
allowConsolidation,
|
||||
containers,
|
||||
);
|
||||
|
||||
@@ -500,10 +597,25 @@ export class BookingsService {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
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);
|
||||
@@ -534,7 +646,7 @@ export class BookingsService {
|
||||
|
||||
let booking = await this.findById(id);
|
||||
|
||||
if (allowConsolidation && !booking.consolidationPartnerId) {
|
||||
if (needsConsolidation && !booking.consolidationPartnerId) {
|
||||
const consolidation = await this.tryAutoConsolidate(booking);
|
||||
booking = consolidation.booking;
|
||||
warnings.push(...consolidation.messages);
|
||||
@@ -583,7 +695,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,15 +710,21 @@ 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.
|
||||
// The company guard always applies; the optional companyProfileId filter
|
||||
// (from the per-page service filter) narrows WITHIN the company — the repo
|
||||
// ANDs both, so cross-company access is impossible.
|
||||
companyId: forceCompanyId ?? filter.companyId,
|
||||
companyProfileId: forceCompanyProfileId ?? filter.companyProfileId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
@@ -627,7 +746,7 @@ export class BookingsService {
|
||||
async findMyPayable(
|
||||
userId: string,
|
||||
filter: FilterBookingDto,
|
||||
): Promise<{ items: Booking[]; total: number }> {
|
||||
): Promise<PaginatedBookings> {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
@@ -635,7 +754,9 @@ export class BookingsService {
|
||||
pageSize: filter.pageSize ?? 20,
|
||||
statuses: BookingsService.PAYABLE_STATUSES,
|
||||
excludePaymentStatus: 'PAID',
|
||||
// Company-wide: payables span all of the customer's services.
|
||||
companyId: company.id,
|
||||
companyProfileId: filter.companyProfileId,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -655,6 +776,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,10 +886,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,
|
||||
allowConsolidation: filter.allowConsolidation,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
consolidationPaired: filter.consolidationPaired,
|
||||
};
|
||||
|
||||
@@ -868,10 +1000,6 @@ export class BookingsService {
|
||||
}> {
|
||||
const booking = await this.findById(id);
|
||||
|
||||
if (!booking.allowConsolidation) {
|
||||
throw new BadRequestException('Booking is not eligible for consolidation');
|
||||
}
|
||||
|
||||
const needs = await this.consolidationService.needsConsolidationFromBooking(
|
||||
booking,
|
||||
);
|
||||
@@ -955,14 +1083,13 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
private pricingRelevantFieldsChanged(
|
||||
private async pricingRelevantFieldsChanged(
|
||||
existing: Booking,
|
||||
dto: UpdateBookingDto,
|
||||
freightType: FreightType,
|
||||
cargoTypeId: string | null | undefined,
|
||||
allowConsolidation: boolean,
|
||||
containers: CreateBookingContainerDto[],
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
|
||||
return true;
|
||||
}
|
||||
@@ -975,18 +1102,14 @@ export class BookingsService {
|
||||
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
dto.allowConsolidation !== undefined &&
|
||||
dto.allowConsolidation !== existing.allowConsolidation
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
|
||||
return true;
|
||||
}
|
||||
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
|
||||
return true;
|
||||
}
|
||||
// Container lines drive both the base price and the consolidation surcharge
|
||||
// (CONSOLIDATION_ENABLED fires on partial wagons), so any line change re-prices.
|
||||
if (dto.containers !== undefined) {
|
||||
const existingContainers = (existing.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
@@ -1001,8 +1124,7 @@ export class BookingsService {
|
||||
}
|
||||
if (
|
||||
freightType !== existing.freightType ||
|
||||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
|
||||
allowConsolidation !== existing.allowConsolidation
|
||||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -1036,4 +1158,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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
clearanceSettingCode,
|
||||
clearanceOutputSettingCode,
|
||||
} from './clearance.util';
|
||||
|
||||
describe('clearance.util — clearanceSettingCode', () => {
|
||||
it('resolves import container with/without customs', () => {
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_import_container_with_customs',
|
||||
);
|
||||
expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe(
|
||||
'clearance_import_container_without_customs',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves export bulk with/without customs', () => {
|
||||
expect(clearanceSettingCode('EXPORT', 'BULK', true)).toBe(
|
||||
'clearance_export_bulk_with_customs',
|
||||
);
|
||||
expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe(
|
||||
'clearance_export_bulk_without_customs',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for DOMESTIC (no clearance gate)', () => {
|
||||
expect(clearanceSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
||||
expect(clearanceSettingCode('DOMESTIC', 'BULK', false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearance.util — clearanceOutputSettingCode', () => {
|
||||
it('returns a container output code only for customs container bookings', () => {
|
||||
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_output_import_container',
|
||||
);
|
||||
expect(clearanceOutputSettingCode('EXPORT', 'CONTAINER', true)).toBe(
|
||||
'clearance_output_export_container',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null without customs', () => {
|
||||
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for bulk (no container output set) and domestic', () => {
|
||||
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull();
|
||||
expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
|
||||
});
|
||||
});
|
||||
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal file
70
apps/edr-freight-api/src/modules/bookings/clearance.util.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Resolves which seeded clearance FileUploadSetting applies to a booking, from
|
||||
* its trade direction, freight type and whether its service includes customs.
|
||||
* Mirrors the codes seeded in file-upload-settings.seeder.ts.
|
||||
*/
|
||||
|
||||
type Op = 'import' | 'export';
|
||||
type Freight = 'container' | 'bulk';
|
||||
|
||||
/** Trade direction → clearance operation. DOMESTIC has no customs clearance. */
|
||||
function operationFor(tradeDirection: string): Op | null {
|
||||
if (tradeDirection === 'IMPORT') return 'import';
|
||||
if (tradeDirection === 'EXPORT') return 'export';
|
||||
return null; // DOMESTIC / intercity — no clearance gate
|
||||
}
|
||||
|
||||
function freightFor(freightType: string): Freight {
|
||||
return freightType === 'BULK' ? 'bulk' : 'container';
|
||||
}
|
||||
|
||||
/** The customer-input clearance setting code, or null when no gate applies. */
|
||||
export function clearanceSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
const freight = freightFor(freightType);
|
||||
const customs = includesCustoms ? 'with_customs' : 'without_customs';
|
||||
return `clearance_${op}_${freight}_${customs}`;
|
||||
}
|
||||
|
||||
/** The GL-output (customs output) setting code; only container customs sets exist. */
|
||||
export function clearanceOutputSettingCode(
|
||||
tradeDirection: string,
|
||||
freightType: string,
|
||||
includesCustoms: boolean,
|
||||
): string | null {
|
||||
if (!includesCustoms) return null;
|
||||
const op = operationFor(tradeDirection);
|
||||
if (!op) return null;
|
||||
// Only container customs output sets are seeded for this phase.
|
||||
if (freightFor(freightType) !== 'container') return null;
|
||||
return `clearance_output_${op}_container`;
|
||||
}
|
||||
|
||||
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */
|
||||
export function clearanceCodesForBooking(booking: Booking): {
|
||||
inputCode: string | null;
|
||||
outputCode: string | null;
|
||||
includesCustoms: boolean;
|
||||
} {
|
||||
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
|
||||
return {
|
||||
inputCode: clearanceSettingCode(
|
||||
booking.tradeDirection,
|
||||
booking.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
outputCode: clearanceOutputSettingCode(
|
||||
booking.tradeDirection,
|
||||
booking.freightType,
|
||||
includesCustoms,
|
||||
),
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
@@ -57,16 +57,29 @@ export class ConsolidationService {
|
||||
async slotsFromContainerLines(
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
): Promise<ConsolidationSlot[]> {
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
// Aggregate by container type first: two lines of the same type on one
|
||||
// booking share the same wagons. Counting them separately would flag a
|
||||
// self-complete booking (e.g. 2× 20FT = exactly one wagon) as a partial
|
||||
// wagon and wrongly park it in PENDING_CONSOLIDATION.
|
||||
const quantityByType = new Map<string, number>();
|
||||
for (const line of lines) {
|
||||
const ct = await this.containerTypesService.findById(line.containerTypeId);
|
||||
if (!line.containerTypeId) continue;
|
||||
quantityByType.set(
|
||||
line.containerTypeId,
|
||||
(quantityByType.get(line.containerTypeId) ?? 0) + Number(line.quantity || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
for (const [containerTypeId, quantity] of quantityByType) {
|
||||
const ct = await this.containerTypesService.findById(containerTypeId);
|
||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||
const remainder = wagonRemainder(line.quantity, perWagon);
|
||||
const remainder = wagonRemainder(quantity, perWagon);
|
||||
if (remainder === 0) continue;
|
||||
slots.push({
|
||||
containerTypeId: line.containerTypeId,
|
||||
containerTypeId,
|
||||
containerTypeCode: ct.code,
|
||||
quantity: line.quantity,
|
||||
quantity,
|
||||
containersPerWagon: perWagon,
|
||||
remainder,
|
||||
slotsNeeded: perWagon - remainder,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { CreateBookingDto } from './create-booking.dto';
|
||||
|
||||
/**
|
||||
* Boolean flags arrive as STRINGS over multipart/form-data ("true" / "false").
|
||||
* The global freight ValidationPipe runs with enableImplicitConversion = false,
|
||||
* so only the explicit @Transform on each flag coerces it. This pins that the
|
||||
* literal string "false" maps to boolean `false` — class-transformer's implicit
|
||||
* boolean coercion would otherwise turn any non-empty string (including "false")
|
||||
* into `true`, silently flagging non-hazardous bookings as hazardous.
|
||||
*/
|
||||
describe('CreateBookingDto — boolean coercion from multipart strings', () => {
|
||||
// Mirror the production pipe: explicit transforms only, no implicit coercion.
|
||||
const toDto = (plain: Record<string, unknown>) =>
|
||||
plainToInstance(CreateBookingDto, plain, {
|
||||
enableImplicitConversion: false,
|
||||
}) as unknown as CreateBookingDto;
|
||||
|
||||
it('maps the string "false" to boolean false for every flag', () => {
|
||||
const dto = toDto({
|
||||
isHazardous: 'false',
|
||||
isGovernment: 'false',
|
||||
customsClearingEnabled: 'false',
|
||||
});
|
||||
expect(dto.isHazardous).toBe(false);
|
||||
expect(dto.isGovernment).toBe(false);
|
||||
expect(dto.customsClearingEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('maps the string "true" to boolean true for every flag', () => {
|
||||
const dto = toDto({
|
||||
isHazardous: 'true',
|
||||
isGovernment: 'true',
|
||||
customsClearingEnabled: 'true',
|
||||
});
|
||||
expect(dto.isHazardous).toBe(true);
|
||||
expect(dto.isGovernment).toBe(true);
|
||||
expect(dto.customsClearingEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('still coerces numeric form strings to numbers', () => {
|
||||
const dto = toDto({ cargoTotalWeightVgm: '12.5' });
|
||||
expect(dto.cargoTotalWeightVgm).toBe(12.5);
|
||||
expect(typeof dto.cargoTotalWeightVgm).toBe('number');
|
||||
});
|
||||
});
|
||||
@@ -11,13 +11,15 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
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 +29,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
@@ -52,6 +55,42 @@ export class CreateBookingContainerDto {
|
||||
vgmPerUnitTons!: number;
|
||||
}
|
||||
|
||||
export class CreateContractRouteDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
originYardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' })
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for CONTAINER contracts; omit for BULK',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) =>
|
||||
value === undefined || value === null || value === '' ? undefined : Number(value),
|
||||
)
|
||||
km?: number;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
/** Class-level freight shape check (not a request field). */
|
||||
@Validate(BookingFreightShapeConstraint)
|
||||
@@ -105,10 +144,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])
|
||||
@@ -129,11 +182,55 @@ export class CreateBookingDto {
|
||||
@IsString()
|
||||
firstMilePickupAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
firstMilePickupLng?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastMileDeliveryAddress?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-90)
|
||||
@Max(90)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLat?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(-180)
|
||||
@Max(180)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
lastMileDeliveryLng?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether EDR handles customs clearance' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
customsClearingEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent name (when customs is enabled)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiProperty({ enum: EQUIPMENT_RETURNS })
|
||||
@IsIn([...EQUIPMENT_RETURNS])
|
||||
equipmentReturn!: string;
|
||||
@@ -146,6 +243,21 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
/**
|
||||
* GENERAL_CONTRACT only: the routes this contract reserves quantity across.
|
||||
* Each entry has its own origin/destination and quantity; the first entry also
|
||||
* matches the booking's originYardId/destinationYardId. Omitted for one-time
|
||||
* bookings, which use the single origin/destination above.
|
||||
*/
|
||||
@ApiPropertyOptional({ type: [CreateContractRouteDto] })
|
||||
@ValidateIf((o) => o.bookingType === 'GENERAL_CONTRACT')
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateContractRouteDto)
|
||||
routes?: CreateContractRouteDto[];
|
||||
|
||||
@ApiProperty({ enum: TRADE_DIRECTIONS })
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
tradeDirection!: string;
|
||||
@@ -218,10 +330,4 @@ export class CreateBookingDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingContainerDto)
|
||||
containers?: CreateBookingContainerDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
allowConsolidation?: boolean;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -37,6 +38,15 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description:
|
||||
'Narrow to a single operational profile (importer/exporter/freight_forwarder) within the company.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
contractType?: string;
|
||||
@@ -56,6 +66,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])
|
||||
@@ -71,11 +96,6 @@ export class FilterBookingDto {
|
||||
@IsIn([...PAYMENT_STATUSES])
|
||||
paymentStatus?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
allowConsolidation?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'true | false — filter paired consolidation' })
|
||||
@IsOptional()
|
||||
consolidationPaired?: string;
|
||||
|
||||
@@ -7,9 +7,22 @@ export class PriceLineItemDto {
|
||||
@ApiProperty()
|
||||
description!: string;
|
||||
|
||||
/** Computed line total (unitAmount × quantity). Retained for totals elsewhere. */
|
||||
@ApiProperty()
|
||||
amount!: number;
|
||||
|
||||
/** Price for a single unit of this charge (e.g. one 20ft container, one ton). */
|
||||
@ApiProperty()
|
||||
unitAmount!: number;
|
||||
|
||||
/** Unit the rate is charged per: PER_CONTAINER | PER_TON | PER_WAGON | PER_KM | FLAT. */
|
||||
@ApiProperty()
|
||||
unit!: string;
|
||||
|
||||
/** How many units this charge applies to (containers, tons, wagons; 1 for FLAT). */
|
||||
@ApiProperty()
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
currency!: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RequestChangesDto {
|
||||
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||
@@ -8,6 +19,21 @@ export class RequestChangesDto {
|
||||
note!: string;
|
||||
}
|
||||
|
||||
export class AcceptIntakeDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'How many days the contract stays valid, counted from the accept date. ' +
|
||||
'The contract is valid from now through now + validityDays.',
|
||||
minimum: 1,
|
||||
maximum: 365,
|
||||
example: 30,
|
||||
})
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(365)
|
||||
validityDays!: number;
|
||||
}
|
||||
|
||||
export class StaffRejectDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -34,3 +60,92 @@ export class CancelBookingDto {
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class RejectBookingDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Optional reason the customer rejected the price estimate',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class AdjustPriceDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'New total price. Omit or send null to clear a previous adjustment.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class ReviewDocumentDto {
|
||||
@ApiProperty({ description: 'The document fileKey being reviewed' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fileKey!: string;
|
||||
|
||||
@ApiProperty({ enum: ['APPROVED', 'QUERIED'] })
|
||||
@IsIn(['APPROVED', 'QUERIED'])
|
||||
status!: 'APPROVED' | 'QUERIED';
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when querying a document' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class RequestOperationDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'The schedule day (train departure day) the customer selects for this ' +
|
||||
'shipment. ISO date — the booking enters the batch pool for this route + day.',
|
||||
example: '2026-07-15',
|
||||
})
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
}
|
||||
|
||||
export class OperationReviewDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
|
||||
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
|
||||
'customer must re-confirm before it proceeds.',
|
||||
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
|
||||
})
|
||||
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
|
||||
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'New total price — required for ADJUST_PRICE.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export class ConfirmOperationPriceDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
'true to accept the operations price adjustment and proceed to the ' +
|
||||
'batch pool; false to reject it (returns to operation changes requested).',
|
||||
})
|
||||
@IsBoolean()
|
||||
accept!: boolean;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { SurchargeType } from '../../rule-engine/entities/surcharge-type.entity';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_cargo_modifier' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['surchargeTypeId'])
|
||||
@Index(['rateId'])
|
||||
export class BookingCargoModifier extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
@@ -15,12 +15,17 @@ export class BookingCargoModifier extends BaseEntity {
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'surcharge_type_id', type: 'uuid' })
|
||||
surchargeTypeId!: string;
|
||||
/**
|
||||
* The trigger-based rate (hazard, reefer, overweight …) that produced this
|
||||
* surcharge line. Replaces the former surcharge_type link now that rates are
|
||||
* self-describing.
|
||||
*/
|
||||
@Column({ name: 'rate_id', type: 'uuid' })
|
||||
rateId!: string;
|
||||
|
||||
@ManyToOne(() => SurchargeType)
|
||||
@JoinColumn({ name: 'surcharge_type_id' })
|
||||
surchargeType?: SurchargeType;
|
||||
@ManyToOne(() => Rate)
|
||||
@JoinColumn({ name: 'rate_id' })
|
||||
rate?: Rate;
|
||||
|
||||
@Column({ name: 'trigger_value', type: 'numeric', precision: 14, scale: 4, nullable: true })
|
||||
triggerValue?: number | null;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const DOCUMENT_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const;
|
||||
export type DocumentReviewStatus = (typeof DOCUMENT_REVIEW_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Per-document GL review for the post-counter-sign clearance gate. One row per
|
||||
* required clearance document (keyed by fileKey within a setting). GL marks each
|
||||
* APPROVED or QUERIED (with a note); the booking can only proceed once every
|
||||
* required customer document is APPROVED. A QUERIED row returns to PENDING when
|
||||
* the customer re-uploads that file.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_document_review' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
@Index(['bookingId', 'settingCode', 'fileKey'], { unique: true })
|
||||
export class BookingDocumentReview extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
/** The clearance setting this document belongs to (e.g. clearance_import_container_with_customs). */
|
||||
@Column({ name: 'setting_code', type: 'varchar', length: 128 })
|
||||
settingCode!: string;
|
||||
|
||||
/** The required document's stable key within the setting (e.g. commercial_invoice). */
|
||||
@Column({ name: 'file_key', type: 'varchar', length: 128 })
|
||||
fileKey!: string;
|
||||
|
||||
/** The uploaded FileRecord backing this review row (null until uploaded). */
|
||||
@Column({ name: 'file_record_id', type: 'uuid', nullable: true })
|
||||
fileRecordId?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
status!: DocumentReviewStatus;
|
||||
|
||||
/** GL note explaining a QUERIED status. */
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
}
|
||||
@@ -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,29 @@ export const BOOKING_STATUSES = [
|
||||
'CANCELLED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CONSOLIDATED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Post counter-sign document-clearance gate (GL workflow).
|
||||
'AWAITING_DOCUMENTS',
|
||||
'DOCUMENTS_UNDER_REVIEW',
|
||||
'CLEARANCE_READY',
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// operation request; the operations team reviews capacity/docs/route before
|
||||
// the booking enters the batch holding pool.
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'OPERATION_PRICE_PENDING_CONFIRM',
|
||||
] 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 +112,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,12 +144,63 @@ 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;
|
||||
|
||||
/**
|
||||
* Staff-adjusted total price. When set, it overrides the computed totalAmount
|
||||
* for the customer, who is shown an "Adjusted by EDR" badge.
|
||||
*/
|
||||
@Column({ name: 'adjusted_total_amount', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||
adjustedTotalAmount?: number | null;
|
||||
|
||||
@Column({ name: 'adjusted_by_staff_id', type: 'uuid', nullable: true })
|
||||
adjustedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'adjusted_at', type: 'timestamptz', nullable: true })
|
||||
adjustedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'adjustment_reason', type: 'text', nullable: true })
|
||||
adjustmentReason?: string | null;
|
||||
|
||||
/**
|
||||
* Contract validity window, set by the backoffice at the accept step. The
|
||||
* staff enter a number of days; the contract is valid from contractValidFrom
|
||||
* (the accept moment) through contractValidUntil (validFrom + N days). Outside
|
||||
* this window the contract is expired and the booking cannot proceed.
|
||||
*/
|
||||
@Column({ name: 'contract_validity_days', type: 'int', nullable: true })
|
||||
contractValidityDays?: number | null;
|
||||
|
||||
@Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true })
|
||||
contractValidFrom?: Date | null;
|
||||
|
||||
@Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true })
|
||||
contractValidUntil?: Date | null;
|
||||
|
||||
@Column({ name: 'payment_status', type: 'varchar', length: 20, default: 'PENDING' })
|
||||
paymentStatus!: string;
|
||||
|
||||
@@ -139,9 +224,27 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true })
|
||||
firstMilePickupAddress?: string | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLat?: number | null;
|
||||
|
||||
@Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
firstMilePickupLng?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true })
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLat?: number | null;
|
||||
|
||||
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
@Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true })
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
@Column({ name: 'equipment_return', type: 'varchar', length: 20 })
|
||||
equipmentReturn!: string;
|
||||
|
||||
@@ -188,6 +291,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
/**
|
||||
* Refrigerated cargo flag. For one-time bookings reefer is derived from the
|
||||
* container type; for general-contract drawdown orders the customer enters a
|
||||
* reefer quantity per order, which sets this flag on the spawned child so the
|
||||
* REEFER_SURCHARGE rate applies even when the container type is not a reefer.
|
||||
*/
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5 })
|
||||
paymentCurrency!: string;
|
||||
|
||||
@@ -254,9 +366,6 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
||||
priorityScore!: number;
|
||||
|
||||
@Column({ name: 'allow_consolidation', type: 'boolean', default: false })
|
||||
allowConsolidation!: boolean;
|
||||
|
||||
@Column({ name: 'consolidation_partner_id', type: 'uuid', nullable: true })
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
|
||||
@@ -24,15 +24,26 @@ 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 { DashboardQueryDto } from "./dto/dashboard-query.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;
|
||||
@@ -76,8 +87,21 @@ export class CompaniesController {
|
||||
})
|
||||
async getDashboard(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Query() query: DashboardQueryDto,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
return this.companiesService.getDashboardSummary(user.id);
|
||||
return this.companiesService.getDashboardSummary(
|
||||
user.id,
|
||||
query.companyProfileId,
|
||||
);
|
||||
}
|
||||
|
||||
@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")
|
||||
@@ -105,6 +129,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 +273,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 +316,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 +344,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" })
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { ExternalProfileRepository } from "./external-profile.repository";
|
||||
import { CompanyDashboardRepository } from "./company-dashboard.repository";
|
||||
import {
|
||||
CompanyDashboardRepository,
|
||||
DashboardScope,
|
||||
} 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 +21,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 +52,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 +92,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 +153,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 +285,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 +302,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 };
|
||||
}
|
||||
@@ -167,6 +324,7 @@ export class CompaniesService {
|
||||
*/
|
||||
async getDashboardSummary(
|
||||
userId: string,
|
||||
companyProfileId?: string,
|
||||
): Promise<DashboardSummaryResponseDto> {
|
||||
// A user without a company profile has no bookings — return an empty summary
|
||||
// rather than 404, so the portal home still renders.
|
||||
@@ -174,6 +332,18 @@ export class CompaniesService {
|
||||
const companyId = profile?.company?.id ?? profile?.companyId ?? null;
|
||||
if (!companyId) return this.emptyDashboardSummary();
|
||||
|
||||
// Company-wide by default (all services' data). An optional companyProfileId
|
||||
// (from the per-page service filter) narrows to one operational profile —
|
||||
// but only after we confirm it belongs to this user's company, since the
|
||||
// dashboard scope has no company guard at the repository layer.
|
||||
let scope: DashboardScope = { companyId };
|
||||
if (companyProfileId) {
|
||||
const owned = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
if (owned.some((p) => p.id === companyProfileId)) {
|
||||
scope = { companyProfileId };
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||
const prevYearStart = new Date(now.getFullYear() - 1, 0, 1);
|
||||
@@ -191,22 +361,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 +493,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 +521,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 +600,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 +618,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 +731,256 @@ 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",
|
||||
});
|
||||
// Awaiting backoffice approval — stays Pending until an admin activates it.
|
||||
await this.companiesRepo.update(companyId, {
|
||||
status: CompanyStatus.Pending,
|
||||
});
|
||||
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 ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding documents stored on a company profile, fetched by profile id.
|
||||
* Internal helper (no ownership check) used when a booking reuses the active
|
||||
* profile's onboarding documents. Returns [] when the profile is unknown.
|
||||
*/
|
||||
async getProfileOnboardingFiles(
|
||||
profileId: string,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
const profile = await this.companyProfilesRepo.findById(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export class CompanyStatsResponseDto {
|
||||
total!: number;
|
||||
active!: number;
|
||||
pending!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -17,7 +18,9 @@ export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
tin!: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -37,6 +40,7 @@ export class CreateCompanyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
@IsValidPhone()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export class DashboardQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: "uuid",
|
||||
description:
|
||||
"Narrow dashboard KPIs to a single operational profile (importer/exporter/freight_forwarder) of the user's company. Omit for company-wide totals.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyProfileId?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { ProfileType } from '../entities/company-profile.entity';
|
||||
|
||||
export class SetActiveModeDto {
|
||||
@IsEnum(ProfileType)
|
||||
type!: ProfileType;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SetOnboardingStepDto {
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
step!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
@@ -28,7 +35,9 @@ export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(10, 10)
|
||||
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||
@Matches(/^00\d{8}$/, {
|
||||
message: 'TIN must be 10 digits starting with 00',
|
||||
})
|
||||
tin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -47,6 +56,15 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPersonPosition?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
contactPersonEmail?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
contactPersonPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -59,6 +77,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
generalManagerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -67,6 +86,7 @@ export class UpdateProfileDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsValidPhone()
|
||||
poaPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -80,4 +100,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user