This commit is contained in:
Roba Boru
2026-07-02 16:08:20 +03:00
277 changed files with 23834 additions and 4528 deletions

View File

@@ -23,6 +23,7 @@
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",

View File

@@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module'; import { WagonsModule } from './modules/wagons/wagons.module';
@@ -69,6 +70,8 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module'; import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module'; import { DriversModule } from './modules/drivers/drivers.module';
import { FuelModule } from './modules/fuel/fuel.module';
import { MaintenanceModule } from './modules/maintenance/maintenance.module';
import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module';
import { LastMileModule } from './modules/last-mile/last-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module';
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
@@ -133,6 +136,8 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
OverviewModule, OverviewModule,
VehiclesModule, VehiclesModule,
DriversModule, DriversModule,
FuelModule,
MaintenanceModule,
FirstMileModule, FirstMileModule,
LastMileModule, LastMileModule,
InterchangeDocumentsModule, InterchangeDocumentsModule,
@@ -156,6 +161,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
ExportDjiboutiInterchangeDemoSeeder, ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder, MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder, ApprovedFirstLastMileDemoBookingsSeeder,
PaidIndodeDemoBookingsSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -174,6 +180,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder,
@@ -195,6 +202,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.warehouseDemoSeeder.run(); await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run(); await this.marshallingDemoTrainsSeeder.run();
await this.paidIndodeDemoBookingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // 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. // Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

@@ -116,8 +116,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => {
freightMigrationsGlob, freightMigrationsGlob,
], ],
migrationsRun: true, migrationsRun: true,
migrationsTransactionMode: "each",
// Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows).
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === "development", logging:
process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"],
}; };
}); });

View File

@@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder {
serviceType: this.valueOrDash( serviceType: this.valueOrDash(
contract.serviceType?.serviceName ?? contract.serviceType?.code, contract.serviceType?.serviceName ?? contract.serviceType?.code,
), ),
scheduledDate: this.formatDate(contract.estimatedShipmentDate), // Estimated shipment date was removed from the contract wizard; the
// binding scheduled date is set per-booking, not on the contract.
scheduledDate: this.formatDate(null),
contractType: this.valueOrDash(contract.contractType), contractType: this.valueOrDash(contract.contractType),
cargoDescription: this.valueOrDash(cargoName), cargoDescription: this.valueOrDash(cargoName),
totalWeightVgm: '—', totalWeightVgm: '—',

View File

@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
{ name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'free_days', type: 'int', default: 0 }, { name: 'free_days', type: 'int', default: 0 },
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'is_active', type: 'boolean', default: true }, { name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' }, { name: 'created_at', type: 'timestamptz', default: 'now()' },

View File

@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`); `);
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, `
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
ADD COLUMN IF NOT EXISTS source varchar(255),
ADD COLUMN IF NOT EXISTS source_id varchar(255),
ADD COLUMN IF NOT EXISTS type varchar(255),
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
ADD COLUMN IF NOT EXISTS payment_id uuid,
ADD COLUMN IF NOT EXISTS due_at timestamptz,
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
`,
);
await queryRunner.query(`
UPDATE freight.invoices
SET due_at = COALESCE(due_at, issued_at, created_at, now())
WHERE due_at IS NULL;
`);
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE contype = 'p'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_invoices_invoice_number'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company_profile'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_payment'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`, `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
); );
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE freight.invoice_lines ( CREATE TABLE IF NOT EXISTS freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(), id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL, invoice_id uuid NOT NULL,
charge_type varchar NOT NULL, charge_type varchar NOT NULL,
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`); `);
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
); );
} }

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous
* or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item
* count for PER_ITEM). These two columns hold that amount on the booking; they
* stay 0 for container freight (which tracks it per line on booking_container)
* and for bulk cargo with no hazardous/reefer portion. The existing
* is_hazardous / is_reefer booleans remain the surcharge trigger.
*/
export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface {
name = 'AddBulkHazmatReeferQuantity1828000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`,
);
}
}

View File

@@ -19,6 +19,31 @@ export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterf
name = 'CentralizeWarehouseInvoices1829000000000'; name = 'CentralizeWarehouseInvoices1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name = 'booking_id'
) THEN
ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'freight'
AND table_name = 'invoices'
AND column_name = 'amount'
) THEN
ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL;
END IF;
END $$;
`);
// 1. Invoice headers. Keep the same id so items still link, and so any // 1. Invoice headers. Keep the same id so items still link, and so any
// external reference to the invoice id stays valid. // external reference to the invoice id stays valid.
await queryRunner.query(` await queryRunner.query(`

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface {
name = 'PhasedClearanceCycleMeta1829000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`,
);
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`,
);
}
}

View File

@@ -0,0 +1,46 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Admin-configurable minimum days between today and export RO vessel departure. */
export class SeedRoVesselMinDays1829000000001 implements MigrationInterface {
name = 'SeedRoVesselMinDays1829000000001';
private readonly code = 'ro_vessel_min_days';
private readonly options: Array<{ value: string; label: string }> = [
{ value: '2', label: '2 days' },
{ value: '3', label: '3 days' },
];
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,
'RO vessel minimum lead time (days)',
'Minimum days between today and the vessel departure date on an export Release Order.',
],
);
const settingId = inserted[0].id;
for (let i = 0; i < this.options.length; i++) {
const opt = this.options[i];
await queryRunner.query(
`INSERT INTO freight.dropdown_options (setting_id, value, label, display_order)
VALUES ($1, $2, $3, $4);`,
[settingId, opt.value, opt.label, i],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [
this.code,
]);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class BookingClearanceMeta1829000000002 implements MigrationInterface {
name = 'BookingClearanceMeta1829000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`,
);
}
}

View File

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

View File

@@ -0,0 +1,72 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface {
name = 'RouteStatusAndSegmentKm1830000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE'
`);
await queryRunner.query(`
UPDATE freight.routes
SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_routes_name"
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS name
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status)
`);
await queryRunner.query(`
ALTER TABLE freight.route_milestones
ADD COLUMN IF NOT EXISTS distance_km numeric(10,2)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS name varchar(120)
`);
await queryRunner.query(`
UPDATE freight.routes SET name = id::text WHERE name IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true
`);
await queryRunner.query(`
UPDATE freight.routes
SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END
`);
await queryRunner.query(`
ALTER TABLE freight.routes DROP COLUMN IF EXISTS status
`);
await queryRunner.query(`
DROP INDEX IF EXISTS freight."IDX_routes_status"
`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name)
`);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface {
name = 'PreClearanceFinalizedAt1830000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`,
);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
name = 'AddWarehouseFeeRuleTiers1831000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
DROP COLUMN IF EXISTS tiers;
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customer_truck_arrived_at,
DROP COLUMN IF EXISTS customer_truck_assigned_at,
DROP COLUMN IF EXISTS customer_truck_container_number,
DROP COLUMN IF EXISTS customer_truck_type,
DROP COLUMN IF EXISTS customer_truck_driver_name,
DROP COLUMN IF EXISTS customer_truck_plate_number
`);
}
}

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateFuelTables1840000000000 implements MigrationInterface {
name = "CreateFuelTables1840000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
const fuelPurchasesExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`,
);
if (!fuelPurchasesExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_purchases (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
purchase_date timestamptz NOT NULL,
liters numeric(10, 2) NOT NULL,
cost_per_liter numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
fuel_station varchar(255) NULL,
payment_method varchar(50) DEFAULT 'CASH',
odometer_reading numeric(10, 2) NULL,
driver_id uuid NULL,
receipt_number varchar(255) NULL,
notes text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_purchases PRIMARY KEY (id),
CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`,
);
await queryRunner.query(
`CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`,
);
}
const fuelConsumptionExists = await queryRunner.query(
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`,
);
if (!fuelConsumptionExists.length) {
await queryRunner.query(`
CREATE TABLE freight.fuel_consumption (
id uuid NOT NULL DEFAULT gen_random_uuid(),
vehicle_id uuid NOT NULL,
month date NOT NULL,
total_liters numeric(10, 2) NOT NULL,
total_cost numeric(14, 2) NOT NULL,
total_distance_km numeric(10, 2) NOT NULL,
fuel_efficiency_km_per_l numeric(10, 2) NULL,
number_of_purchases integer DEFAULT 0,
average_cost_per_liter numeric(10, 2) NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz NULL,
CONSTRAINT pk_fuel_consumption PRIMARY KEY (id),
CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id)
REFERENCES freight.vehicles (id) ON DELETE CASCADE,
CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month)
);
`);
await queryRunner.query(
`CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`);
}
}

View File

@@ -0,0 +1,82 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateMaintenanceTables1850000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
// Create maintenance_schedules table
const scheduleTableExists = await queryRunner.query(`
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules'
)
`);
if (!scheduleTableExists[0].exists) {
await queryRunner.query(`
CREATE TABLE "freight"."maintenance_schedules" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"vehicle_id" uuid NOT NULL,
"maintenance_type" varchar NOT NULL,
"description" varchar NOT NULL,
"scheduled_date" timestamptz NOT NULL,
"completed_date" timestamptz,
"estimated_cost" numeric(14,2),
"actual_cost" numeric(14,2),
"status" varchar NOT NULL DEFAULT 'SCHEDULED',
"odometer_reading" numeric,
"service_provider" varchar,
"notes" text,
"next_due_km" numeric,
"next_due_date" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
PRIMARY KEY ("id")
)
`);
await queryRunner.query(
`CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")`
);
}
// Create maintenance_costs table
const costsTableExists = await queryRunner.query(`
SELECT EXISTS(
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'freight' AND table_name = 'maintenance_costs'
)
`);
if (!costsTableExists[0].exists) {
await queryRunner.query(`
CREATE TABLE "freight"."maintenance_costs" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"vehicle_id" uuid NOT NULL,
"maintenance_schedule_id" uuid,
"incurred_date" timestamptz NOT NULL,
"cost_amount" numeric(14,2) NOT NULL,
"cost_type" varchar NOT NULL,
"description" varchar NOT NULL,
"service_provider" varchar,
"invoice_number" varchar,
"notes" text,
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
PRIMARY KEY ("id"),
CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id")
REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL
)
`);
await queryRunner.query(
`CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")`
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`);
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`);
}
}

View File

@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add paid column to first_mile and last_mile tables to track invoice payment status.
*/
export class AddPaidToFirstAndLastMile1860000000000
implements MigrationInterface
{
name = "AddPaidToFirstAndLastMile1860000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.first_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
`);
await queryRunner.query(`
ALTER TABLE freight.last_mile
ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.first_mile
DROP COLUMN IF EXISTS paid;
`);
await queryRunner.query(`
ALTER TABLE freight.last_mile
DROP COLUMN IF EXISTS paid;
`);
}
}

View File

@@ -1,5 +1,5 @@
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards"; import { FreightAdmin } from "../../common/booking-guards";
@@ -8,6 +8,7 @@ import { BillingService } from "./billing.service";
@ApiTags("billing") @ApiTags("billing")
@Controller("billing") @Controller("billing")
@FreightAdmin() @FreightAdmin()
@ApiBearerAuth()
export class BillingController { export class BillingController {
constructor(private readonly billingService: BillingService) { } constructor(private readonly billingService: BillingService) { }

View File

@@ -3,6 +3,7 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { BillingController } from "./billing.controller"; import { BillingController } from "./billing.controller";
import { PortalBillingController } from "./portal-billing.controller"; import { PortalBillingController } from "./portal-billing.controller";
import { PaymentController } from "./payment.controller";
import { BillingService } from "./billing.service"; import { BillingService } from "./billing.service";
import { DocumentsModule } from "./documents/documents.module"; import { DocumentsModule } from "./documents/documents.module";
import { Invoice } from "./entities/invoice.entity"; import { Invoice } from "./entities/invoice.entity";
@@ -19,7 +20,7 @@ import { CompaniesModule } from "../companies/companies.module";
CompaniesModule, CompaniesModule,
DocumentsModule, DocumentsModule,
], ],
controllers: [BillingController, PortalBillingController], controllers: [BillingController, PortalBillingController, PaymentController],
providers: [BillingService, InvoiceRepository, InvoiceLineRepository], providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
exports: [BillingService], exports: [BillingService],
}) })

View File

@@ -116,12 +116,14 @@ describe("BillingService.generateInvoice", () => {
}); });
describe("BillingService.markInvoiceAsPaid", () => { describe("BillingService.markInvoiceAsPaid", () => {
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => { it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => {
const open = { const open = {
id: "inv-1", id: "inv-1",
status: Freight.InvoiceStatus.Pending, status: Freight.InvoiceStatus.Pending,
source: "booking", source: "booking",
sourceId: "booking-1", sourceId: "booking-1",
totalAmount: 1500,
paidAt: null,
}; };
const mg = { const mg = {
findOne: jest.fn().mockResolvedValue(open), findOne: jest.fn().mockResolvedValue(open),
@@ -143,7 +145,22 @@ describe("BillingService.markInvoiceAsPaid", () => {
expect(mg.update).toHaveBeenCalledWith( expect(mg.update).toHaveBeenCalledWith(
expect.anything(), expect.anything(),
{ id: "inv-1" }, { id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, {
status: Freight.InvoiceStatus.Paid,
paymentId: "pay-1",
paidAt: expect.any(Date),
paidAmount: 1500,
balanceAmount: 0,
payments: [
{
amount: 1500,
method: "GATEWAY",
reference: "pay-1",
paidAt: expect.any(String),
metadata: null,
},
],
},
); );
expect(events.emit).toHaveBeenCalledWith( expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid", "booking.invoice.paid",
@@ -190,8 +207,14 @@ describe("BillingService.recordPayment", () => {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
}; };
const events = makeEvents(); const events = makeEvents();
const dataSource = {
manager: mg,
transaction: jest
.fn()
.mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)),
};
const service = new BillingService( const service = new BillingService(
{ manager: mg } as never, dataSource as never,
{} as never, {} as never,
{} as never, {} as never,
events as never, events as never,
@@ -257,6 +280,14 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).not.toHaveBeenCalled(); expect(mg.update).not.toHaveBeenCalled();
}); });
it("rejects a payment that exceeds the outstanding balance", async () => {
const { service, mg } = serviceFor(openInvoice());
await expect(
service.recordPayment("inv-1", { amount: 1500 }),
).rejects.toThrow();
expect(mg.update).not.toHaveBeenCalled();
});
it("rejects payment against a cancelled invoice", async () => { it("rejects payment against a cancelled invoice", async () => {
const { service, mg } = serviceFor( const { service, mg } = serviceFor(
openInvoice({ status: Freight.InvoiceStatus.Cancelled }), openInvoice({ status: Freight.InvoiceStatus.Cancelled }),
@@ -265,74 +296,3 @@ describe("BillingService.recordPayment", () => {
expect(mg.update).not.toHaveBeenCalled(); expect(mg.update).not.toHaveBeenCalled();
}); });
}); });
describe("BillingService.settlePayable", () => {
it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => {
const open = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
source: Freight.InvoiceSource.Booking,
sourceId: "booking-1",
};
const mg = {
findOne: jest.fn().mockResolvedValue(open),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled?.status).toBe(Freight.InvoiceStatus.Paid);
expect(mg.update).toHaveBeenCalledWith(
expect.anything(),
{ id: "inv-1" },
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
);
expect(events.emit).toHaveBeenCalledWith(
"booking.invoice.paid",
expect.anything(),
);
});
it("is a no-op (returns null) when the source has no open invoice", async () => {
const mg = {
findOne: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue(undefined),
};
const events = makeEvents();
const service = new BillingService(
{ manager: mg } as never,
{} as never,
{} as never,
events as never,
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
);
const settled = await service.settlePayable(
Freight.InvoiceSource.Booking,
"booking-1",
"pay-1",
mg as never,
);
expect(settled).toBeNull();
expect(mg.update).not.toHaveBeenCalled();
expect(events.emit).not.toHaveBeenCalled();
});
});

View File

@@ -49,7 +49,6 @@ const DEFAULT_DUE_DAYS = 14;
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ /** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
const OPEN_STATUSES: Freight.InvoiceStatus[] = [ const OPEN_STATUSES: Freight.InvoiceStatus[] = [
Freight.InvoiceStatus.Draft,
Freight.InvoiceStatus.Issued, Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending, Freight.InvoiceStatus.Pending,
Freight.InvoiceStatus.PartiallyPaid, Freight.InvoiceStatus.PartiallyPaid,
@@ -285,20 +284,15 @@ export class BillingService {
/** /**
* Initiate gateway payment for one of the customer's own invoices. Verifies * Initiate gateway payment for one of the customer's own invoices. Verifies
* ownership, then charges whichever open invoice the source currently has * ownership, then charges the invoice directly by ID (see {@link payInvoice}).
* (see {@link payInvoice}).
*/ */
async payInvoiceForUser( async payInvoiceForUser(
id: string, id: string,
userId: string, userId: string,
opts: PayInvoiceOptions = {}, opts: PayInvoiceOptions = {},
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.findByIdForUser(id, userId); await this.findByIdForUser(id, userId);
return this.payInvoice( return this.payInvoice(id, opts);
invoice.source as Freight.InvoiceSource,
invoice.sourceId,
opts,
);
} }
/** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */
@@ -344,6 +338,7 @@ export class BillingService {
input: GenerateInvoiceInput, input: GenerateInvoiceInput,
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice & { lines: InvoiceLine[] }> { ): Promise<Invoice & { lines: InvoiceLine[] }> {
console.log("oooooooooo", input);
const run = (mg: EntityManager) => this.createInvoice(input, mg); const run = (mg: EntityManager) => this.createInvoice(input, mg);
return manager ? run(manager) : this.dataSource.transaction(run); return manager ? run(manager) : this.dataSource.transaction(run);
} }
@@ -422,23 +417,89 @@ export class BillingService {
// ── State transitions ──────────────────────────────────────────────────────── // ── State transitions ────────────────────────────────────────────────────────
/** /**
* Mark an invoice paid and link the gateway payment, then emit * Run `fn` inside a transaction and only emit its returned domain event
* `${source}.invoice.paid`. Full-payment only — no partial settlement. * after commit. When the caller passes their own `manager`, they own commit
* No-op when the invoice is already paid. Pass `manager` to enlist in a * timing — `fn`'s event fires inline as soon as it resolves (the outer
* caller's transaction. * transaction may still roll back afterwards; this is the caller's
* documented tradeoff). When no `manager` is given, this opens its own
* transaction and defers the emit until after that transaction commits, so
* listeners (e.g. booking advancement) can never observe an invoice change
* that then rolls back.
*/
private async runTransition<T>(
manager: EntityManager | undefined,
fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>,
): Promise<T> {
if (manager) {
const { result, emit } = await fn(manager);
emit?.();
return result;
}
let pending: (() => void) | undefined;
const result = await this.dataSource.transaction(async (mg) => {
const out = await fn(mg);
pending = out.emit;
return out.result;
});
pending?.();
return result;
}
/**
* Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts,
* append the settlement to the `payments` ledger, link the gateway payment,
* then emit `${source}.invoice.paid`. Full-payment only — no partial
* settlement. No-op when the invoice is already paid. Pass `manager` to
* enlist in a caller's transaction; otherwise locks the row for update and
* emits only after commit (see {@link runTransition}).
*/ */
async markInvoiceAsPaid( async markInvoiceAsPaid(
invoiceId: string, invoiceId: string,
paymentId: string | null = null, paymentId: string | null = null,
manager?: EntityManager, manager?: EntityManager,
settlement: { providerTxnId?: string; paidAt?: Date } = {},
): Promise<Invoice | null> { ): Promise<Invoice | null> {
return this.transition( return this.runTransition(manager, async (mg) => {
invoiceId, const invoice = await mg.findOne(Invoice, {
Freight.InvoiceStatus.Paid, where: { id: invoiceId },
"paid", lock: { mode: "pessimistic_write" },
{ paymentId: paymentId ?? undefined }, });
manager, if (!invoice) {
); throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
return { result: invoice };
}
const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date();
const settledAmount = round2(
Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0),
);
const entry: InvoicePayment = {
amount: settledAmount,
method: "GATEWAY",
reference: settlement.providerTxnId ?? paymentId ?? null,
paidAt: paidAt.toISOString(),
metadata: null,
};
const payments = [...(invoice.payments ?? []), entry];
const patch = {
status: Freight.InvoiceStatus.Paid,
paymentId,
paidAt,
paidAmount: invoice.totalAmount,
balanceAmount: 0,
payments,
};
await mg.update(Invoice, { id: invoiceId }, patch as never);
const updated = { ...invoice, ...patch } as Invoice;
return {
result: updated,
emit: () => this.emitInvoiceEvent("paid", updated),
};
});
} }
/** /**
@@ -450,9 +511,11 @@ export class BillingService {
* at the warehouse counter); gateway settlement goes through * at the warehouse counter); gateway settlement goes through
* {@link markInvoiceAsPaid}. * {@link markInvoiceAsPaid}.
* *
* Throws when the invoice is missing, cancelled, refunded, already fully paid, * Throws when the invoice is missing, cancelled, refunded, already fully
* or when `amount` is not positive. Pass `manager` to enlist in a caller's * paid, `amount` is not positive, or `amount` exceeds the outstanding
* transaction. * balance. Pass `manager` to enlist in a caller's transaction; otherwise
* locks the row for update and emits only after commit (see
* {@link runTransition}).
*/ */
async recordPayment( async recordPayment(
invoiceId: string, invoiceId: string,
@@ -465,62 +528,71 @@ export class BillingService {
); );
} }
const mg = manager ?? this.dataSource.manager; return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); const invoice = await mg.findOne(Invoice, {
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); where: { id: invoiceId },
if (invoice.status === Freight.InvoiceStatus.Cancelled) { lock: { mode: "pessimistic_write" },
throw new BadRequestException("Cannot pay a cancelled invoice."); });
} if (!invoice) {
if (invoice.status === Freight.InvoiceStatus.Refunded) { throw new NotFoundException(`Invoice ${invoiceId} not found`);
throw new BadRequestException("Cannot pay a refunded invoice."); }
} if (invoice.status === Freight.InvoiceStatus.Cancelled) {
if (invoice.status === Freight.InvoiceStatus.Paid) { throw new BadRequestException("Cannot pay a cancelled invoice.");
throw new BadRequestException("Invoice is already fully paid."); }
} if (invoice.status === Freight.InvoiceStatus.Refunded) {
throw new BadRequestException("Cannot pay a refunded invoice.");
}
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException("Invoice is already fully paid.");
}
if (round2(input.amount) > Number(invoice.balanceAmount)) {
throw new BadRequestException(
`Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`,
);
}
const at = input.paidAt ?? new Date(); const at = input.paidAt ?? new Date();
const { paidAmount, balanceAmount, fullyPaid } = applySettlement( const { paidAmount, balanceAmount, fullyPaid } = applySettlement(
invoice.totalAmount, invoice.totalAmount,
invoice.paidAmount, invoice.paidAmount,
input.amount, input.amount,
); );
const status = fullyPaid const status = fullyPaid
? Freight.InvoiceStatus.Paid ? Freight.InvoiceStatus.Paid
: Freight.InvoiceStatus.PartiallyPaid; : Freight.InvoiceStatus.PartiallyPaid;
const entry: InvoicePayment = { const entry: InvoicePayment = {
amount: round2(input.amount), amount: round2(input.amount),
method: input.method ?? null, method: input.method ?? null,
reference: input.reference ?? null, reference: input.reference ?? null,
paidAt: at.toISOString(), paidAt: at.toISOString(),
metadata: input.metadata ?? null, metadata: input.metadata ?? null,
}; };
const payments = [...(invoice.payments ?? []), entry]; const payments = [...(invoice.payments ?? []), entry];
await mg.update(Invoice, { id: invoice.id }, { const patch = {
paidAmount, paidAmount,
balanceAmount, balanceAmount,
status, status,
payments, payments,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null), paidAt: fullyPaid ? at : (invoice.paidAt ?? null),
} as never); };
await mg.update(Invoice, { id: invoice.id }, patch as never);
const updated = { const updated = { ...invoice, ...patch } as Invoice;
...invoice, return {
paidAmount, result: updated,
balanceAmount, emit: fullyPaid
status, ? () => this.emitInvoiceEvent("paid", updated)
payments, : undefined,
paidAt: fullyPaid ? at : (invoice.paidAt ?? null), };
} as Invoice; });
if (fullyPaid) this.emitInvoiceEvent("paid", updated);
return updated;
} }
/** /**
* Mark an invoice refunded and emit `${source}.invoice.refunded`. * Mark an invoice refunded and emit `${source}.invoice.refunded`.
* No-op when already refunded. * No-op when already refunded. Throws when the invoice has no recorded
* payment (nothing to refund).
*/ */
async markInvoiceAsRefunded( async markInvoiceAsRefunded(
invoiceId: string, invoiceId: string,
@@ -532,12 +604,20 @@ export class BillingService {
"refunded", "refunded",
{}, {},
manager, manager,
(invoice) => {
if (!(Number(invoice.paidAmount) > 0)) {
throw new BadRequestException(
"Cannot refund an invoice with no recorded payment.",
);
}
},
); );
} }
/** /**
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`. * Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
* No-op when already cancelled. * No-op when already cancelled. Throws when the invoice has payments
* recorded against it (refund it instead).
*/ */
async cancelInvoice( async cancelInvoice(
invoiceId: string, invoiceId: string,
@@ -549,16 +629,23 @@ export class BillingService {
"cancelled", "cancelled",
{}, {},
manager, manager,
(invoice) => {
if (Number(invoice.paidAmount) > 0) {
throw new BadRequestException(
"Cannot cancel an invoice that has payments recorded against it.",
);
}
},
); );
} }
/** /**
* Load the invoice, apply the new status (+ extra columns), then emit * Load the invoice, apply the new status (+ extra columns), then emit
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already * `${source}.invoice.<event>`. No-op (returns the invoice, skipping `guard`)
* in the target status. Throws when the invoice does not exist. * when it is already in the target status. Throws when the invoice does not
* * exist or `guard` rejects the current state. Pass `manager` to enlist in a
* Note: the event fires in-process synchronously. When a `manager` from an * caller's transaction; otherwise locks the row for update and emits only
* outer transaction is passed, listeners run before that transaction commits. * after commit (see {@link runTransition}).
*/ */
private async transition( private async transition(
invoiceId: string, invoiceId: string,
@@ -566,17 +653,27 @@ export class BillingService {
event: string, event: string,
extra: { paymentId?: string }, extra: { paymentId?: string },
manager?: EntityManager, manager?: EntityManager,
guard?: (invoice: Invoice) => void,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; return this.runTransition(manager, async (mg) => {
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); const invoice = await mg.findOne(Invoice, {
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); where: { id: invoiceId },
if (invoice.status === status) return invoice; lock: { mode: "pessimistic_write" },
});
if (!invoice) {
throw new NotFoundException(`Invoice ${invoiceId} not found`);
}
if (invoice.status === status) return { result: invoice };
guard?.(invoice);
await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
const updated = { ...invoice, ...extra, status } as Invoice; const updated = { ...invoice, ...extra, status } as Invoice;
this.emitInvoiceEvent(event, updated); return {
return updated; result: updated,
emit: () => this.emitInvoiceEvent(event, updated),
};
});
} }
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */ /** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
@@ -600,16 +697,17 @@ export class BillingService {
// ── Payment reconciliation (by source) ─────────────────────────────────────── // ── Payment reconciliation (by source) ───────────────────────────────────────
/** /**
* The invoice a gateway payment should settle for a source record, or null if * The invoice a source record already has open, or null if it needs a new
* none. This is the billing document of record for "what is owed" — callers * one. This is the idempotency check every `ensureInvoiceFor*` (booking,
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than * first-mile, last-mile) runs before generating — it must see DRAFT
* recomputing from the source's own total, so discounts/penalties/adjustments * invoices too, not just issued ones, otherwise a source that already has
* carried on the invoice are honored. * an unissued draft gets a second, duplicate invoice minted alongside it
* instead of that draft being reused and then issued.
* *
* Pass `type` to select a specific invoice when a source carries several (e.g. * Pass `type` to select a specific invoice when a source carries several (e.g.
* a booking's up-front vs final charge); omit it to settle whichever single * a booking's up-front vs final charge); omit it to settle whichever single
* invoice is currently open. Returns the most recent matching open (unpaid, * invoice is currently open. Returns the most recent matching draft-or-open
* non-cancelled) invoice. * (unpaid, non-cancelled) invoice.
*/ */
findPayable( findPayable(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
@@ -620,7 +718,7 @@ export class BillingService {
where: { where: {
source, source,
sourceId, sourceId,
status: In(OPEN_STATUSES), status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]),
...(type ? { type } : {}), ...(type ? { type } : {}),
}, },
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
@@ -628,56 +726,24 @@ export class BillingService {
} }
/** /**
* Settle a source's currently-open invoice as paid and link the gateway * Pass `type` to select a specific invoice when a source carries several (e.g.
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then * a booking's up-front vs final charge); omit it to settle whichever single
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial * invoice is currently open. Returns the most recent matching open (unpaid,
* settlement. No-op (returns null) when the source has no open invoice. * non-cancelled) invoice.
*
* Type-blind by design: settles whichever invoice is due; any per-type reaction
* belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`.
* Pass the caller's transaction `manager` to enlist in its DB transaction.
*
* NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded`
* event ({@link settleByPaymentId}); this source-keyed settle is a generic helper
* for callers that settle by source rather than by gateway intent id.
*/ */
async settlePayable( findInvoice(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
paymentId: string | null, type?: string,
manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; return this.dataSource.getRepository(Invoice).findOne({
const invoice = await mg.findOne(Invoice, { where: {
where: { source, sourceId, status: In(OPEN_STATUSES) }, source,
sourceId,
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
}
/**
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
* No-op (returns null) when the source has no paid invoice.
*
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
* to enlist in its DB transaction.
*/
async refundPayable(
source: Freight.InvoiceSource,
sourceId: string,
manager?: EntityManager,
): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
order: { issuedAt: "DESC" },
});
if (!invoice) return null;
return this.markInvoiceAsRefunded(invoice.id, mg);
} }
/** /**
@@ -693,11 +759,17 @@ export class BillingService {
async expirePayable( async expirePayable(
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
type?: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) }, where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return null; if (!invoice) return null;
@@ -721,17 +793,29 @@ export class BillingService {
source: Freight.InvoiceSource, source: Freight.InvoiceSource,
sourceId: string, sourceId: string,
dueAt: Date, dueAt: Date,
type?: string,
manager?: EntityManager, manager?: EntityManager,
): Promise<void> { ): Promise<void> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { source, sourceId, status: In(OPEN_STATUSES) }, where: {
source,
sourceId,
status: In(OPEN_STATUSES),
...(type ? { type } : {}),
},
order: { issuedAt: "DESC" }, order: { issuedAt: "DESC" },
}); });
if (!invoice) return; if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { dueAt }); await mg.update(Invoice, { id: invoice.id }, { dueAt });
} }
/**
* Force an invoice to `status`, including issuing a still-DRAFT invoice
* (stamping `issuedAt`) — unlike the other transitions here, this is a
* blunt admin/workflow override, not a settlement. No-op when the invoice
* is missing or already terminal (paid/cancelled/refunded/expired).
*/
async updateStatus( async updateStatus(
invoiceId: string, invoiceId: string,
status: Freight.InvoiceStatus, status: Freight.InvoiceStatus,
@@ -739,29 +823,33 @@ export class BillingService {
): Promise<void> { ): Promise<void> {
const mg = manager ?? this.dataSource.manager; const mg = manager ?? this.dataSource.manager;
const invoice = await mg.findOne(Invoice, { const invoice = await mg.findOne(Invoice, {
where: { id: invoiceId, status: In(OPEN_STATUSES) }, where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
order: { issuedAt: "DESC" },
}); });
if (!invoice) return; if (!invoice) return;
await mg.update(Invoice, { id: invoice.id }, { status }); await mg.update(
Invoice,
{ id: invoice.id },
{ status, issuedAt: invoice.issuedAt ?? new Date() },
);
} }
// ── Payment initiation & settlement (the gateway boundary) ─────────────────── // ── Payment initiation & settlement (the gateway boundary) ───────────────────
/** /**
* Charge a source's open invoice through the payment gateway. Billing is the * Charge an invoice through the payment gateway. Billing is the single place
* single place that turns "what is owed" (the invoice) into a payment intent — * that turns "what is owed" (the invoice) into a payment intent — the domain
* the domain never talks to the payment service directly. Resolves the open * never talks to the payment service directly. Resolves the invoice by ID,
* invoice, opens an intent for `invoice.totalAmount`, records the intent id on * opens an intent for `invoice.balanceAmount` (so partial payments are honored),
* the invoice (the settlement correlation key), and returns the client action. * records the intent id on the invoice (the settlement correlation key), and
* returns the client action.
* *
* When the provider settles synchronously, the invoice is settled inline here — * When the provider settles synchronously, the invoice is settled inline here —
* after the intent id is stored — so the `payment.succeeded` correlation can * after the intent id is stored — so the `payment.succeeded` correlation can
* never fire before the link exists. Throws when the source has no open invoice. * never fire before the link exists. Throws when the invoice is not found or
* not in an open/payable status.
*/ */
async payInvoice( async payInvoice(
source: Freight.InvoiceSource, invoiceId: string,
sourceId: string,
opts: { opts: {
method?: string; method?: string;
platform?: "web" | "mobile"; platform?: "web" | "mobile";
@@ -770,15 +858,22 @@ export class BillingService {
failureUrl?: string; failureUrl?: string;
} = {}, } = {},
): Promise<InitiateResponseDto> { ): Promise<InitiateResponseDto> {
const invoice = await this.findPayable(source, sourceId); const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { id: invoiceId, status: In(OPEN_STATUSES) },
});
if (!invoice) { if (!invoice) {
throw new NotFoundException( throw new NotFoundException(
`No open invoice to charge for ${source}:${sourceId}`, `Invoice ${invoiceId} not found or not in a payable status`,
); );
} }
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
const result = await this.payment.initiate({ const result = await this.payment.initiate({
referenceId: sourceId, referenceId: invoice.sourceId,
source: invoice.source, source: invoice.source,
// Freight payments settle under the generic SHIPMENT reference — how the // Freight payments settle under the generic SHIPMENT reference — how the
// payment service attributes them to the freight API. The payment ↔ invoice // payment service attributes them to the freight API. The payment ↔ invoice
@@ -787,7 +882,7 @@ export class BillingService {
// service branches on a domain-specific reference type. // service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber, orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)), amountMinor: Math.round(Number(invoice.balanceAmount)),
currency: invoice.currency, currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`, reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR", method: opts.method ?? "TELEBIRR",
@@ -822,8 +917,8 @@ export class BillingService {
*/ */
async settleByPaymentId( async settleByPaymentId(
paymentId: string, paymentId: string,
_providerTxnId?: string, providerTxnId?: string,
_paidAt?: Date, paidAt?: Date,
): Promise<Invoice | null> { ): Promise<Invoice | null> {
const invoice = await this.dataSource.getRepository(Invoice).findOne({ const invoice = await this.dataSource.getRepository(Invoice).findOne({
where: { paymentId, status: In(OPEN_STATUSES) }, where: { paymentId, status: In(OPEN_STATUSES) },
@@ -831,6 +926,9 @@ export class BillingService {
}); });
if (!invoice) return null; if (!invoice) return null;
return this.markInvoiceAsPaid(invoice.id, paymentId); return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, {
providerTxnId,
paidAt,
});
} }
} }

View File

@@ -11,7 +11,7 @@
/** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */ /** Anything exposing TypeORM's `.query` — an `EntityManager` or `DataSource`. */
export interface SqlRunner { export interface SqlRunner {
query(sql: string, params?: unknown[]): Promise<Array<{ seq: number | string }>>; query(sql: string, params?: unknown[]): Promise<unknown>;
} }
export interface InvoiceNumberOptions { export interface InvoiceNumberOptions {
@@ -34,11 +34,18 @@ export async function nextDailyInvoiceNumber(
const prefix = `${opts.code}-${ymd}-`; const prefix = `${opts.code}-${ymd}-`;
const column = opts.column ?? "invoice_number"; const column = opts.column ?? "invoice_number";
const [row] = await runner.query( // Serialize concurrent allocation for this exact day+code prefix so two
// simultaneous transactions can't both read the same MAX(seq) and mint a
// duplicate number. Session-scoped to the caller's transaction — released
// automatically on commit/rollback. Different prefixes hash to different
// keys and never contend with each other.
await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]);
const rows = (await runner.query(
`SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq
FROM ${opts.table} WHERE ${column} LIKE $1`, FROM ${opts.table} WHERE ${column} LIKE $1`,
[`${prefix}%`], [`${prefix}%`],
); )) as Array<{ seq: number | string }>;
const next = Number(row?.seq ?? 0) + 1; const next = Number(rows[0]?.seq ?? 0) + 1;
return `${prefix}${String(next).padStart(5, "0")}`; return `${prefix}${String(next).padStart(5, "0")}`;
} }

View File

@@ -16,9 +16,8 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Response } from "express"; import { Response } from "express";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
import { Freight } from "@edr/types";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "./billing.service";
import { import {
InitiatePaymentDto, InitiatePaymentDto,
InitiateResponseDto, InitiateResponseDto,
@@ -27,25 +26,24 @@ import {
} from "../payment/payments.dto"; } from "../payment/payments.dto";
/** /**
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a * Central payment entrypoints. Domain-agnostic the caller supplies an
* booking it maps the request to {@link Freight.InvoiceSource.Booking} and hands * invoice ID and the billing service resolves the amount and drives the
* off to billing, which resolves the invoice/amount and drives the gateway. Billing * gateway. The domain never talks to the payment service directly.
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
* Routes are unchanged (`/payments/*`) so the portal is unaffected. * Routes are unchanged (`/payments/*`) so the portal is unaffected.
*/ */
@ApiTags("Payment") @ApiTags("Payment")
@Controller("payments") @Controller("payments")
export class BookingPaymentController { export class PaymentController {
constructor(private readonly billing: BillingService) { } constructor(private readonly billing: BillingService) { }
@Post("initiate") @Post("initiate")
@ApiOperation({ @ApiOperation({
summary: "Initiate payment for a freight booking", summary: "Initiate payment for an invoice",
description: "Charges the booking's open invoice through the payment gateway.", description: "Charges the invoice through the payment gateway.",
}) })
@ApiOkResponse({ type: InitiateResponseDto }) @ApiOkResponse({ type: InitiateResponseDto })
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> { initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { return this.billing.payInvoice(dto.invoiceId, {
method: dto.method, method: dto.method,
platform: dto.platform, platform: dto.platform,
payerAccount: dto.payerAccount, payerAccount: dto.payerAccount,
@@ -59,23 +57,23 @@ export class BookingPaymentController {
@ApiOperation({ @ApiOperation({
summary: "Browser checkout redirect", summary: "Browser checkout redirect",
description: description:
"Charges the booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.",
}) })
@ApiQuery({ name: "bookingId", required: true }) @ApiQuery({ name: "invoiceId", required: true })
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
@ApiProduces("text/html") @ApiProduces("text/html")
async checkout( async checkout(
@Query("bookingId") bookingId: string, @Query("invoiceId") invoiceId: string,
@Query("method") method: PaymentMethodTypeEnum, @Query("method") method: PaymentMethodTypeEnum,
@Query("platform") platform: PaymentPlatformDto = "web", @Query("platform") platform: PaymentPlatformDto = "web",
@Res() res: Response, @Res() res: Response,
) { ) {
if (!bookingId) { if (!invoiceId) {
return res return res
.status(HttpStatus.BAD_REQUEST) .status(HttpStatus.BAD_REQUEST)
.type("html") .type("html")
.send(this.buildErrorHtml("Missing required query parameter: bookingId")); .send(this.buildErrorHtml("Missing required query parameter: invoiceId"));
} }
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
return res return res
@@ -86,8 +84,7 @@ export class BookingPaymentController {
try { try {
const result = await this.billing.payInvoice( const result = await this.billing.payInvoice(
Freight.InvoiceSource.Booking, invoiceId,
bookingId,
{ method, platform }, { method, platform },
); );
const url = const url =

View File

@@ -1,7 +1,13 @@
import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
} from "@nestjs/common";
import { OnEvent } from "@nestjs/event-emitter"; import { OnEvent } from "@nestjs/event-emitter";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { DataSource } from "typeorm"; import { DataSource, EntityManager } from "typeorm";
import { import {
BillingService, BillingService,
@@ -58,9 +64,9 @@ export class BookingInvoiceService {
* Ensure the booking has its invoice, generating one from the snapshotted * Ensure the booking has its invoice, generating one from the snapshotted
* pricing breakdown if absent. Called when a booking reaches a billable state. * pricing breakdown if absent. Called when a booking reaches a billable state.
* Idempotent — returns the existing open invoice instead of a duplicate. * Idempotent — returns the existing open invoice instead of a duplicate.
* Returns `null` (and logs) when the booking is not billable: no company to * Throws `BadRequestException` when the booking is not billable: no company
* bill (e.g. government bookings whose `companyId` is null, which the invoices * to bill (e.g. government bookings whose `companyId` is null, which the
* FK requires), or no priced amount. * invoices FK requires), or no priced amount.
*/ */
async ensureInvoiceForBooking( async ensureInvoiceForBooking(
booking: Booking, booking: Booking,
@@ -74,8 +80,8 @@ export class BookingInvoiceService {
if (existing) return existing; if (existing) return existing;
if (!booking.companyId) { if (!booking.companyId) {
this.logger.warn( throw new BadRequestException(
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
); );
} }
@@ -102,7 +108,13 @@ export class BookingInvoiceService {
} }
} }
updateStatus = this.billing.updateStatus; updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,
manager?: EntityManager,
): Promise<void> {
return this.billing.updateStatus(invoiceId, status, manager);
}
/** /**
* Advance a booking once its prepaid invoice settles — the domain side-effect * Advance a booking once its prepaid invoice settles — the domain side-effect
@@ -165,7 +177,11 @@ export class BookingInvoiceService {
// Fall back to a single freight line when no breakdown was snapshotted. // Fall back to a single freight line when no breakdown was snapshotted.
if (lines.length === 0) { if (lines.length === 0) {
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
if (!Number.isFinite(amount) || amount <= 0) throw new Error("No price"); if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException(
`Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
);
}
lines.push({ lines.push({
chargeType: "FREIGHT", chargeType: "FREIGHT",
description: "Rail freight", description: "Rail freight",

View File

@@ -1,43 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Freight } from '@edr/types';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { BillingService } from '../billing/billing.service';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly billing: BillingService,
) { }
/**
* Start payment for a booking. The booking never touches the payment gateway
* directly — it charges its invoice through billing, which resolves the amount
* and drives the provider. Returns the provider redirect URL (empty when none).
*/
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
method: PaymentMethodTypeEnum.TELEBIRR,
platform: 'web',
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

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

View File

@@ -35,6 +35,8 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
{} as never, // fileUploadSettingsService {} as never, // fileUploadSettingsService
{} as never, // bookingBatchService {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
); );
return { service, bookingsRepository, ruleEngineService }; return { service, bookingsRepository, ruleEngineService };
} }

View File

@@ -46,6 +46,8 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, // bookingBatchService {} as never, // bookingBatchService
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
); );
return { service, bookingsRepository }; return { service, bookingsRepository };
} }
@@ -128,6 +130,8 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, {} as never,
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
); );
return { service, bookingsRepository }; return { service, bookingsRepository };
} }
@@ -196,6 +200,8 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
fileUploadSettingsService as never, fileUploadSettingsService as never,
{} as never, {} as never,
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
); );
return { service, bookingsRepository, filesService }; return { service, bookingsRepository, filesService };
} }

View File

@@ -38,6 +38,8 @@ describe('BookingTransitionService — operation review', () => {
{} as never, // fileUploadSettingsService {} as never, // fileUploadSettingsService
bookingBatchService as never, bookingBatchService as never,
bookingsService as never, bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
); );
return { service, bookingsRepository, bookingBatchService }; return { service, bookingsRepository, bookingBatchService };
} }

View File

@@ -7,28 +7,30 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
import { assertCanApproveBookingStep } from "../../common/freight-permission.util"; import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from "../train-scheduling/batch-window.util"; import { eatDay } from '../train-scheduling/batch-window.util';
import { isRoadService } from "./road.util"; import { isRoadService } from './road.util';
import { RuleEngineService } from "../rule-engine/rule-engine.service"; import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { FilesService } from "../files/files.service"; import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from "../file-upload-settings/file-upload-settings.service"; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from "./booking-contract.service"; import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from "./booking-pricing.service"; import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from "./bookings.repository"; import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from "./booking-status.util"; import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from "./clearance.util"; import { clearanceCodesForBooking } from './clearance.util';
import { import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
computeNextStep, import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
type BookingNextStep, import { PriceLineItemDto } from './dto/generate-price-response.dto';
} from "./booking-next-step.util"; import { Booking } from './entities/booking.entity';
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { BookingsService } from './bookings.service';
import { PriceLineItemDto } from "./dto/generate-price-response.dto"; import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { Booking } from "./entities/booking.entity"; import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
import { BookingsService } from "./bookings.service"; import { ContractDocPhase } from '@edr/types';
import { BookingInvoiceService } from "./booking-invoice.service";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import { BookingInvoiceService } from "./booking-invoice.service";
@Injectable() @Injectable()
export class BookingTransitionService { export class BookingTransitionService {
@@ -44,8 +46,17 @@ export class BookingTransitionService {
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingsService)) @Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService, private readonly bookingsService: BookingsService,
private readonly invoiceService: BookingInvoiceService, @Inject(forwardRef(() => BookingClearanceService))
) { } private readonly bookingClearanceService: BookingClearanceService,
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
) {}
private isPhasedGeneralCustoms(booking: Booking): boolean {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
}
async submit(bookingId: string): Promise<SubmitBookingResponseDto> { async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
@@ -447,6 +458,7 @@ export class BookingTransitionService {
"CHANGES_REQUESTED", "CHANGES_REQUESTED",
"PENDING_APPROVAL", "PENDING_APPROVAL",
"CONTRACT_READY", "CONTRACT_READY",
"OPERATION_REQUEST_PENDING",
]); ]);
await this.bookingsRepository.createReviewNote( await this.bookingsRepository.createReviewNote(
@@ -510,8 +522,19 @@ export class BookingTransitionService {
note: string | null; note: string | null;
}>; }>;
allApproved: boolean; allApproved: boolean;
phase?: string | null;
milestones?: unknown[];
nextAction?: unknown;
dutyRequired?: boolean | null;
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
operationReady?: boolean;
}> { }> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(booking)) {
return this.bookingClearanceService.getClearanceView(bookingId);
}
const { inputCode, outputCode, includesCustoms } = const { inputCode, outputCode, includesCustoms } =
clearanceCodesForBooking(booking); clearanceCodesForBooking(booking);
@@ -667,6 +690,18 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, { await this.bookingsRepository.update(bookingId, {
status: "DOCUMENTS_UNDER_REVIEW", status: "DOCUMENTS_UNDER_REVIEW",
} as never); } as never);
if (this.isPhasedGeneralCustoms(booking)) {
await this.workflowService.onCustomerDocsUploadedForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }
@@ -734,6 +769,15 @@ export class BookingTransitionService {
"A note is required when querying a document", "A note is required when querying a document",
); );
} }
if (
status === 'QUERIED' &&
this.isPhasedGeneralCustoms(booking) &&
booking.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
await this.bookingsRepository.setDocumentReviewStatus( await this.bookingsRepository.setDocumentReviewStatus(
bookingId, bookingId,
@@ -750,8 +794,30 @@ export class BookingTransitionService {
"CHANGES_REQUESTED", "CHANGES_REQUESTED",
staffId, staffId,
); );
if (this.isPhasedGeneralCustoms(booking)) {
await this.workflowService.onDocumentReviewReopenedForBooking(bookingId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtReview,
} as never);
}
} }
return this.bookingsService.findById(bookingId);
const updated = await this.bookingsService.findById(bookingId);
if (this.isPhasedGeneralCustoms(updated)) {
const allApproved = await this.isClearanceFullyApproved(updated);
if (allApproved) {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
const phase =
updated.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput;
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: phase,
} as never);
}
}
return updated;
} }
/** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */
@@ -787,7 +853,12 @@ export class BookingTransitionService {
*/ */
async finalizeClearance(bookingId: string): Promise<Booking> { async finalizeClearance(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId); const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); if (this.isPhasedGeneralCustoms(booking)) {
throw new BadRequestException(
'General customs bookings use phased clearance — complete milestones via the phased actions instead of finalize.',
);
}
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
const approved = await this.isClearanceFullyApproved(booking); const approved = await this.isClearanceFullyApproved(booking);
if (!approved) { if (!approved) {

View File

@@ -12,14 +12,15 @@ import {
Request, Request,
Res, Res,
UnauthorizedException, UnauthorizedException,
UploadedFile,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
} from "@nestjs/common"; } from '@nestjs/common';
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from "../../common/booking-guards"; import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -30,17 +31,22 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import type { Response } from "express"; import type { Response } from "express";
import { BookingContractService } from "./booking-contract.service"; import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from "./booking-pricing.service"; import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from "./booking-transition.service"; import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingClearanceService } from '../contracts/booking-clearance.service';
import { BookingsService } from "./bookings.service"; import {
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto"; AdviseContractDutyDto,
import { CreateBookingDto } from "./dto/create-booking.dto"; RoAmendmentDto,
import { BookingListSummaryDto } from "./dto/booking-list-summary.dto"; } from '../contracts/dto/phased-clearance.dto';
import { FilterBookingDto } from "./dto/filter-booking.dto"; import { BookingReferenceDataService } from './booking-reference-data.service';
import { GeneratePriceResponseDto } from "./dto/generate-price-response.dto"; import { BookingsService } from './bookings.service';
import { SubmitBookingResponseDto } from "./dto/submit-booking-response.dto"; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { import {
AcceptIntakeDto, AcceptIntakeDto,
ApproveStepDto, ApproveStepDto,
@@ -52,10 +58,11 @@ import {
RequestOperationDto, RequestOperationDto,
OperationReviewDto, OperationReviewDto,
StaffRejectDto, StaffRejectDto,
} from "./dto/request-changes.dto"; } from './dto/request-changes.dto';
import { ContractViewDto } from "./dto/contract-view.dto"; import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from "./dto/sign-contract.dto"; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { UpdateBookingDto } from "./dto/update-booking.dto"; import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { import {
type AuthUserPayload, type AuthUserPayload,
resolveAuthUserId, resolveAuthUserId,
@@ -75,7 +82,8 @@ export class BookingsController {
private readonly pricingService: BookingPricingService, private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService, private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService, private readonly contractService: BookingContractService,
) { } private readonly bookingClearanceService: BookingClearanceService,
) {}
@Post() @Post()
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@@ -268,7 +276,40 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Get(":id/tracking") @Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CustomerTruckAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
return this.transitionService.enrichBookingResponse(assigned);
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/tracking')
@ApiOperation({ @ApiOperation({
summary: "Shipment tracking timeline for a booking", summary: "Shipment tracking timeline for a booking",
description: description:
@@ -358,7 +399,21 @@ export class BookingsController {
// ── Document clearance (post counter-sign) ──────────────────────────────── // ── Document clearance (post counter-sign) ────────────────────────────────
@Get(":id/clearance") @Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' })
getBookingEtClearanceQueue() {
return this.bookingClearanceService.etQueue();
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL DJ queue — general customs bookings awaiting DJ action' })
getBookingDjClearanceQueue() {
return this.bookingClearanceService.djQueue();
}
@Get(':id/clearance')
@ApiOperation({ @ApiOperation({
summary: summary:
"Document-clearance grid (required docs + upload + GL review status)", "Document-clearance grid (required docs + upload + GL review status)",
@@ -469,7 +524,161 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(":id/staff/request-changes") @Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' })
async uploadBookingDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeclaration(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/duty')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
@UseInterceptors(FileInterceptor('attachment'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' })
async adviseBookingDuty(
@Param('id', ParseUUIDPipe) id: string,
@Body('dutyRequired') dutyRequiredRaw: string,
@Body('amount') amountRaw: string | undefined,
@Body('currency') currency: string | undefined,
@Body('declarationSerial') declarationSerial: string | undefined,
@UploadedFile() attachment: Express.Multer.File | undefined,
@CurrentUser() user: TCurrentUser,
) {
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
const dto: AdviseContractDutyDto = {
dutyRequired,
amount:
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
currency: currency ?? 'ETB',
declarationSerial,
};
const booking = await this.bookingClearanceService.adviseDuty(
id,
dto,
resolveAuthUserId(user),
attachment,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' })
async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingClearanceService.finalizePreClearance(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/duty-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' })
async uploadBookingDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
) {
const booking = await this.bookingClearanceService.uploadDutySlip(id, file);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/transit-permit')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
async uploadBookingTransitPermit(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadTransitPermit(
id,
files ?? [],
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/delivery-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async uploadBookingReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: TCurrentUser,
) {
const result = await this.bookingClearanceService.uploadReleaseOrder(
id,
file,
vesselDepartureDate,
resolveAuthUserId(user),
);
return {
...this.transitionService.enrichBookingResponse(result.booking),
hold: result.hold,
holdReason: result.holdReason,
};
}
@Post(':id/clearance/ro-amendment')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
async requestBookingRoAmendment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.requestRoAmendment(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/clearance/export-release')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
async confirmBookingExportRelease(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.confirmExportRelease(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges) @BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: "Staff return booking for customer updates" }) @ApiOperation({ summary: "Staff return booking for customer updates" })
async requestChanges( async requestChanges(

View File

@@ -4,41 +4,43 @@ import { TypeOrmModule } from "@nestjs/typeorm";
import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; import { ExchangeModule, ExchangeOptions } from "@edr/api-common";
// import { CustomersModule } from '../customers/customers.module'; // import { CustomersModule } from '../customers/customers.module';
import { CompaniesModule } from "../companies/companies.module"; import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from "../files/files.module"; import { FilesModule } from '../files/files.module';
import { MinioModule } from "../minio/minio.module"; import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from "../rule-engine/rule-engine.module"; import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from "../file-upload-settings/file-upload-settings.module"; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { SignaturesModule } from "../signatures/signatures.module"; import { SignaturesModule } from '../signatures/signatures.module';
import { BillingModule } from "../billing/billing.module"; import { BillingModule } from '../billing/billing.module';
import { FirstMileModule } from "../first-mile/first-mile.module"; import { FirstMileModule } from '../first-mile/first-mile.module';
import { BookingContractService } from "./booking-contract.service"; import { BookingContractService } from './booking-contract.service';
import { BookingInvoiceService } from "./booking-invoice.service"; import { BookingInvoiceService } from './booking-invoice.service';
import { BookingPaymentController } from "./booking-payment.controller"; // import { BookingPaymentController } from './booking-payment.controller';
import { BookingPaymentService } from "./booking-payment.service"; // import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from "./booking-pricing.service"; import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from "./booking-reference-data.service"; import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from "./booking-transition.service"; import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from "./bookings.controller"; import { BookingsController } from './bookings.controller';
import { PayController } from "./pay.controller"; // import { PayController } from './pay.controller';
import { BookingsRepository } from "./bookings.repository"; import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from "./consolidation.service"; import { ConsolidationService } from './consolidation.service';
import { BookingsService } from "./bookings.service"; import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from "./entities/booking-approval-step.entity"; import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from "./entities/booking-cargo-modifier.entity"; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingDocumentReview } from "./entities/booking-document-review.entity"; import { BookingDocumentReview } from './entities/booking-document-review.entity';
import { BookingContainer } from "./entities/booking-container.entity"; import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from "./entities/booking-rate-snapshot.entity"; import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from "./entities/booking-contract-signature.entity"; import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from "./entities/booking-review-note.entity"; import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from "./entities/booking.entity"; import { Booking } from './entities/booking.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPdfService } from "../../contracts/contract-pdf.service";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
@Module({ @Module({
imports: [ imports: [
@@ -56,6 +58,8 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
BillingModule, BillingModule,
forwardRef(() => FirstMileModule), forwardRef(() => FirstMileModule),
forwardRef(() => TrainSchedulingModule), forwardRef(() => TrainSchedulingModule),
forwardRef(() => ContractsModule),
forwardRef(() => ContractsModule),
FilesModule, FilesModule,
MinioModule, MinioModule,
CompaniesModule, CompaniesModule,
@@ -69,7 +73,7 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
config.get<ExchangeOptions>("app.cbeExchange") ?? {}, config.get<ExchangeOptions>("app.cbeExchange") ?? {},
}), }),
], ],
controllers: [BookingsController, PayController, BookingPaymentController], controllers: [BookingsController],
providers: [ providers: [
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
@@ -79,7 +83,6 @@ import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.modu
BookingTransitionService, BookingTransitionService,
BookingContractService, BookingContractService,
BookingInvoiceService, BookingInvoiceService,
BookingPaymentService,
ContractTemplateResolver, ContractTemplateResolver,
ContractViewModelBuilder, ContractViewModelBuilder,
ContractPricingScheduleBuilder, ContractPricingScheduleBuilder,

View File

@@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
containerTypeId: string; containerTypeId: string;
quantity: number; quantity: number;
vgmPerUnitTons: number; vgmPerUnitTons: number;
hazardousQuantity?: number;
reeferQuantity?: number;
weightResult: ContainerWeightResult; weightResult: ContainerWeightResult;
}>, }>,
): Promise<BookingContainer[]> { ): Promise<BookingContainer[]> {
@@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const totalVgm = item.quantity * item.vgmPerUnitTons; const totalVgm = item.quantity * item.vgmPerUnitTons;
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
// A per-line breakdown can never exceed the line's own quantity.
const clamp = (v?: number) =>
Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0));
const row = containerRepo.create({ const row = containerRepo.create({
bookingId, bookingId,
containerTypeId: item.containerTypeId, containerTypeId: item.containerTypeId,
quantity: item.quantity, quantity: item.quantity,
hazardousQuantity: clamp(item.hazardousQuantity),
reeferQuantity: clamp(item.reeferQuantity),
vgmPerUnitTons: item.vgmPerUnitTons, vgmPerUnitTons: item.vgmPerUnitTons,
totalVgmTons: totalVgm, totalVgmTons: totalVgm,
wagonsRequired, wagonsRequired,
@@ -483,6 +490,15 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never); } as never);
} }
/** Bookings in any of the given statuses (clearance queue helpers). */
async findByStatuses(statuses: string[]): Promise<Booking[]> {
if (!statuses.length) return [];
return this.repository.find({
where: { status: In(statuses) },
order: { createdAt: 'DESC' },
});
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */ /** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: { async findQueue(options: {
status: string | string[]; status: string | string[];

View File

@@ -45,6 +45,8 @@ import {
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings { export interface PaginatedBookings {
@@ -67,6 +69,17 @@ const NEEDS_ACTION_STATUSES = [
'APPROVED_PENDING_SIGNATURE', 'APPROVED_PENDING_SIGNATURE',
] as const; ] as const;
/**
* Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed
* the total cargo it's a portion of, and is never negative.
*/
function clampToCargo(value: number | undefined, cargoAmount: number): number {
const v = Number(value ?? 0);
if (!Number.isFinite(v) || v <= 0) return 0;
const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0;
return Math.min(v, cap);
}
@Injectable() @Injectable()
export class BookingsService { export class BookingsService {
constructor( constructor(
@@ -81,8 +94,62 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService, private readonly consolidationService: ConsolidationService,
private readonly contractPdfService: ContractPdfService,
) {} ) {}
async assignCustomerTruck(
bookingId: string,
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
}
await this.bookingsRepository.update(bookingId, {
status: 'TRUCK_ASSIGNED',
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
customerTruckDriverName: dto.driverName.trim(),
customerTruckType: dto.truckType.trim(),
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
customerTruckAssignedAt: new Date(),
});
return this.findById(bookingId);
}
async customerTruckFreightOrderCopies(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/** Resolve trade direction from yard countries; reject client mismatch. */ /** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking( private async resolveTradeDirectionForBooking(
originYardId: string, originYardId: string,
@@ -120,6 +187,79 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`; return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
} }
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
</div>
</section>`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
</head>
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Build evaluation input from booking freight shape. */ /** Build evaluation input from booking freight shape. */
/** /**
* Whether a service type bundles customs clearance. This is the single source * Whether a service type bundles customs clearance. This is the single source
@@ -506,6 +646,16 @@ export class BookingsService {
// the container type at pricing time, so the booking-level flag stays off // the container type at pricing time, so the booking-level flag stays off
// for container freight to avoid double-counting. // for container freight to avoid double-counting.
isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false,
// Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container
// freight tracks this per line, so these are 0 for CONTAINER.
bulkHazardousQuantity:
dto.freightType === 'BULK'
? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm)
: 0,
bulkReeferQuantity:
dto.freightType === 'BULK'
? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm)
: 0,
paymentCurrency: dto.paymentCurrency, paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode, pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms, financialTerms: dto.financialTerms,
@@ -528,6 +678,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId, containerTypeId: c.containerTypeId,
quantity: c.quantity, quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons, vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i], weightResult: ruleResult.containerWeightResults[i],
})), })),
); );
@@ -665,6 +817,8 @@ export class BookingsService {
containers, containers,
); );
const cargoAmount =
dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0);
const updates: Record<string, unknown> = { const updates: Record<string, unknown> = {
...dto, ...dto,
freightType, freightType,
@@ -675,6 +829,22 @@ export class BookingsService {
freightType === 'BULK' freightType === 'BULK'
? (dto.isReefer ?? existing.isReefer ?? false) ? (dto.isReefer ?? existing.isReefer ?? false)
: false, : false,
// Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for
// container freight (per-line on the containers instead).
bulkHazardousQuantity:
freightType === 'BULK'
? clampToCargo(
dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0),
cargoAmount,
)
: 0,
bulkReeferQuantity:
freightType === 'BULK'
? clampToCargo(
dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0),
cargoAmount,
)
: 0,
priorityScore: ruleResult.priorityScore, priorityScore: ruleResult.priorityScore,
tradeDirection, tradeDirection,
}; };
@@ -721,6 +891,8 @@ export class BookingsService {
containerTypeId: c.containerTypeId, containerTypeId: c.containerTypeId,
quantity: c.quantity, quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons, vgmPerUnitTons: c.vgmPerUnitTons,
hazardousQuantity: c.hazardousQuantity,
reeferQuantity: c.reeferQuantity,
weightResult: ruleResult.containerWeightResults[i], weightResult: ruleResult.containerWeightResults[i],
})), })),
); );

View File

@@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto {
@ApiProperty({ example: 'BULK_COFFEE' }) @ApiProperty({ example: 'BULK_COFFEE' })
code!: string; code!: string;
@ApiProperty()
show_free_text_box!: boolean;
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
unit_of_measure?: CargoUnitOfMeasure | null; unit_of_measure?: CargoUnitOfMeasure | null;
} }

View File

@@ -52,6 +52,28 @@ export class CreateBookingContainerDto {
@Min(0) @Min(0)
@Transform(({ value }) => Number(value)) @Transform(({ value }) => Number(value))
vgmPerUnitTons!: number; vgmPerUnitTons!: number;
@ApiPropertyOptional({
description: 'How many of this line are hazardous (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
hazardousQuantity?: number;
@ApiPropertyOptional({
description: 'How many of this line are refrigerated (0..quantity)',
minimum: 0,
default: 0,
})
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
reeferQuantity?: number;
} }
/** /**
@@ -320,6 +342,25 @@ export class CreateBookingDto {
@Transform(({ value }) => value === 'true' || value === true) @Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean; isReefer?: boolean;
/**
* Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's
* unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed
* cargoTotalWeightVgm. Ignored for container freight (per-line on containers).
*/
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkHazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0, default: 0 })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value ?? 0))
bulkReeferQuantity?: number;
@ApiProperty({ enum: PAYMENT_CURRENCIES }) @ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES]) @IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string; paymentCurrency!: string;

View File

@@ -0,0 +1,34 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;
}

View File

@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
// Road (truck) drawdown orders skip the train batch pool and wait here for // Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons. // truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING', 'ROAD_DISPATCH_PENDING',
'TRUCK_ASSIGNED',
'OPERATION_REQUESTED', 'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the // Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before // operation request; the operations team reviews capacity/docs/route before
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null; lastMileDeliveryLng?: number | null;
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
customerTruckPlateNumber?: string | null;
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
customerTruckDriverName?: string | null;
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
customerTruckType?: string | null;
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
customerTruckContainerNumber?: string | null;
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
customerTruckAssignedAt?: Date | null;
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean; customsClearingEnabled!: boolean;
@@ -321,6 +340,19 @@ export class Booking extends BaseEntity {
@Column({ name: 'is_reefer', type: 'boolean', default: false }) @Column({ name: 'is_reefer', type: 'boolean', default: false })
isReefer!: boolean; isReefer!: boolean;
/**
* Bulk-only hazardous / reefer amount, in the cargo's own unit of measure
* (tons for PER_TON commodities, item count for PER_ITEM) — i.e. how much of
* `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container
* freight carries this per line on `booking_container` instead, so these stay
* 0 for CONTAINER bookings. The booleans above remain the surcharge trigger.
*/
@Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
bulkHazardousQuantity!: number;
@Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
bulkReeferQuantity!: number;
@Column({ name: 'payment_currency', type: 'varchar', length: 5 }) @Column({ name: 'payment_currency', type: 'varchar', length: 5 })
paymentCurrency!: string; paymentCurrency!: string;
@@ -435,6 +467,25 @@ export class Booking extends BaseEntity {
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
glStationYardId?: string | null; glStationYardId?: string | null;
/** Per-booking phased clearance (GENERAL + customs). */
@Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true })
clearanceCurrentPhase?: string | null;
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null;
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
vesselDepartureDate?: string | null;
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
roAmendmentRequestedAt?: Date | null;
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
roHoldReason?: string | null;
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
/** GL staff user bound to this shipment by the station manager. */ /** GL staff user bound to this shipment by the station manager. */
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
glAssignedStaffId?: string | null; glAssignedStaffId?: string | null;

View File

@@ -1,27 +0,0 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
// import { BookingTransitionService } from './booking-transition.service';
// import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
// import { Booking } from './entities/booking.entity';
// import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
// private readonly transitionService: BookingTransitionService,
) { }
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string) {
return await this.paymentService.pay(id);
// const abstract = await this.transitionService.enrichBookingResponse(booking);
// return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -0,0 +1,188 @@
import { BadRequestException } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { BookingClearanceService } from './booking-clearance.service';
import type { Booking } from '../bookings/entities/booking.entity';
const generalImportBooking = {
id: 'b-general',
status: 'DOCUMENTS_UNDER_REVIEW',
tradeDirection: 'IMPORT',
freightType: 'CONTAINER',
customsClearingEnabled: true,
contractKind: 'GENERAL',
contractId: 'c-1',
dutyRequired: true,
roHoldReason: null,
vesselDepartureDate: null,
} as Booking;
const generalExportBooking = {
...generalImportBooking,
id: 'b-export',
tradeDirection: 'EXPORT',
dutyRequired: null,
} as Booking;
function makeService(overrides?: {
booking?: Booking;
workflowThrows?: boolean;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
};
const filesService = {
upsertByCode: jest.fn().mockResolvedValue({}),
findByResource: jest.fn().mockResolvedValue([]),
};
const fileUploadSettingsService = {
getByCode: jest.fn().mockRejectedValue(new Error('no setting')),
};
const workflowService = {
assertPriorCompleteForBooking: overrides?.workflowThrows
? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete'))
: jest.fn().mockResolvedValue(undefined),
completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined),
onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined),
onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined),
listMilestonesForBooking: jest.fn().mockResolvedValue([]),
resolvePhaseForBooking: jest.fn().mockReturnValue(null),
computeNextActionForBooking: jest.fn().mockReturnValue(null),
isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false),
markReadyForOperation: jest.fn().mockResolvedValue(undefined),
onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined),
};
const milestoneService = {
adviseDuty: jest.fn().mockResolvedValue(undefined),
};
const dropdownSettingsService = {
getByCode: jest.fn().mockResolvedValue({
children: [{ value: '2' }],
}),
};
const service = new BookingClearanceService(
bookingsRepository as never,
bookingsService as never,
filesService as never,
fileUploadSettingsService as never,
workflowService as never,
milestoneService as never,
dropdownSettingsService as never,
);
return {
service,
bookingsRepository,
bookingsService,
filesService,
workflowService,
milestoneService,
};
}
describe('BookingClearanceService', () => {
describe('adviseDuty', () => {
it('skips duty milestones when duty is not required', async () => {
const { service, workflowService, bookingsRepository } = makeService();
await service.adviseDuty('b-general', { dutyRequired: false });
expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general');
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
dutyRequired: false,
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
}),
);
});
it('records duty advice when duty applies', async () => {
const { service, milestoneService } = makeService();
await service.adviseDuty('b-general', {
dutyRequired: true,
amount: 1500,
currency: 'ETB',
declarationSerial: 'DS-1',
});
expect(milestoneService.adviseDuty).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }),
undefined,
);
});
});
describe('uploadDutySlip', () => {
it('rejects when duty is not required', async () => {
const { service } = makeService({
booking: { ...generalImportBooking, dutyRequired: false } as Booking,
});
await expect(
service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File),
).rejects.toBeInstanceOf(BadRequestException);
});
it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => {
const { service, filesService, workflowService, bookingsRepository } = makeService();
const file = { fieldname: 'file' } as Express.Multer.File;
await service.uploadDutySlip('b-general', file);
expect(filesService.upsertByCode).toHaveBeenCalledWith(
expect.objectContaining({
resourceId: 'b-general',
code: 'duty_tax_receipt',
file,
}),
);
expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith(
'b-general',
'DUTY_TAX_PAID',
);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-general',
expect.objectContaining({
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
}),
);
});
});
describe('uploadDeclaration', () => {
it('rejects when a prior milestone is incomplete', async () => {
const { service } = makeService({ workflowThrows: true });
await expect(
service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('uploadReleaseOrder', () => {
it('places RO on hold when vessel departs too soon', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().slice(0, 10);
const { service, bookingsRepository } = makeService({ booking: generalExportBooking });
const result = await service.uploadReleaseOrder(
'b-export',
{ fieldname: 'ro' } as Express.Multer.File,
dateStr,
);
expect(result.hold).toBe(true);
expect(result.holdReason).toMatch(/minimum lead time/i);
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-export',
expect.objectContaining({ roHoldReason: expect.any(String) }),
);
});
});
});

View File

@@ -0,0 +1,618 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
bookingId: string;
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;
phase?: string | null;
milestones?: Array<{
id: string;
milestoneCode: string;
milestoneLabel: string;
status: string;
ownerRegion?: string | null;
metadata?: Record<string, unknown> | null;
sortOrder: number;
}>;
nextAction?: {
actor: string;
action: string;
milestoneCode?: string | null;
blockedReason?: string | null;
} | null;
dutyRequired?: boolean | null;
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: string | null;
operationReady?: boolean;
preClearanceFinalized?: boolean;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
}
@Injectable()
export class BookingClearanceService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Phased clearance applies only to customs bookings.');
}
if (booking.contractKind !== 'GENERAL') {
throw new BadRequestException('Per-booking phased clearance applies to general contracts.');
}
if (!booking.contractId) {
throw new BadRequestException('Booking is not linked to a contract.');
}
}
private async loadBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
await this.assertPhasedGeneralCustoms(booking);
return booking;
}
async getClearanceView(bookingId: string): Promise<BookingClearanceView> {
const booking = await this.loadBooking(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: BookingClearanceView['documents'] = [];
const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => {
if (!code) return;
let setting;
try {
setting = await this.fileUploadSettingsService.getByCode(code);
} catch {
return;
}
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');
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);
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const phase = this.workflowService.resolvePhaseForBooking(booking, milestones);
const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones);
const boundary = await this.workflowService.isBoundaryCompleteForBooking(
bookingId,
booking.tradeDirection ?? 'IMPORT',
);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
const workflowFiles = buildWorkflowFiles(
files,
booking.tradeDirection ?? 'IMPORT',
);
return {
bookingId,
status: booking.status,
includesCustoms,
inputCode,
outputCode,
documents,
allApproved,
phase,
milestones: milestones.map((m) => ({
id: m.id,
milestoneCode: m.milestoneCode,
milestoneLabel: m.milestoneLabel,
status: m.status,
ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
sortOrder: m.sortOrder,
})),
nextAction,
dutyRequired: booking.dutyRequired ?? null,
roHold: Boolean(booking.roHoldReason),
roHoldReason: booking.roHoldReason ?? null,
vesselDepartureDate: booking.vesselDepartureDate ?? null,
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
? booking.roAmendmentRequestedAt.toISOString()
: null,
operationReady: boundary,
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
};
}
private buildDutyAdvice(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): BookingClearanceView['dutyAdvice'] {
const advised = milestones.find(
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
);
if (!advised?.metadata) return null;
const amount = advised.metadata.dutyAmount;
const currency = advised.metadata.dutyCurrency;
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
const notice = files.find((f) => f.code === 'duty_tax_notice');
return {
amount,
currency,
declarationSerial:
typeof advised.metadata.declarationSerial === 'string'
? advised.metadata.declarationSerial
: null,
noticeFile: notice
? { id: notice.id, name: notice.name, url: notice.url }
: null,
};
}
private async isClearanceFullyApproved(booking: Booking): Promise<boolean> {
const { inputCode } = clearanceCodesForBooking(booking);
if (!inputCode) return true;
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',
),
);
}
isPhasedGeneralCustomsBooking(booking: Booking): boolean {
return (
Boolean(booking.customsClearingEnabled) &&
booking.contractKind === 'GENERAL' &&
Boolean(booking.contractId)
);
}
async uploadDeclaration(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
const allApproved = await this.isClearanceFullyApproved(booking);
if (!allApproved) {
throw new BadRequestException(
'All required customer documents must be approved before uploading a declaration.',
);
}
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
await this.workflowService.onAllDocsApprovedForBooking(bookingId);
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
tradeDirection,
'UNDER_CUSTOMS_CLEARANCE',
);
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase:
tradeDirection === 'EXPORT'
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
return this.bookingsService.findById(bookingId);
}
async adviseDuty(
bookingId: string,
dto: AdviseContractDutyDto,
userId?: string,
attachment?: Express.Multer.File,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty advice applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'DUTY_TAXES_ADVISED',
);
await this.bookingsRepository.update(bookingId, {
dutyRequired: dto.dutyRequired,
clearanceCurrentPhase: dto.dutyRequired
? ContractDocPhase.CustomerDuty
: ContractDocPhase.GlEtPostClearance,
} as never);
if (!dto.dutyRequired) {
await this.workflowService.onDutySkippedForBooking(bookingId);
} else {
if (dto.amount == null || dto.amount < 0) {
throw new BadRequestException('Duty amount is required when duty applies.');
}
if (!attachment) {
throw new BadRequestException('Duty notice attachment is required when duty applies.');
}
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_notice',
file: attachment,
});
await this.milestoneService.adviseDuty(
bookingId,
{
amount: dto.amount,
currency: dto.currency ?? 'ETB',
declarationSerial: dto.declarationSerial,
},
userId,
);
}
return this.bookingsService.findById(bookingId);
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
}
if (!booking.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt',
file,
});
await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID');
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
return this.bookingsService.findById(bookingId);
}
async uploadTransitPermit(
bookingId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Transit permit applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files);
await this.workflowService.completeMilestoneForBooking(
bookingId,
'TRANSIT_PERMIT_UPLOADED',
userId,
);
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (booking.preClearanceFinalizedAt) {
return this.bookingsService.findById(bookingId);
}
await this.bookingsRepository.update(bookingId, {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
return this.bookingsService.findById(bookingId);
}
async uploadDeliveryOrder(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import bookings.');
}
if (!booking.preClearanceFinalizedAt) {
throw new BadRequestException(
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
);
}
await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'delivery_order',
file,
});
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
return this.bookingsService.findById(bookingId);
}
private async resolveRoMinDays(): Promise<number> {
try {
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
const first = setting.children?.[0];
const n = Number(first?.value);
return Number.isFinite(n) && n > 0 ? n : 2;
} catch {
return 2;
}
}
private daysUntil(dateStr: string): number {
const target = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
}
async uploadReleaseOrder(
bookingId: string,
file: Express.Multer.File,
vesselDepartureDate: string,
userId?: string,
): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
if (!file) throw new BadRequestException('No Release Order uploaded');
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
}
const minDays = await this.resolveRoMinDays();
const leadDays = this.daysUntil(vesselDepartureDate);
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'release_order',
file,
});
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
await this.bookingsRepository.update(bookingId, {
roHoldReason: reason,
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
return {
booking: await this.bookingsService.findById(bookingId),
hold: true,
holdReason: reason,
};
}
await this.bookingsRepository.update(bookingId, {
roHoldReason: null,
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.workflowService.completeMilestoneForBooking(
bookingId,
'RELEASE_ORDER_SECURED',
userId,
);
return { booking: await this.bookingsService.findById(bookingId), hold: false };
}
async requestRoAmendment(
bookingId: string,
note?: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('RO amendment applies only to export bookings.');
}
const reason =
note?.trim() ||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
await this.bookingsRepository.update(bookingId, {
roAmendmentRequestedAt: new Date(),
roHoldReason: reason,
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
if (userId) {
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'CHANGES_REQUESTED',
userId,
);
}
return this.bookingsService.findById(bookingId);
}
async confirmExportRelease(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export release applies only to export bookings.');
}
await this.workflowService.assertPriorCompleteForBooking(
bookingId,
'EXPORT',
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
}
return filtered;
}
async djQueue(): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...DJ_BOOKING_QUEUE_STATUSES,
]);
const filtered: Booking[] = [];
for (const b of candidates) {
if (!this.isPhasedGeneralCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (
belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, {
roHoldReason: b.roHoldReason,
preClearanceFinalizedAt: b.preClearanceFinalizedAt,
})
) {
filtered.push(b);
}
}
return filtered;
}
}

View File

@@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
async findById(id: string): Promise<BookingRequest | null> { async findById(id: string): Promise<BookingRequest | null> {
return this.repository.findOne({ return this.repository.findOne({
where: { id }, where: { id },
relations: { contract: true }, // Load the contract with the bits the detail page surfaces: customer
// (company), service type (mile/customs flags), routes (with yard labels)
// and cargo scope.
relations: {
contract: {
company: true,
serviceType: true,
routes: { originYard: true, destinationYard: true },
cargoScope: true,
},
},
}); });
} }

View File

@@ -22,6 +22,11 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true },
TRANSIT_PERMIT_UPLOADED: {
label: 'Transit Permit Uploaded',
ownerRegion: 'ET',
triggeredByDoc: true,
},
DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true },
WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false },
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true },
@@ -52,6 +57,11 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false },
FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true },
WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false },
EXPORT_TRANSPORT_ISSUED: {
label: 'Export Transport Document Issued',
ownerRegion: 'ET',
triggeredByDoc: true,
},
CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false },
READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false },
LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false },

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { import {
@@ -39,6 +39,15 @@ export class ClearanceMilestoneService {
}); });
} }
/** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */
async seedPreBookingMilestonesOnBooking(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const { preBooking } = splitMilestones(tradeDirection);
await this.seed(preBooking, { bookingId });
}
/** Seed the post-booking milestones onto a freshly created booking. */ /** Seed the post-booking milestones onto a freshly created booking. */
async seedPostBookingMilestones( async seedPostBookingMilestones(
bookingId: string, bookingId: string,
@@ -94,7 +103,7 @@ export class ClearanceMilestoneService {
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
} }
if (milestone.status === 'COMPLETED') { if (milestone.status === 'COMPLETED') {
throw new BadRequestException(`Milestone ${code} is already completed.`); return milestone;
} }
milestone.status = 'COMPLETED'; milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date(); milestone.triggeredAt = new Date();
@@ -178,7 +187,7 @@ export class ClearanceMilestoneService {
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
} }
if (milestone.status === 'COMPLETED') { if (milestone.status === 'COMPLETED') {
throw new BadRequestException(`Milestone ${code} is already completed.`); return milestone;
} }
milestone.status = 'COMPLETED'; milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date(); milestone.triggeredAt = new Date();
@@ -187,6 +196,99 @@ export class ClearanceMilestoneService {
return this.repo.save(milestone); return this.repo.save(milestone);
} }
/** Skip optional milestones (e.g. duty when not required). */
/** Reopen a completed contract milestone so review can continue after a query. */
async reopenForContract(contractId: string, code: string): Promise<void> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone || milestone.status !== 'COMPLETED') return;
milestone.status = 'PENDING';
milestone.triggeredAt = null;
milestone.triggeredByUserId = null;
await this.repo.save(milestone);
}
/** Reopen a completed booking milestone so review can continue after a query. */
async reopenForBooking(bookingId: string, code: string): Promise<void> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone || milestone.status !== 'COMPLETED') return;
milestone.status = 'PENDING';
milestone.triggeredAt = null;
milestone.triggeredByUserId = null;
await this.repo.save(milestone);
}
async skipForContract(contractId: string, code: string): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
}
if (milestone.status === 'COMPLETED') return milestone;
milestone.status = 'SKIPPED';
milestone.triggeredAt = new Date();
return this.repo.save(milestone);
}
async skipForBooking(bookingId: string, code: string): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`);
}
if (milestone.status === 'COMPLETED') return milestone;
milestone.status = 'SKIPPED';
milestone.triggeredAt = new Date();
return this.repo.save(milestone);
}
async completeWithMetadataForBooking(
bookingId: string,
code: string,
metadata: MilestoneMetadata,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadata(bookingId, code, metadata, userId, note);
}
/** Complete a contract milestone with structured metadata (duty advice, etc.). */
async completeWithMetadataForContract(
contractId: string,
code: string,
metadata: MilestoneMetadata,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } });
if (!milestone) {
throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`);
}
if (milestone.status === 'COMPLETED') {
return milestone;
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata };
if (note) milestone.note = note;
return this.repo.save(milestone);
}
async adviseDutyForContract(
contractId: string,
input: { amount: number; currency: string; declarationSerial?: string },
userId?: string,
): Promise<ClearanceMilestone> {
return this.completeWithMetadataForContract(
contractId,
'DUTY_TAXES_ADVISED',
{
dutyAmount: input.amount,
dutyCurrency: input.currency,
declarationSerial: input.declarationSerial,
},
userId,
);
}
/** Complete a doc-triggered milestone when its document is uploaded/approved. */ /** Complete a doc-triggered milestone when its document is uploaded/approved. */
async completeByDocTrigger( async completeByDocTrigger(
scope: { bookingId?: string; contractId?: string }, scope: { bookingId?: string; contractId?: string },

View File

@@ -0,0 +1,331 @@
import { BadRequestException } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import type { ClearanceMilestone } from './entities/clearance-milestone.entity';
import type { Contract } from './entities/contract.entity';
import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import type { Booking } from '../bookings/entities/booking.entity';
function ms(
code: string,
status: 'PENDING' | 'COMPLETED' | 'SKIPPED',
ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET',
): ClearanceMilestone {
return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone;
}
function importThroughDeclaration(): ClearanceMilestone[] {
return [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'),
ms('DECLARED', 'PENDING', 'ET'),
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
];
}
function makeService(milestones: ClearanceMilestone[]) {
const contractsRepository = {
currentCycle: jest.fn(),
update: jest.fn(),
setCycleStatus: jest.fn(),
updateCycle: jest.fn(),
};
const milestoneService = {
listForContract: jest.fn().mockResolvedValue(milestones),
listForBooking: jest.fn().mockResolvedValue(milestones),
skipForContract: jest.fn(),
completeForContract: jest.fn(),
completeWithMetadataForContract: jest.fn(),
};
const bookingsRepository = { update: jest.fn() };
const service = new ClearanceWorkflowService(
contractsRepository as never,
milestoneService as never,
bookingsRepository as never,
);
return { service, milestoneService, contractsRepository, bookingsRepository };
}
const importContract = {
id: 'c-import',
tradeDirection: 'IMPORT',
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
} as Contract;
const exportContract = {
id: 'c-export',
tradeDirection: 'EXPORT',
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
} as Contract;
describe('ClearanceWorkflowService', () => {
describe('boundaryMilestone', () => {
it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => {
const { service } = makeService([]);
expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED');
expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED');
});
});
describe('assertPriorComplete', () => {
it('rejects when a prior milestone is still pending', async () => {
const milestones = importThroughDeclaration().map((m) =>
m.milestoneCode === 'DOCUMENTS_APPROVED'
? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET')
: m,
);
const { service } = makeService(milestones);
await expect(
service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('allows proceeding when prior milestones are completed or skipped', async () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
];
const { service } = makeService(milestones);
await expect(
service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'),
).resolves.toBeUndefined();
});
});
describe('isBoundaryComplete', () => {
it('returns true only when boundary milestone is completed', async () => {
const done = [
...importThroughDeclaration().slice(0, -1),
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
];
const { service: doneSvc } = makeService(done);
await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true);
const pending = importThroughDeclaration();
const { service: pendingSvc } = makeService(pending);
await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false);
});
});
describe('onDutySkipped', () => {
it('skips duty milestones on the contract', async () => {
const { service, milestoneService } = makeService([]);
await service.onDutySkipped('c-import');
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
'c-import',
'DUTY_TAXES_ADVISED',
);
expect(milestoneService.skipForContract).toHaveBeenCalledWith(
'c-import',
'DUTY_TAX_PAID',
);
});
});
describe('computeNextAction — import happy path', () => {
it('prompts customer to upload docs first', () => {
const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]);
const next = service.computeNextAction(importContract, null, [
ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'),
]);
expect(next?.actor).toBe('CUSTOMER');
expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED');
});
it('prompts ET review after customer docs', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
];
const { service } = makeService(milestones);
const next = service.computeNextAction(importContract, null, milestones);
expect(next?.actor).toBe('GL_ET');
expect(next?.action).toMatch(/Review/i);
});
it('prompts duty toggle when declaration done and duty unset', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'),
];
const cycle = { dutyRequired: null } as ContractClearanceCycle;
const { service } = makeService(milestones);
const next = service.computeNextAction(importContract, cycle, milestones);
expect(next?.actor).toBe('GL_ET');
expect(next?.action).toMatch(/duty/i);
});
it('prompts customer duty slip when duty required and advised', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'),
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
];
const cycle = { dutyRequired: true } as ContractClearanceCycle;
const { service } = makeService(milestones);
const next = service.computeNextAction(importContract, cycle, milestones);
expect(next?.actor).toBe('CUSTOMER');
expect(next?.milestoneCode).toBe('DUTY_TAX_PAID');
});
it('skips duty path when duty not required', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'),
];
const cycle = { dutyRequired: false } as ContractClearanceCycle;
const { service } = makeService(milestones);
const next = service.computeNextAction(importContract, cycle, milestones);
expect(next?.actor).toBe('GL_ET');
expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED');
});
it('prompts ET to finalize pre-clearance after transit permit', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
];
const cycle = { dutyRequired: false } as ContractClearanceCycle;
const { service } = makeService(milestones);
const next = service.computeNextAction(importContract, cycle, milestones);
expect(next?.actor).toBe('GL_ET');
expect(next?.action).toMatch(/finalize pre-clearance/i);
});
it('prompts DJ for DO then ET booking when pre-booking complete', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
];
const cycle = {
dutyRequired: false,
preClearanceFinalizedAt: new Date(),
} as ContractClearanceCycle;
const { service } = makeService(milestones);
const djNext = service.computeNextAction(importContract, cycle, milestones);
expect(djNext?.actor).toBe('GL_DJ');
const booked = milestones.map((m) =>
m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m,
);
const etNext = service.computeNextAction(importContract, cycle, booked);
expect(etNext?.actor).toBe('GL_ET');
expect(etNext?.action).toMatch(/booking/i);
});
});
describe('computeNextAction — export RO hold', () => {
it('surfaces DJ action when RO is on hold', () => {
const milestones = [
ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'),
];
const cycle = {
roHoldReason: 'Vessel departs in 1 day(s) — minimum lead time is 2 day(s).',
} as ContractClearanceCycle;
const { service } = makeService(milestones);
const next = service.computeNextAction(exportContract, cycle, milestones);
expect(next?.actor).toBe('GL_DJ');
expect(next?.blockedReason).toMatch(/minimum lead time/i);
});
});
describe('inferPhase', () => {
it('places import contract in customer duty phase when duty outstanding', () => {
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAX_PAID', 'PENDING', 'CUST'),
];
const cycle = { dutyRequired: true } as ContractClearanceCycle;
const { service } = makeService(milestones);
const phase = service.inferPhase(importContract, cycle, milestones);
expect(phase).toBe(ContractDocPhase.CustomerDuty);
});
});
describe('queue helpers', () => {
it('returns first pending ET-owned milestone code', () => {
const { service } = makeService([
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
]);
expect(service.etPendingMilestoneCodes([])).toBeNull();
expect(
service.etPendingMilestoneCodes([
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'),
]),
).toBe('DOCUMENTS_APPROVED');
});
it('returns first pending DJ-owned milestone code', () => {
const { service } = makeService([]);
expect(
service.djPendingMilestoneCodes([
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
ms('DO_COLLECTED', 'PENDING', 'DJ'),
]),
).toBe('DO_COLLECTED');
});
});
describe('computeNextActionForBooking', () => {
it('prompts customer to proceed after import boundary on booking', () => {
const booking = {
tradeDirection: 'IMPORT',
dutyRequired: false,
preClearanceFinalizedAt: new Date(),
} as Booking;
const milestones = [
ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'),
ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'),
ms('DECLARED', 'COMPLETED', 'ET'),
ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'),
ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'),
ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'),
ms('DO_COLLECTED', 'COMPLETED', 'DJ'),
];
const { service } = makeService(milestones);
const next = service.computeNextActionForBooking(booking, milestones);
expect(next?.actor).toBe('CUSTOMER');
expect(next?.action).toMatch(/operation/i);
});
});
});

View File

@@ -0,0 +1,566 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { MilestoneMetadata } from './entities/clearance-milestone.entity';
import { splitMilestones } from './clearance-milestone.catalog';
import { Contract } from './entities/contract.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import type { ClearanceMetaState } from './clearance-workflow.types';
import { metaFromBooking } from './clearance-workflow.types';
export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS';
export interface ClearanceNextAction {
actor: ClearanceActorRole;
action: string;
milestoneCode?: string | null;
blockedReason?: string | null;
}
const IMPORT_BOUNDARY = 'DO_COLLECTED';
const EXPORT_BOUNDARY = 'EXPORT_RELEASED';
const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED';
const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED';
@Injectable()
export class ClearanceWorkflowService {
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly milestoneService: ClearanceMilestoneService,
private readonly bookingsRepository: BookingsRepository,
) {}
boundaryMilestone(tradeDirection: string): string {
return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY;
}
// ── Contract scope (ONE_TIME) ─────────────────────────────────────────────
async listMilestones(contractId: string): Promise<ClearanceMilestone[]> {
return this.milestoneService.listForContract(contractId);
}
async listMilestonesForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
return this.milestoneService.listForBooking(bookingId);
}
async isBoundaryComplete(contractId: string, tradeDirection: string): Promise<boolean> {
return this.isBoundaryCompleteForMilestones(
await this.listMilestones(contractId),
tradeDirection,
);
}
async isBoundaryCompleteForBooking(
bookingId: string,
tradeDirection: string,
): Promise<boolean> {
return this.isBoundaryCompleteForMilestones(
await this.listMilestonesForBooking(bookingId),
tradeDirection,
);
}
private isBoundaryCompleteForMilestones(
milestones: ClearanceMilestone[],
tradeDirection: string,
): boolean {
const code = this.boundaryMilestone(tradeDirection);
const m = milestones.find((x) => x.milestoneCode === code);
return m?.status === 'COMPLETED';
}
async assertBoundaryComplete(contract: Contract): Promise<void> {
const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection);
if (!ok) {
throw new BadRequestException(
`Pre-booking clearance is not complete — ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`,
);
}
}
async assertPriorComplete(
contractId: string,
tradeDirection: string,
targetCode: string,
): Promise<void> {
await this.assertPriorCompleteOnMilestones(
await this.listMilestones(contractId),
tradeDirection,
targetCode,
);
}
async assertPriorCompleteForBooking(
bookingId: string,
tradeDirection: string,
targetCode: string,
): Promise<void> {
await this.assertPriorCompleteOnMilestones(
await this.listMilestonesForBooking(bookingId),
tradeDirection,
targetCode,
);
}
private async assertPriorCompleteOnMilestones(
milestones: ClearanceMilestone[],
tradeDirection: string,
targetCode: string,
): Promise<void> {
const { preBooking } = splitMilestones(tradeDirection);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const targetIdx = preBooking.findIndex((d) => d.code === targetCode);
if (targetIdx < 0) return;
for (let i = 0; i < targetIdx; i++) {
const code = preBooking[i]!.code;
const m = byCode.get(code);
if (!m) continue;
if (m.status === 'SKIPPED') continue;
if (m.status !== 'COMPLETED') {
throw new BadRequestException(
`Complete "${preBooking[i]!.label}" before proceeding.`,
);
}
}
}
async skipMilestones(contractId: string, codes: string[]): Promise<void> {
for (const code of codes) {
await this.milestoneService.skipForContract(contractId, code);
}
}
async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise<void> {
for (const code of codes) {
await this.milestoneService.skipForBooking(bookingId, code);
}
}
async completeMilestone(
contractId: string,
code: string,
userId?: string,
metadata?: MilestoneMetadata,
): Promise<ClearanceMilestone> {
if (metadata && Object.keys(metadata).length > 0) {
return this.milestoneService.completeWithMetadataForContract(
contractId,
code,
metadata,
userId,
);
}
return this.milestoneService.completeForContract(contractId, code, userId);
}
async completeMilestoneForBooking(
bookingId: string,
code: string,
userId?: string,
metadata?: MilestoneMetadata,
): Promise<ClearanceMilestone> {
if (metadata && Object.keys(metadata).length > 0) {
return this.milestoneService.completeWithMetadataForBooking(
bookingId,
code,
metadata,
userId,
);
}
return this.milestoneService.completeForBooking(bookingId, code, userId);
}
async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise<void> {
const uploaded =
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
await this.completeMilestone(contractId, uploaded);
await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW');
}
async onCustomerDocsUploadedForBooking(
bookingId: string,
tradeDirection: string,
): Promise<void> {
const uploaded =
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
await this.completeMilestoneForBooking(bookingId, uploaded);
await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW');
}
async onAllDocsApproved(contractId: string): Promise<void> {
await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED');
}
async onAllDocsApprovedForBooking(bookingId: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED');
}
/** Customer doc queried or re-uploaded — document approval milestone must reopen. */
async onDocumentReviewReopened(contractId: string): Promise<void> {
await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED');
}
async onDocumentReviewReopenedForBooking(bookingId: string): Promise<void> {
await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED');
}
async onDeclarationUploaded(contractId: string, userId?: string): Promise<void> {
await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE');
await this.completeMilestone(contractId, 'DECLARED', userId);
}
async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE');
await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId);
}
async onDutySkipped(contractId: string): Promise<void> {
await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
}
async onDutySkippedForBooking(bookingId: string): Promise<void> {
await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']);
}
async onExportReleased(contractId: string, userId?: string): Promise<void> {
await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId);
await this.markReadyForBooking(contractId);
}
async onExportReleasedForBooking(bookingId: string, userId?: string): Promise<void> {
await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId);
await this.markReadyForOperation(bookingId);
}
async markReadyForBooking(contractId: string): Promise<void> {
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_READY_FOR_BOOKING',
clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING',
} as never);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', {
clearanceReadyAt: new Date(),
currentPhase: ContractDocPhase.GlEtPostClearance,
});
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
}
/** GENERAL per-booking: boundary complete → customer may proceed to operations. */
async markReadyForOperation(bookingId: string): Promise<void> {
await this.bookingsRepository.update(bookingId, {
status: 'CLEARANCE_READY',
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
}
resolvePhase(
contract: Contract,
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ContractDocPhase {
const meta: ClearanceMetaState = {
dutyRequired: cycle?.dutyRequired ?? null,
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
currentPhase: cycle?.currentPhase ?? null,
};
return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones);
}
resolvePhaseForBooking(
booking: Booking,
milestones: ClearanceMilestone[],
): ContractDocPhase {
return this.resolvePhaseFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
);
}
private resolvePhaseFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
): ContractDocPhase {
if (meta.currentPhase) {
return meta.currentPhase as ContractDocPhase;
}
return this.inferPhaseFromMeta(tradeDirection, meta, milestones);
}
inferPhase(
contract: Contract,
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ContractDocPhase {
return this.inferPhaseFromMeta(
contract.tradeDirection,
{
dutyRequired: cycle?.dutyRequired ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
},
milestones,
);
}
inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase {
return this.inferPhaseFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
);
}
private inferPhaseFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
): ContractDocPhase {
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const isDone = (code: string) =>
byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED';
const docUploaded =
tradeDirection === 'IMPORT'
? isDone(IMPORT_DOC_UPLOADED)
: isDone(EXPORT_DOC_UPLOADED);
if (!docUploaded) return ContractDocPhase.CustomerIntake;
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return ContractDocPhase.GlDjCollection;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
return ContractDocPhase.GlEtPostClearance;
}
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) {
return ContractDocPhase.CustomerDuty;
}
if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance;
if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance;
if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection;
return ContractDocPhase.GlEtPostClearance;
}
computeNextAction(
contract: Contract,
cycle: ContractClearanceCycle | null,
milestones: ClearanceMilestone[],
): ClearanceNextAction | null {
return this.computeNextActionFromMeta(
contract.tradeDirection,
{
dutyRequired: cycle?.dutyRequired ?? null,
roHoldReason: cycle?.roHoldReason ?? null,
preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null,
},
milestones,
'contract',
);
}
computeNextActionForBooking(
booking: Booking,
milestones: ClearanceMilestone[],
): ClearanceNextAction | null {
return this.computeNextActionFromMeta(
booking.tradeDirection ?? 'IMPORT',
metaFromBooking(booking),
milestones,
'booking',
);
}
private computeNextActionFromMeta(
tradeDirection: string,
meta: ClearanceMetaState,
milestones: ClearanceMilestone[],
terminalScope: 'contract' | 'booking',
): ClearanceNextAction | null {
if (meta.roHoldReason) {
return {
actor: 'GL_DJ',
action: 'Re-upload Release Order or request port amendment',
milestoneCode: 'RELEASE_ORDER_SECURED',
blockedReason: meta.roHoldReason,
};
}
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const pending = (code: string) => {
const m = byCode.get(code);
return m && m.status === 'PENDING';
};
const isDone = (code: string) => {
const m = byCode.get(code);
return m?.status === 'COMPLETED' || m?.status === 'SKIPPED';
};
const docCode =
tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED;
if (pending(docCode) || !isDone(docCode)) {
return {
actor: 'CUSTOMER',
action: 'Upload clearance documents',
milestoneCode: docCode,
};
}
if (!isDone('DOCUMENTS_APPROVED')) {
return {
actor: 'GL_ET',
action: 'Review and approve customer documents',
milestoneCode: 'DOCUMENTS_APPROVED',
};
}
const terminalAction =
terminalScope === 'contract'
? 'Create shipment booking'
: 'Proceed to request operation';
if (tradeDirection === 'EXPORT') {
if (!isDone('RELEASE_ORDER_SECURED')) {
return {
actor: 'GL_DJ',
action: 'Upload Release Order and vessel departure date',
milestoneCode: 'RELEASE_ORDER_SECURED',
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
action: 'Upload customs declaration documents',
milestoneCode: 'DECLARED',
};
}
if (!isDone(EXPORT_BOUNDARY)) {
return {
actor: 'GL_ET',
action: 'Confirm export release',
milestoneCode: EXPORT_BOUNDARY,
};
}
if (terminalScope === 'booking') {
if (!isDone('FREIGHT_PAYMENT_SETTLED')) {
return {
actor: 'CUSTOMER',
action: 'Pay freight charges',
milestoneCode: 'FREIGHT_PAYMENT_SETTLED',
};
}
if (!isDone('WAGON_ALLOCATED')) {
return {
actor: 'OPERATIONS',
action: 'Allocate wagon',
milestoneCode: 'WAGON_ALLOCATED',
};
}
if (!isDone('EXPORT_TRANSPORT_ISSUED')) {
return {
actor: 'GL_ET',
action: 'Upload transit permit',
milestoneCode: 'EXPORT_TRANSPORT_ISSUED',
};
}
return null;
}
return {
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
action: terminalAction,
milestoneCode: EXPORT_BOUNDARY,
};
}
if (!isDone('DECLARED')) {
return {
actor: 'GL_ET',
action: 'Upload customs declaration documents',
milestoneCode: 'DECLARED',
};
}
if (meta.dutyRequired === null || meta.dutyRequired === undefined) {
return {
actor: 'GL_ET',
action: 'Set whether duty/tax applies',
milestoneCode: 'DUTY_TAXES_ADVISED',
};
}
if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) {
if (!isDone('DUTY_TAXES_ADVISED')) {
return {
actor: 'GL_ET',
action: 'Advise duty and tax amount',
milestoneCode: 'DUTY_TAXES_ADVISED',
};
}
return {
actor: 'CUSTOMER',
action: 'Upload duty/tax payment slip',
milestoneCode: 'DUTY_TAX_PAID',
};
}
if (!isDone('TRANSIT_PERMIT_UPLOADED')) {
return {
actor: 'GL_ET',
action: 'Upload transit permit screenshot',
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
};
}
if (!meta.preClearanceFinalizedAt) {
return {
actor: 'GL_ET',
action: 'Finalize pre-clearance',
milestoneCode: 'TRANSIT_PERMIT_UPLOADED',
};
}
if (!isDone(IMPORT_BOUNDARY)) {
return {
actor: 'GL_DJ',
action: 'Upload Delivery Order',
milestoneCode: IMPORT_BOUNDARY,
};
}
return {
actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER',
action: terminalAction,
milestoneCode: IMPORT_BOUNDARY,
};
}
etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET');
return pending?.milestoneCode ?? null;
}
djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null {
const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ');
return pending?.milestoneCode ?? null;
}
}

View File

@@ -0,0 +1,33 @@
import type { ContractDocPhase } from '@edr/types';
/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */
export interface ClearanceMetaState {
dutyRequired?: boolean | null;
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: Date | null;
roHoldReason?: string | null;
currentPhase?: ContractDocPhase | string | null;
preClearanceFinalizedAt?: Date | null;
}
export type ClearanceScope =
| { kind: 'contract'; contractId: string }
| { kind: 'booking'; bookingId: string };
export function metaFromBooking(booking: {
dutyRequired?: boolean | null;
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: Date | null;
roHoldReason?: string | null;
clearanceCurrentPhase?: string | null;
preClearanceFinalizedAt?: Date | null;
}): ClearanceMetaState {
return {
dutyRequired: booking.dutyRequired ?? null,
vesselDepartureDate: booking.vesselDepartureDate ?? null,
roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null,
roHoldReason: booking.roHoldReason ?? null,
currentPhase: booking.clearanceCurrentPhase ?? null,
preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null,
};
}

View File

@@ -23,6 +23,7 @@ import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity'; import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ /** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
@@ -56,6 +57,7 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService, private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
) {} ) {}
@@ -194,9 +196,11 @@ export class ContractBookingService {
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
} as never); } as never);
} else if (generalCustoms) { } else if (generalCustoms) {
// Per-booking clearance: seed post-booking milestones on the booking (no // Per-booking clearance: seed full milestone timeline on the booking.
// cycle needed) and leave the contract active. The booking now drives its await this.milestoneService.seedPreBookingMilestonesOnBooking(
// own clearance via the booking-level pipeline. booking.id,
contract.tradeDirection,
);
await this.milestoneService.seedPostBookingMilestones( await this.milestoneService.seedPostBookingMilestones(
booking.id, booking.id,
contract.tradeDirection, contract.tradeDirection,
@@ -246,10 +250,14 @@ export class ContractBookingService {
} }
return 'GL_ET'; return 'GL_ET';
} }
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle. // ONE_TIME customs — pre-booking boundary milestone must be complete.
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { const boundaryOk = await this.workflowService.isBoundaryComplete(
contract.id,
contract.tradeDirection,
);
if (!boundaryOk) {
throw new BadRequestException( throw new BadRequestException(
'Contract clearance is not ready for booking yet.', 'Pre-booking clearance is not complete — booking cannot be created yet.',
); );
} }
return 'GL_ET'; return 'GL_ET';

View File

@@ -1,13 +1,23 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
import { ContractsService, PaginatedContracts } from './contracts.service'; import { ContractsService, PaginatedContracts } from './contracts.service';
import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util'; import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity'; import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
import { FilterContractDto } from './dto/filter-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface ContractClearanceDocument { export interface ContractClearanceDocument {
fileKey: string; fileKey: string;
@@ -34,6 +44,39 @@ export interface ContractClearanceView {
outputCode: string | null; outputCode: string | null;
documents: ContractClearanceDocument[]; documents: ContractClearanceDocument[];
allApproved: boolean; allApproved: boolean;
phase?: string | null;
milestones?: Array<{
id: string;
milestoneCode: string;
milestoneLabel: string;
status: string;
ownerRegion?: string | null;
metadata?: Record<string, unknown> | null;
sortOrder: number;
}>;
nextAction?: {
actor: string;
action: string;
milestoneCode?: string | null;
blockedReason?: string | null;
} | null;
dutyRequired?: boolean | null;
roHold?: boolean;
roHoldReason?: string | null;
vesselDepartureDate?: string | null;
roAmendmentRequestedAt?: string | null;
bookingReady?: boolean;
preClearanceFinalized?: boolean;
/** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */
exportClearanceFinalized?: boolean;
linkedBookingId?: string | null;
dutyAdvice?: {
amount: number;
currency: string;
declarationSerial?: string | null;
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
} }
@Injectable() @Injectable()
@@ -41,13 +84,57 @@ export class ContractClearanceService {
constructor( constructor(
private readonly contractsRepository: ContractsRepository, private readonly contractsRepository: ContractsRepository,
private readonly contractsService: ContractsService, private readonly contractsService: ContractsService,
private readonly bookingsService: BookingsService,
private readonly filesService: FilesService, private readonly filesService: FilesService,
private readonly fileUploadSettingsService: FileUploadSettingsService, private readonly fileUploadSettingsService: FileUploadSettingsService,
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
) {} ) {}
private isPhasedCustoms(contract: Contract): boolean {
return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME';
}
private assertPhasedCustoms(contract: Contract): void {
if (!this.isPhasedCustoms(contract)) {
throw new BadRequestException(
'Phased clearance (Phase 1) applies to one-time customs contracts.',
);
}
}
/**
* Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing
* phased milestones. Revert that state so declaration / DO steps can proceed.
*/
private async reconcilePrematureBookingReady(
contractId: string,
contract: Contract,
bookingReady: boolean,
): Promise<Contract> {
if (
!this.isPhasedCustoms(contract) ||
contract.status !== 'CLEARANCE_READY_FOR_BOOKING' ||
bookingReady
) {
return contract;
}
const cycle = await this.contractsRepository.currentCycle(contractId);
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
return this.contractsService.findById(contractId);
}
/** The pre-booking clearance document grid for a contract (Path B). */ /** The pre-booking clearance document grid for a contract (Path B). */
async getClearanceView(contractId: string): Promise<ContractClearanceView> { async getClearanceView(contractId: string): Promise<ContractClearanceView> {
const contract = await this.contractsService.findById(contractId); let contract = await this.contractsService.findById(contractId);
const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId); const cycle = await this.contractsRepository.currentCycle(contractId);
@@ -109,6 +196,47 @@ export class ContractClearanceService {
} }
const allApproved = await this.isClearanceFullyApproved(contract); const allApproved = await this.isClearanceFullyApproved(contract);
const milestones = await this.workflowService.listMilestones(contractId);
let boundary = await this.workflowService.isBoundaryComplete(
contractId,
contract.tradeDirection,
);
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
const dutyAdvice = this.buildDutyAdvice(files, milestones);
let workflowFiles = buildWorkflowFiles(
files,
contract.tradeDirection ?? 'IMPORT',
);
if (cycle?.bookingId) {
const bookingFiles = await this.filesService.findByResource(
cycle.bookingId,
'bookings',
);
const bookingWorkflow = buildWorkflowFiles(
bookingFiles,
contract.tradeDirection ?? 'IMPORT',
);
const byCode = new Map(workflowFiles.map((f) => [f.code, f]));
for (const row of bookingWorkflow) {
if (row.file) byCode.set(row.code, row);
}
workflowFiles = [...byCode.values()];
}
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
return { return {
contractId, contractId,
@@ -120,6 +248,55 @@ export class ContractClearanceService {
outputCode, outputCode,
documents, documents,
allApproved, allApproved,
phase,
milestones: milestones.map((m) => ({
id: m.id,
milestoneCode: m.milestoneCode,
milestoneLabel: m.milestoneLabel,
status: m.status,
ownerRegion: m.ownerRegion,
metadata: (m.metadata ?? null) as Record<string, unknown> | null,
sortOrder: m.sortOrder,
})),
nextAction,
dutyRequired: cycle?.dutyRequired ?? null,
roHold: Boolean(cycle?.roHoldReason),
roHoldReason: cycle?.roHoldReason ?? null,
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
? cycle.roAmendmentRequestedAt.toISOString()
: null,
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
dutyAdvice,
workflowFiles,
};
}
private buildDutyAdvice(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
milestones: ClearanceMilestone[],
): ContractClearanceView['dutyAdvice'] {
const advised = milestones.find(
(m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED',
);
if (!advised?.metadata) return null;
const amount = advised.metadata.dutyAmount;
const currency = advised.metadata.dutyCurrency;
if (typeof amount !== 'number' || typeof currency !== 'string') return null;
const notice = files.find((f) => f.code === 'duty_tax_notice');
return {
amount,
currency,
declarationSerial:
typeof advised.metadata.declarationSerial === 'string'
? advised.metadata.declarationSerial
: null,
noticeFile: notice
? { id: notice.id, name: notice.name, url: notice.url }
: null,
}; };
} }
@@ -154,6 +331,59 @@ export class ContractClearanceService {
); );
} }
/** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */
private assertClearanceReviewableStatus(contract: Contract): void {
const allowed = [
'CLEARANCE_UNDER_REVIEW',
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_READY_FOR_BOOKING',
];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);
}
}
/** Finalize when docs are under review or all approved after a partial query cycle. */
private assertClearanceFinalizableStatus(contract: Contract): void {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
}
private assertClearanceOutputUploadableStatus(contract: Contract): void {
const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS'];
if (!allowed.includes(contract.status)) {
throw new ConflictException(
`Cannot upload output documents on status "${contract.status}".`,
);
}
}
private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise<void> {
const refreshed = await this.contractsService.findById(contractId);
const allApproved = await this.isClearanceFullyApproved(refreshed);
if (
!allApproved ||
(refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' &&
refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING')
) {
return;
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW');
}
}
/** /**
* Customer uploads clearance documents on the contract. When every required * Customer uploads clearance documents on the contract. When every required
* input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET.
@@ -209,8 +439,16 @@ export class ContractClearanceService {
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never); } as never);
if (cycle) { if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', {
currentPhase: ContractDocPhase.GlEtReview,
});
} }
if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') {
await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection);
await this.workflowService.onDocumentReviewReopened(contractId);
}
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
} }
@@ -294,25 +532,22 @@ export class ContractClearanceService {
note?: string, note?: string,
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has this.assertClearanceReviewableStatus(contract);
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
// the contract to "awaiting" (the customer must re-upload), but the reviewer
// may still be working through the rest of the batch. Restricting to
// UNDER_REVIEW only would 409 every review after the first query.
if (
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
) {
throw new ConflictException(
`Cannot review clearance documents on status "${contract.status}".`,
);
}
if (status === 'QUERIED' && !note?.trim()) { if (status === 'QUERIED' && !note?.trim()) {
throw new BadRequestException('A note is required when querying a document'); throw new BadRequestException('A note is required when querying a document');
} }
const { inputCode, outputCode } = contractClearanceCodes(contract); const { inputCode, outputCode } = contractClearanceCodes(contract);
const cycle = await this.contractsRepository.currentCycle(contractId); const cycle = await this.contractsRepository.currentCycle(contractId);
if (
status === 'QUERIED' &&
this.isPhasedCustoms(contract) &&
cycle?.preClearanceFinalizedAt
) {
throw new BadRequestException(
'Customer documents cannot be queried after pre-clearance is finalized.',
);
}
const reviews = await this.contractsRepository.findDocumentReviews( const reviews = await this.contractsRepository.findDocumentReviews(
contractId, contractId,
cycle?.id ?? null, cycle?.id ?? null,
@@ -348,6 +583,33 @@ export class ContractClearanceService {
if (cycle) { if (cycle) {
await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS');
} }
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onDocumentReviewReopened(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtReview,
});
}
}
} else if (status === 'APPROVED') {
await this.bumpToUnderReviewWhenFullyApproved(contractId);
const refreshed = await this.contractsService.findById(contractId);
if (
refreshed.customsClearingEnabled &&
refreshed.contractKind === 'ONE_TIME' &&
(await this.isClearanceFullyApproved(refreshed))
) {
await this.workflowService.onAllDocsApproved(contractId);
const c = await this.contractsRepository.currentCycle(contractId);
if (c) {
await this.contractsRepository.updateCycle(c.id, {
currentPhase:
refreshed.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput,
});
}
}
} }
return this.contractsService.findById(contractId); return this.contractsService.findById(contractId);
@@ -359,11 +621,7 @@ export class ContractClearanceService {
files: Express.Multer.File[], files: Express.Multer.File[],
): Promise<Contract> { ): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { this.assertClearanceOutputUploadableStatus(contract);
throw new ConflictException(
`Cannot upload output documents on status "${contract.status}".`,
);
}
const { outputCode } = contractClearanceCodes(contract); const { outputCode } = contractClearanceCodes(contract);
if (!outputCode) { if (!outputCode) {
throw new BadRequestException('This contract has no customs output documents'); throw new BadRequestException('This contract has no customs output documents');
@@ -384,8 +642,10 @@ export class ContractClearanceService {
/** /**
* GL ET finalizes Path B pre-booking clearance: requires every customer * GL ET finalizes Path B pre-booking clearance: requires every customer
* document APPROVED and required output docs present → CLEARANCE_READY_FOR_BOOKING * document APPROVED. For phased customs (ONE_TIME), document review completes
* (GL then creates the booking). Rejects self-clearance (Path A) contracts. * here — booking readiness is set only after delivery order (import) or export
* release via the milestone workflow. Non-phased customs still jump straight to
* CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts.
*/ */
async finalize(contractId: string): Promise<Contract> { async finalize(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId); const contract = await this.contractsService.findById(contractId);
@@ -394,11 +654,7 @@ export class ContractClearanceService {
'Self-clearance (Path A) contracts are finalized by Operations, not GL.', 'Self-clearance (Path A) contracts are finalized by Operations, not GL.',
); );
} }
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { this.assertClearanceFinalizableStatus(contract);
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract); const approved = await this.isClearanceFullyApproved(contract);
if (!approved) { if (!approved) {
@@ -407,6 +663,24 @@ export class ContractClearanceService {
); );
} }
if (this.isPhasedCustoms(contract)) {
await this.workflowService.onAllDocsApproved(contractId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase:
contract.tradeDirection === 'EXPORT'
? ContractDocPhase.GlDjCollection
: ContractDocPhase.GlEtOutput,
});
}
await this.contractsRepository.update(contractId, {
status: 'CLEARANCE_UNDER_REVIEW',
clearanceStatus: 'DOCUMENTS_UNDER_REVIEW',
} as never);
return this.contractsService.findById(contractId);
}
const { outputCode } = contractClearanceCodes(contract); const { outputCode } = contractClearanceCodes(contract);
if (outputCode) { if (outputCode) {
const setting = await this.fileUploadSettingsService.getByCode(outputCode); const setting = await this.fileUploadSettingsService.getByCode(outputCode);
@@ -452,11 +726,7 @@ export class ContractClearanceService {
'Operations finalize applies only to self-clearance (non-customs) contracts.', 'Operations finalize applies only to self-clearance (non-customs) contracts.',
); );
} }
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { this.assertClearanceFinalizableStatus(contract);
throw new ConflictException(
`Cannot finalize clearance on status "${contract.status}".`,
);
}
const approved = await this.isClearanceFullyApproved(contract); const approved = await this.isClearanceFullyApproved(contract);
if (!approved) { if (!approved) {
@@ -479,19 +749,14 @@ export class ContractClearanceService {
} }
/** /**
* GL ET clearance hub: every customs (Path B) contract that still needs * GL ET clearance hub: every customs (Path B) contract in phased clearance,
* customs clearance — awaiting the customer's documents, under GL review, or * including after booking is created.
* finalized and waiting for the customer to create the booking in the portal.
*/ */
async queue(filter: FilterContractDto): Promise<PaginatedContracts> { async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({ return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1, page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100, pageSize: filter.pageSize ?? 100,
statuses: [ statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
],
customsClearingEnabled: true, customsClearingEnabled: true,
sortBy: filter.sortBy, sortBy: filter.sortBy,
sortOrder: filter.sortOrder, sortOrder: filter.sortOrder,
@@ -536,4 +801,492 @@ export class ContractClearanceService {
sortOrder: filter.sortOrder ?? 'DESC', sortOrder: filter.sortOrder ?? 'DESC',
}); });
} }
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
private async ensureDeclarationPrerequisites(
contractId: string,
contract: Contract,
): Promise<void> {
const allApproved = await this.isClearanceFullyApproved(contract);
if (!allApproved) {
throw new BadRequestException(
'All required customer documents must be approved before uploading a declaration.',
);
}
const milestones = await this.workflowService.listMilestones(contractId);
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
await this.workflowService.onAllDocsApproved(contractId);
}
}
async uploadDeclaration(
contractId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
await this.ensureDeclarationPrerequisites(contractId, contract);
await this.workflowService.assertPriorComplete(
contractId,
contract.tradeDirection,
'UNDER_CUSTOMS_CLEARANCE',
);
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
await persistDeclarationUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.onDeclarationUploaded(contractId, userId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase:
contract.tradeDirection === 'EXPORT'
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
});
}
return this.contractsService.findById(contractId);
}
async adviseDuty(
contractId: string,
dto: AdviseContractDutyDto,
userId?: string,
attachment?: Express.Multer.File,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty advice applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED');
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.contractsRepository.updateCycle(cycle.id, {
dutyRequired: dto.dutyRequired,
currentPhase: dto.dutyRequired
? ContractDocPhase.CustomerDuty
: ContractDocPhase.GlEtPostClearance,
});
if (!dto.dutyRequired) {
await this.workflowService.onDutySkipped(contractId);
} else {
if (dto.amount == null || dto.amount < 0) {
throw new BadRequestException('Duty amount is required when duty applies.');
}
if (!attachment) {
throw new BadRequestException('Duty notice attachment is required when duty applies.');
}
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'duty_tax_notice',
file: attachment,
});
await this.milestoneService.adviseDutyForContract(
contractId,
{
amount: dto.amount,
currency: dto.currency ?? 'ETB',
declarationSerial: dto.declarationSerial,
},
userId,
);
}
return this.contractsService.findById(contractId);
}
async uploadDutySlip(
contractId: string,
file: Express.Multer.File,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.dutyRequired) {
throw new BadRequestException('Duty/tax is not required for this clearance.');
}
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'duty_tax_receipt',
file,
});
await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID');
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
return this.contractsService.findById(contractId);
}
async uploadTransitPermit(
contractId: string,
files: Express.Multer.File[],
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Transit permit applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await persistTransitPermitUploads(
this.filesService,
contractId,
'contracts',
files,
);
await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle) {
await this.contractsRepository.updateCycle(cycle.id, {
currentPhase: ContractDocPhase.GlEtPostClearance,
});
}
return this.contractsService.findById(contractId);
}
async finalizePreClearance(contractId: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'IMPORT',
'TRANSIT_PERMIT_UPLOADED',
);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
if (cycle.preClearanceFinalizedAt) {
return this.contractsService.findById(contractId);
}
await this.contractsRepository.updateCycle(cycle.id, {
preClearanceFinalizedAt: new Date(),
currentPhase: ContractDocPhase.GlDjCollection,
});
return this.contractsService.findById(contractId);
}
async uploadDeliveryOrder(
contractId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.preClearanceFinalizedAt) {
throw new BadRequestException(
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
);
}
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'delivery_order',
file,
});
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
return this.contractsService.findById(contractId);
}
private async resolveRoMinDays(): Promise<number> {
try {
const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE);
const first = setting.children?.[0];
const n = Number(first?.value);
return Number.isFinite(n) && n > 0 ? n : 2;
} catch {
return 2;
}
}
private daysUntil(dateStr: string): number {
const target = new Date(dateStr);
const today = new Date();
today.setHours(0, 0, 0, 0);
target.setHours(0, 0, 0, 0);
return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000));
}
async uploadReleaseOrder(
contractId: string,
file: Express.Multer.File,
vesselDepartureDate: string,
userId?: string,
): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Release Order applies only to export contracts.');
}
await this.workflowService.assertPriorComplete(
contractId,
'EXPORT',
'RELEASE_ORDER_SECURED',
);
if (!file) throw new BadRequestException('No Release Order uploaded');
if (!vesselDepartureDate?.trim()) {
throw new BadRequestException('Vessel departure date is required');
}
const minDays = await this.resolveRoMinDays();
const leadDays = this.daysUntil(vesselDepartureDate);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
code: 'release_order',
file,
});
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate,
roAmendmentRequestedAt: null,
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
await this.contractsRepository.updateCycle(cycle.id, {
roHoldReason: reason,
currentPhase: ContractDocPhase.GlDjCollection,
});
return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason };
}
await this.contractsRepository.updateCycle(cycle.id, {
roHoldReason: null,
currentPhase: ContractDocPhase.GlEtOutput,
});
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
return { contract: await this.contractsService.findById(contractId), hold: false };
}
async requestRoAmendment(
contractId: string,
note?: string,
userId?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('RO amendment applies only to export contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle) throw new BadRequestException('No clearance cycle found');
const reason =
note?.trim() ||
'Port amendment requested — vessel departure window is too short. A new Release Order will be required.';
await this.contractsRepository.updateCycle(cycle.id, {
roAmendmentRequestedAt: new Date(),
roHoldReason: reason,
currentPhase: ContractDocPhase.GlDjCollection,
});
if (userId) {
await this.contractsRepository.createReviewNote(
contractId,
reason,
'CHANGES_REQUESTED',
userId,
'GL_DJ',
);
}
return this.contractsService.findById(contractId);
}
async confirmExportRelease(contractId: string, userId?: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export release applies only to export contracts.');
}
await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED');
await this.workflowService.onExportReleased(contractId, userId);
return this.contractsService.findById(contractId);
}
/** GL ET finalizes export clearance after post-booking transit permit is uploaded. */
async finalizeExportClearance(contractId: string, userId?: string): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
if (contract.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Export clearance finalize applies only to export contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.bookingId) {
throw new BadRequestException(
'A shipment booking must exist before export clearance can be finalized.',
);
}
if (cycle.completedAt) {
return this.contractsService.findById(contractId);
}
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
const transportDone = bookingMilestones.some(
(m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED',
);
if (!transportDone) {
throw new BadRequestException(
'Upload the transit permit before finalizing export clearance.',
);
}
await this.contractsRepository.updateCycle(cycle.id, {
completedAt: new Date(),
currentPhase: ContractDocPhase.GlEtPostClearance,
});
void userId;
return this.contractsService.findById(contractId);
}
/** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */
async etQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(c);
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
/** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */
async djQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
const base = await this.contractsRepository.findAllPaginated({
page: 1,
pageSize: 500,
statuses: [...DJ_CONTRACT_QUEUE_STATUSES],
customsClearingEnabled: true,
contractKind: 'ONE_TIME',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
const filtered: typeof base.items = [];
for (const c of base.items) {
const cycle = await this.contractsRepository.currentCycle(c.id);
const milestones = await this.workflowService.listMilestones(c.id);
if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) {
filtered.push(c);
}
}
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
meta: {
page,
pageSize,
total: filtered.length,
totalPages: Math.ceil(filtered.length / pageSize) || 1,
hasNextPage: start + pageSize < filtered.length,
hasPreviousPage: page > 1,
},
};
}
} }

View File

@@ -172,15 +172,11 @@ export class ContractTransitionService {
const cargoTypeId = const cargoTypeId =
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null;
// US-06 routing: bulk always needs director approval; container needs it only // Resolve the chain from the cargo type flag only.
// when its cargo type flags it. Resolve the chain via the same approval_rules let requiresDirectorApproval = false;
// source of truth the booking flow uses (no booking row is created here).
let requiresDirectorApproval = contract.freightType === 'BULK';
if (cargoTypeId) { if (cargoTypeId) {
const cargoType = await this.cargoTypesService.findById(cargoTypeId); const cargoType = await this.cargoTypesService.findById(cargoTypeId);
if (cargoType?.requiresDirectorApproval) { requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false;
requiresDirectorApproval = true;
}
} }
const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); const chain = await this.approvalRulesService.findChain(requiresDirectorApproval);
@@ -345,6 +341,14 @@ export class ContractTransitionService {
return { view, html, signatures: view.signatures }; return { view, html, signatures: view.signatures };
} }
/** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */
async streamContractPdf(contractId: string) {
const contract = await this.contractsService.findById(contractId);
const { view } = await this.documentViewModelBuilder.build(contractId);
const record = await this.upsertContractPdf(contractId, contract.reference, view);
return this.filesService.streamById(record.id);
}
/** /**
* Rebuild the stored `contract` PDF from the current aggregate (now including * Rebuild the stored `contract` PDF from the current aggregate (now including
* the latest signatures) so the downloaded/viewed file matches the live HTML * the latest signatures) so the downloaded/viewed file matches the live HTML

View File

@@ -9,13 +9,16 @@ import {
Patch, Patch,
Post, Post,
Query, Query,
Res,
UnauthorizedException, UnauthorizedException,
UploadedFiles, UploadedFiles,
UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { CurrentUser } from '@edr/api-common'; import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody, ApiBody,
@@ -40,11 +43,13 @@ import { ContractsService } from './contracts.service';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ContractTransitionService } from './contract-transition.service'; import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
import { ContractBookingService } from './contract-booking.service'; import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
import { BookingRequestService } from './booking-request.service'; import { BookingRequestService } from './booking-request.service';
import { SignaturesService } from '../signatures/signatures.service'; import { SignaturesService } from '../signatures/signatures.service';
import { BookingsService } from '../bookings/bookings.service';
import { CreateContractDto } from './dto/create-contract.dto'; import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto';
@@ -70,6 +75,10 @@ import {
CompleteMilestoneDto, CompleteMilestoneDto,
ReportIncidentDto, ReportIncidentDto,
} from './dto/gl-operations.dto'; } from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@ApiTags('contracts') @ApiTags('contracts')
@Controller('contracts') @Controller('contracts')
@@ -85,6 +94,8 @@ export class ContractsController {
private readonly glOperationsService: GlOperationsService, private readonly glOperationsService: GlOperationsService,
private readonly bookingRequestService: BookingRequestService, private readonly bookingRequestService: BookingRequestService,
private readonly signaturesService: SignaturesService, private readonly signaturesService: SignaturesService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly bookingsService: BookingsService,
) {} ) {}
// ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── // ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
@@ -417,6 +428,26 @@ export class ContractsController {
}; };
} }
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
): Promise<void> {
const contract = await this.contractsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.contractsService.assertCustomerCanAccessContract(user?.id, contract);
}
const { stream, record } = await this.transitionService.streamContractPdf(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Post(':id/contract/sign') @Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' })
signContract( signContract(
@@ -459,7 +490,10 @@ export class ContractsController {
} }
@Post(':id/clearance/review') @Post(':id/clearance/review')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) @BookingStaff([
FREIGHT_PERMS.contracts.clearanceReview,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' })
reviewClearanceDocument( reviewClearanceDocument(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,
@@ -489,11 +523,164 @@ export class ContractsController {
@Post(':id/clearance/finalize') @Post(':id/clearance/finalize')
@BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance)
@ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING' }) @ApiOperation({ summary: 'GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)' })
finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.finalize(id); return this.clearanceService.finalize(id);
} }
@Post(':id/clearance/declaration')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' })
uploadDeclaration(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user));
}
@Post(':id/clearance/duty')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise)
@UseInterceptors(FileInterceptor('attachment'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' })
adviseContractDuty(
@Param('id', ParseUUIDPipe) id: string,
@Body('dutyRequired') dutyRequiredRaw: string,
@Body('amount') amountRaw: string | undefined,
@Body('currency') currency: string | undefined,
@Body('declarationSerial') declarationSerial: string | undefined,
@UploadedFile() attachment: Express.Multer.File | undefined,
@CurrentUser() user: AuthUserPayload,
) {
const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1';
const dto: AdviseContractDutyDto = {
dutyRequired,
amount:
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
currency: currency ?? 'ETB',
declarationSerial,
};
return this.clearanceService.adviseDuty(
id,
dto,
resolveAuthUserId(user),
attachment,
);
}
@Post(':id/clearance/finalize-pre-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET finalizes import pre-clearance — unlocks Djibouti DO upload' })
finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) {
return this.clearanceService.finalizePreClearance(id);
}
@Post(':id/clearance/duty-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' })
uploadContractDutySlip(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.clearanceService.uploadDutySlip(id, file);
}
@Post(':id/clearance/transit-permit')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' })
uploadTransitPermit(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user));
}
@Post(':id/clearance/delivery-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
}
@Post(':id/clearance/release-order')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' })
uploadReleaseOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadReleaseOrder(
id,
file,
vesselDepartureDate,
resolveAuthUserId(user),
);
}
@Post(':id/clearance/ro-amendment')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' })
requestRoAmendment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RoAmendmentDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user));
}
@Post(':id/clearance/export-release')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL ET confirms export release after declaration' })
confirmExportRelease(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user));
}
@Post(':id/clearance/finalize-export-clearance')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({
summary: 'GL ET finalizes export clearance after post-booking transit permit upload',
})
finalizeExportClearance(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user));
}
@Get('clearance/et-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' })
etClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.etQueue(filter);
}
@Get('clearance/dj-queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' })
djClearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.djQueue(filter);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ─────── // ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue') @Get('clearance/ops-queue')
@@ -674,6 +861,18 @@ export class ContractsController {
}); });
} }
@Post('bookings/:bookingId/transport-document')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' })
uploadTransportDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
@Post('bookings/:bookingId/documents') @Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@@ -692,11 +891,16 @@ export class ContractsController {
@UseInterceptors(AnyFilesInterceptor()) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
uploadDutySlip( async uploadDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[], @UploadedFiles() files: Express.Multer.File[],
) { ) {
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); const file = (files ?? [])[0];
const booking = await this.bookingsService.findById(bookingId);
if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) {
return this.bookingClearanceService.uploadDutySlip(bookingId, file);
}
return this.glOperationsService.uploadDutySlip(bookingId, file);
} }
@Get('bookings/:bookingId/incidents') @Get('bookings/:bookingId/incidents')

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
@@ -18,6 +18,8 @@ import { ContractsRepository } from './contracts.repository';
import { ContractPricingService } from './contract-pricing.service'; import { ContractPricingService } from './contract-pricing.service';
import { ContractTransitionService } from './contract-transition.service'; import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
import { BookingClearanceService } from './booking-clearance.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ContractBookingService } from './contract-booking.service'; import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service'; import { GlOperationsService } from './gl-operations.service';
@@ -71,7 +73,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
CompaniesModule, CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the // BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
BookingsModule, forwardRef(() => BookingsModule),
ExchangeModule.forRootAsync({ ExchangeModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions => useFactory: (config: ConfigService): ExchangeOptions =>
@@ -85,6 +87,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractPricingService, ContractPricingService,
ContractTransitionService, ContractTransitionService,
ContractClearanceService, ContractClearanceService,
ClearanceWorkflowService,
BookingClearanceService,
ContractBookingService, ContractBookingService,
ClearanceMilestoneService, ClearanceMilestoneService,
GlOperationsService, GlOperationsService,
@@ -103,6 +107,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractPricingService, ContractPricingService,
ContractTransitionService, ContractTransitionService,
ContractClearanceService, ContractClearanceService,
ClearanceWorkflowService,
BookingClearanceService,
ContractBookingService, ContractBookingService,
ClearanceMilestoneService, ClearanceMilestoneService,
], ],

View File

@@ -518,7 +518,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
cycleId: string, cycleId: string,
status: string, status: string,
fields: Partial< fields: Partial<
Pick<ContractClearanceCycle, 'bookingId' | 'clearanceReadyAt' | 'completedAt'> Pick<
ContractClearanceCycle,
| 'bookingId'
| 'clearanceReadyAt'
| 'completedAt'
| 'dutyRequired'
| 'vesselDepartureDate'
| 'roAmendmentRequestedAt'
| 'roHoldReason'
| 'currentPhase'
>
> = {}, > = {},
): Promise<void> { ): Promise<void> {
await this.dataSource await this.dataSource
@@ -526,6 +536,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
.update(cycleId, { status, ...fields } as never); .update(cycleId, { status, ...fields } as never);
} }
async updateCycle(
cycleId: string,
fields: Partial<
Pick<
ContractClearanceCycle,
| 'dutyRequired'
| 'vesselDepartureDate'
| 'roAmendmentRequestedAt'
| 'roHoldReason'
| 'currentPhase'
| 'status'
| 'preClearanceFinalizedAt'
| 'completedAt'
>
>,
): Promise<void> {
await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never);
}
/** Link the GL-created booking to a clearance cycle. */ /** Link the GL-created booking to a clearance cycle. */
async linkBooking(cycleId: string, bookingId: string): Promise<void> { async linkBooking(cycleId: string, bookingId: string): Promise<void> {
await this.dataSource await this.dataSource

View File

@@ -200,9 +200,6 @@ export class ContractsService {
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
isHazardous: dto.isHazardous ?? false, isHazardous: dto.isHazardous ?? false,
isReefer: dto.isReefer ?? false, isReefer: dto.isReefer ?? false,
estimatedShipmentDate: dto.estimatedShipmentDate
? new Date(dto.estimatedShipmentDate)
: null,
contractType: dto.contractType ?? null, contractType: dto.contractType ?? null,
status: 'DRAFT', status: 'DRAFT',
clearanceStatus: 'NOT_APPLICABLE', clearanceStatus: 'NOT_APPLICABLE',
@@ -347,9 +344,6 @@ export class ContractsService {
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng,
contractType: dto.contractType ?? existing.contractType, contractType: dto.contractType ?? existing.contractType,
}; };
if (dto.estimatedShipmentDate) {
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
}
if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null;
// Customs clearing always mirrors the (possibly changed) service type. // Customs clearing always mirrors the (possibly changed) service type.

View File

@@ -101,6 +101,13 @@ export class CreateBulkLineDto {
@Min(0) @Min(0)
@Transform(({ value }) => Number(value)) @Transform(({ value }) => Number(value))
hazardousQuantity?: number; hazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
} }
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */ /** Shipment booking created under a contract (Path A customer, Path B GL ET). */

View File

@@ -4,7 +4,6 @@ import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsDateString,
IsIn, IsIn,
IsNumber, IsNumber,
IsOptional, IsOptional,
@@ -219,14 +218,6 @@ export class CreateContractDto {
@Transform(({ value }) => value === 'true' || value === true) @Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean; isReefer?: boolean;
@ApiPropertyOptional({
description: 'Non-binding estimate from the wizard (NOT validated against departures)',
example: '2026-07-15T00:00:00.000Z',
})
@IsOptional()
@IsDateString()
estimatedShipmentDate?: string;
@ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class AdviseContractDutyDto {
@ApiProperty({ description: 'Whether the customer must pay duty/tax' })
@IsBoolean()
dutyRequired!: boolean;
@ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' })
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
@ApiPropertyOptional({ default: 'ETB' })
@IsOptional()
@IsString()
currency?: string;
@ApiPropertyOptional({ description: 'Declaration / payment reference code' })
@IsOptional()
@IsString()
declarationSerial?: string;
}
export class ReleaseOrderDto {
@ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' })
@IsString()
vesselDepartureDate!: string;
}
export class RoAmendmentDto {
@ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' })
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -34,4 +34,25 @@ export class ContractClearanceCycle extends BaseEntity {
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) @Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt?: Date | null; completedAt?: Date | null;
/** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */
@Column({ name: 'duty_required', type: 'boolean', nullable: true })
dutyRequired?: boolean | null;
/** Export RO vessel departure date (Path B export). */
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
vesselDepartureDate?: string | null;
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
roAmendmentRequestedAt?: Date | null;
@Column({ name: 'ro_hold_reason', type: 'text', nullable: true })
roHoldReason?: string | null;
@Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true })
currentPhase?: string | null;
/** ET GL confirms import pre-clearance complete — unlocks Djibouti DO upload. */
@Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true })
preClearanceFinalizedAt?: Date | null;
} }

View File

@@ -8,6 +8,7 @@ import {
IncidentType, IncidentType,
} from './entities/clearance-incident.entity'; } from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { persistExportTransportUploads } from './phased-clearance.util';
/** /**
* Maps a GL post-booking document `code` to the milestone it auto-completes when * Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -21,6 +22,7 @@ const DOC_CODE_TO_MILESTONE: Record<string, string> = {
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
}; };
/** /**
@@ -158,4 +160,44 @@ export class GlOperationsService {
} }
return { uploaded: files.length, completedMilestones }; return { uploaded: files.length, completedMilestones };
} }
/**
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
*/
async uploadTransportDocument(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') {
throw new BadRequestException('Transport document upload applies to export shipments only.');
}
const milestones = await this.milestoneService.listForBooking(bookingId);
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
const wagonDone =
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
if (!wagonDone) {
throw new BadRequestException(
'Wagon must be allocated before the transport document can be uploaded.',
);
}
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
await persistExportTransportUploads(this.filesService, bookingId, files);
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
}
await this.milestoneService.completeByDocTrigger(
{ bookingId },
'EXPORT_TRANSPORT_ISSUED',
);
return { uploaded: true, milestoneCompleted: true };
}
} }

View File

@@ -0,0 +1,125 @@
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util';
describe('buildWorkflowFiles', () => {
const resourceFiles = [
{ code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' },
{ code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' },
{
code: 'transit_permitted',
id: 'f-transit',
name: 'transit.png',
url: '/files/transit',
},
{
code: 'duty_tax_notice',
id: 'f-duty',
name: 'notice.pdf',
url: '/files/duty',
},
{ code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' },
];
it('includes declaration and transit files even when they also appear in GL output document settings', () => {
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']),
);
});
it('includes multi-file declaration uploads alongside catalog codes', () => {
const result = buildWorkflowFiles(
[
...resourceFiles,
{
code: 'declaration_0',
id: 'f-dec-0',
name: 'decl-a.pdf',
url: '/files/decl-a',
},
{
code: 'declaration_1',
id: 'f-dec-1',
name: 'decl-b.pdf',
url: '/files/decl-b',
},
],
'IMPORT',
);
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']),
);
expect(result.find((f) => f.code === 'declaration_0')?.label).toBe(
'Declaration document 1',
);
});
it('includes multi-file import transit permit uploads', () => {
const result = buildWorkflowFiles(
[
...resourceFiles,
{
code: 'transit_permit_0',
id: 'f-tp-0',
name: 'permit-a.pdf',
url: '/files/tp-a',
},
{
code: 'transit_permit_1',
id: 'f-tp-1',
name: 'permit-b.pdf',
url: '/files/tp-b',
},
],
'IMPORT',
);
expect(result.map((f) => f.code)).toEqual(
expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']),
);
expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1');
});
it('does not include non-catalog customer document codes', () => {
const result = buildWorkflowFiles(resourceFiles, 'IMPORT');
expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false);
});
});
describe('belongsOnDjClearanceQueue', () => {
it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => {
expect(
belongsOnDjClearanceQueue(
'IMPORT',
{ preClearanceFinalizedAt: new Date('2026-01-01') },
[],
),
).toBe(true);
});
it('keeps contracts with completed Djibouti milestones', () => {
expect(
belongsOnDjClearanceQueue('IMPORT', null, [
{ ownerRegion: 'DJ', status: 'COMPLETED' },
]),
).toBe(true);
});
it('excludes import contracts still on Ethiopia-side clearance only', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
});
});
describe('belongsOnEtClearanceQueue', () => {
it('keeps contracts once phased clearance milestones exist', () => {
expect(
belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]),
).toBe(true);
});
it('excludes contracts with no clearance milestones', () => {
expect(belongsOnEtClearanceQueue([])).toBe(false);
});
});

View File

@@ -0,0 +1,319 @@
import { BadRequestException } from '@nestjs/common';
import {
catalogEntriesForTradeDirection,
declarationFileLabel,
isDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
exportTransportFileLabel,
transitPermitFileLabel,
type ClearanceWorkflowFile,
} from '@edr/types';
/** Require at least one declaration file in the upload batch. */
export function assertDeclarationFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No declaration documents uploaded');
}
}
/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */
export function normalizeDeclarationFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `declaration_${index}`,
}));
}
type DeclarationFileStore = {
findByResource(
resourceId: string,
resource: string,
): Promise<Array<{ code?: string | null }>>;
deleteByCode(resourceId: string, resource: string, code: string): Promise<void>;
upload(input: {
resourceId: string;
resource: string;
code: string;
file: Express.Multer.File;
}): Promise<unknown>;
};
/** Replace all declaration files on a resource with a new multi-file upload batch. */
export async function persistDeclarationUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeDeclarationFieldNames(files);
assertDeclarationFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isDeclarationFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `declaration_${index}`,
file,
}),
),
);
}
/** Require at least one transit permit file in the upload batch. */
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
}
/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */
export function normalizeTransitPermitFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `transit_permit_${index}`,
}));
}
/** Replace all import transit permit files on a resource with a new multi-file batch. */
export async function persistTransitPermitUploads(
store: DeclarationFileStore,
resourceId: string,
resource: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeTransitPermitFieldNames(files);
assertTransitPermitFiles(normalized);
const existing = await store.findByResource(resourceId, resource);
await Promise.all(
existing
.filter((f) => f.code && isImportTransitPermitFileCode(f.code))
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId,
resource,
code: `transit_permit_${index}`,
file,
}),
),
);
}
/** Require at least one export transport document in the upload batch. */
export function assertExportTransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No transit permit documents uploaded');
}
}
export function normalizeExportTransportFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `export_transport_document_${index}`,
}));
}
/** Replace all export transport documents on a booking with a new multi-file batch. */
export async function persistExportTransportUploads(
store: DeclarationFileStore,
bookingId: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeExportTransportFieldNames(files);
assertExportTransportFiles(normalized);
const existing = await store.findByResource(bookingId, 'bookings');
await Promise.all(
existing
.filter((f) => f.code && isExportTransportFileCode(f.code))
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId: bookingId,
resource: 'bookings',
code: `export_transport_document_${index}`,
file,
}),
),
);
}
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false;
return value === 'true' || value === '1';
}
type DjQueueMilestone = {
ownerRegion?: string | null;
status: string;
};
type DjQueueCycle = {
preClearanceFinalizedAt?: Date | null;
roHoldReason?: string | null;
} | null | undefined;
/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */
export function belongsOnDjClearanceQueue(
tradeDirection: string | null | undefined,
cycle: DjQueueCycle,
milestones: DjQueueMilestone[],
extras?: {
roHoldReason?: string | null;
preClearanceFinalizedAt?: Date | null;
},
): boolean {
const roHold = cycle?.roHoldReason ?? extras?.roHoldReason;
if (roHold) return true;
const hasDjActivity = milestones.some(
(m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'),
);
if (hasDjActivity) return true;
const preFinalized =
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
if (tradeDirection === 'IMPORT' && preFinalized) return true;
return false;
}
/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
'AWAITING_CLEARANCE_DOCUMENTS',
'CLEARANCE_UNDER_REVIEW',
'CLEARANCE_READY_FOR_BOOKING',
'ACTIVE_SHIPMENT_IN_PROGRESS',
'FULLY_EXECUTED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean {
return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED');
}
/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */
export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES;
/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */
export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
'AWAITING_DOCUMENTS',
'DOCUMENTS_UNDER_REVIEW',
'CLEARANCE_READY',
'FULLY_EXECUTED',
'OPERATION_REQUEST_PENDING',
'OPERATION_CHANGES_REQUESTED',
'ROAD_DISPATCH_PENDING',
'IN_TRANSIT',
'PAID',
'COMPLETED',
'CONTRACT_ACTIVE',
'CONTRACT_CLOSED',
] as const;
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES;
/** Build labeled phased-customs file rows from resource files. */
export function buildWorkflowFiles(
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
tradeDirection: string,
): ClearanceWorkflowFile[] {
const fileByCode = new Map(
files.filter((f) => f.code).map((f) => [f.code as string, f]),
);
const out: ClearanceWorkflowFile[] = [];
const included = new Set<string>();
for (const entry of catalogEntriesForTradeDirection(tradeDirection)) {
const file = fileByCode.get(entry.code) ?? null;
if (!file) continue;
included.add(entry.code);
out.push({
code: entry.code,
label: entry.label,
uploadedBy: entry.uploadedBy,
category: entry.category,
file: { id: file.id, name: file.name, url: file.url },
});
}
const extraDeclarations = files
.filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraDeclarations.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: declarationFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'declaration',
file: { id: file.id, name: file.name, url: file.url },
});
});
if (tradeDirection === 'IMPORT') {
const extraTransit = files
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraTransit.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: transitPermitFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'transit',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
if (tradeDirection === 'EXPORT') {
const extraExportTransport = files
.filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraExportTransport.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: exportTransportFileLabel(file.code, index),
uploadedBy: 'gl_et',
category: 'transit',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
return out;
}

View File

@@ -61,6 +61,14 @@ export class FilesService {
return this.upload(input); return this.upload(input);
} }
async deleteByCode(
resourceId: string,
resource: string,
code: string,
): Promise<void> {
await this.filesRepository.deleteByCode(resourceId, resource, code);
}
async uploadMany( async uploadMany(
resourceId: string, resourceId: string,
resource: string, resource: string,

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity'; import { FIRST_MILE_STATUSES, FirstMileStatus } from '../entities/first-mile.entity';
@@ -58,4 +58,9 @@ export class CreateFirstMileDto {
@Transform(({ value }) => (value === '' ? undefined : value)) @Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID() @IsUUID()
vehicleId?: string | null; vehicleId?: string | null;
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
@IsOptional()
@IsBoolean()
paid?: boolean;
} }

View File

@@ -35,6 +35,9 @@ export class FirstMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number; remainingPayment!: number;
@Column({ name: 'paid', type: 'boolean', default: false })
paid!: boolean;
// TODO: uncomment after migration creates column // TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false }) // @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean; // isPostPaymentCompleted!: boolean;

View File

@@ -48,7 +48,7 @@ export class FirstMileInvoiceService {
} }
// Fetch the booking to get the companyId and companyProfileId // Fetch the booking to get the companyId and companyProfileId
const fm = record.booking ? record : (await this.firstMileRepo.findById(record.bookingId, { relations: { booking: true } })); const fm = record.booking ? record : (await this.firstMileRepo.findById(record.id, { relations: { booking: true } }));
if (!fm) return null; if (!fm) return null;
if (!fm.booking?.companyId) { if (!fm.booking?.companyId) {
this.logger.warn( this.logger.warn(

View File

@@ -21,6 +21,9 @@ import { AllocateFirstMileContainersDto } from './dto/allocate-containers.dto';
import { FirstMileStatus } from './entities/first-mile.entity'; import { FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileService } from './first-mile.service'; import { FirstMileService } from './first-mile.service';
import { FirstMileInvoiceService } from './first-mile-invoice.service'; import { FirstMileInvoiceService } from './first-mile-invoice.service';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
import { Freight } from '@edr/types';
@ApiTags('first-mile') @ApiTags('first-mile')
@ApiBearerAuth() @ApiBearerAuth()
@@ -30,7 +33,9 @@ export class FirstMileController {
constructor( constructor(
private readonly firstMileService: FirstMileService, private readonly firstMileService: FirstMileService,
private readonly firstMileInvoiceService: FirstMileInvoiceService, private readonly firstMileInvoiceService: FirstMileInvoiceService,
) {} private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) { }
@Get() @Get()
@ApiOperation({ summary: 'List first-mile legs' }) @ApiOperation({ summary: 'List first-mile legs' })
@@ -80,7 +85,34 @@ export class FirstMileController {
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFirstMileDto) {
const record = await this.firstMileService.update(id, dto); const record = await this.firstMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated // Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { const booking = await this.bookingsService.findById(record.bookingId);
if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
await this.billingService.generateInvoice({
source: Freight.InvoiceSource.FirstMile,
sourceId: record.id,
type: "FIRST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
lines: [
{
chargeType: "FIRST_MILE",
description: "First Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.firstMileInvoiceService.ensureInvoiceFor(record); await this.firstMileInvoiceService.ensureInvoiceFor(record);
} }
return record; return record;

View File

@@ -16,7 +16,7 @@ import { FirstMileService } from './first-mile.service';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]), TypeOrmModule.forFeature([FirstMile, FirstMileContainerAllocation]),
BillingModule, forwardRef(() => BillingModule),
forwardRef(() => BookingsModule), forwardRef(() => BookingsModule),
VehiclesModule, VehiclesModule,
DriversModule, DriversModule,

View File

@@ -12,6 +12,8 @@ import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
import { FirstMile, FirstMileStatus } from './entities/first-mile.entity'; import { FirstMile, FirstMileStatus } from './entities/first-mile.entity';
import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity'; import { FirstMileContainerAllocation } from './entities/first-mile-container-allocation.entity';
import { FirstMileRepository } from './first-mile.repository'; import { FirstMileRepository } from './first-mile.repository';
import { OnEvent } from '@nestjs/event-emitter';
import { InvoiceEventPayload } from '../billing/billing.service';
type FirstMileListFilter = { type FirstMileListFilter = {
status?: FirstMileStatus; status?: FirstMileStatus;
@@ -146,6 +148,18 @@ export class FirstMileService {
}; };
} }
@OnEvent("firstmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.firstMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked first-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
} catch (err) {
this.logger.error(
`Failed to update first-mile payment status for record ${payload.sourceId}: ${String(err)}`,
);
}
}
async findById(id: string): Promise<FirstMile> { async findById(id: string): Promise<FirstMile> {
const record = await this.firstMileRepository.findById(id, { const record = await this.firstMileRepository.findById(id, {
relations: { relations: {
@@ -175,6 +189,7 @@ export class FirstMileService {
estimatedKm: dto.estimatedKm ?? null, estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null, exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null, vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
}); });
} }
@@ -207,6 +222,7 @@ export class FirstMileService {
async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> { async update(id: string, dto: UpdateFirstMileDto): Promise<FirstMile> {
const existing = await this.findById(id); const existing = await this.findById(id);
const dtoAny = dto as any;
const updated = await this.firstMileRepository.update(id, { const updated = await this.firstMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}),
@@ -215,7 +231,8 @@ export class FirstMileService {
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
}); ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
} as any);
if (!updated) { if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`); throw new NotFoundException(`First-mile record ${id} not found`);

View File

@@ -0,0 +1,40 @@
import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator';
import { PaymentMethod } from '../entities/fuel-purchase.entity';
export class CreateFuelPurchaseDto {
@IsUUID()
vehicleId!: string;
@IsDateString()
purchaseDate!: string;
@IsNumber()
liters!: number;
@IsNumber()
costPerLiter!: number;
@IsOptional()
@IsString()
fuelStation?: string;
@IsEnum(PaymentMethod)
@IsOptional()
paymentMethod?: PaymentMethod;
@IsOptional()
@IsNumber()
odometerReading?: number;
@IsOptional()
@IsUUID()
driverId?: string;
@IsOptional()
@IsString()
receiptNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,35 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
@Entity({ name: 'fuel_consumption', schema: 'freight' })
@Index(['vehicleId', 'month'])
export class FuelConsumption extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'month', type: 'date' })
month!: Date;
@Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 })
totalLiters!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 })
totalDistanceKm: number = 0;
@Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true })
fuelEfficiencyKmPerL?: number;
@Column({ name: 'number_of_purchases', type: 'integer', default: 0 })
numberOfPurchases!: number;
@Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true })
averageCostPerLiter?: number;
}

View File

@@ -0,0 +1,51 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum PaymentMethod {
CASH = 'CASH',
CARD = 'CARD',
FUEL_CARD = 'FUEL_CARD',
TRANSFER = 'TRANSFER',
CHEQUE = 'CHEQUE',
}
@Entity({ name: 'fuel_purchases', schema: 'freight' })
export class FuelPurchase extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'purchase_date', type: 'timestamptz' })
purchaseDate!: Date;
@Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 })
liters!: number;
@Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 })
costPerLiter!: number;
@Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 })
totalCost!: number;
@Column({ name: 'fuel_station', nullable: true })
fuelStation?: string;
@Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH })
paymentMethod!: PaymentMethod;
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
odometerReading?: number;
@Column({ name: 'driver_id', type: 'uuid', nullable: true })
driverId?: string;
@Column({ name: 'receipt_number', nullable: true })
receiptNumber?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,60 @@
import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { FuelService } from './fuel.service';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@ApiTags('Fuel Management')
@Controller('fuel')
export class FuelController {
constructor(private readonly fuelService: FuelService) {}
@Post('purchases')
@ApiOperation({ summary: 'Record fuel purchase' })
async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) {
return this.fuelService.recordFuelPurchase(dto);
}
@Get('purchases')
@ApiOperation({ summary: 'Get all fuel purchases' })
async getAllFuelPurchases() {
return this.fuelService.getAllFuelPurchases();
}
@Get('purchases/:vehicleId')
@ApiOperation({ summary: 'Get fuel purchases for vehicle' })
async getFuelPurchases(
@Param('vehicleId') vehicleId: string,
@Query('startDate') startDate: string,
@Query('endDate') endDate: string,
) {
return this.fuelService.getFuelPurchases(
vehicleId,
new Date(startDate),
new Date(endDate),
);
}
@Get('consumption/:vehicleId/:month')
@ApiOperation({ summary: 'Get monthly fuel consumption' })
async getMonthlyConsumption(
@Param('vehicleId') vehicleId: string,
@Param('month') month: string,
) {
return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month));
}
@Get('stats')
@ApiOperation({ summary: 'Get fleet-wide fuel statistics' })
async getFleetFuelStats(@Query('months') months: number = 12) {
return this.fuelService.getFleetFuelStats(months);
}
@Get('stats/:vehicleId')
@ApiOperation({ summary: 'Get fuel statistics for vehicle' })
async getVehicleFuelStats(
@Param('vehicleId') vehicleId: string,
@Query('months') months: number = 12,
) {
return this.fuelService.getVehicleFuelStats(vehicleId, months);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FuelController } from './fuel.controller';
import { FuelService } from './fuel.service';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Module({
imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])],
controllers: [FuelController],
providers: [FuelService, FuelRepository],
exports: [FuelService],
})
export class FuelModule {}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, Between } from 'typeorm';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
@Injectable()
export class FuelRepository extends BaseRepository<FuelPurchase> {
constructor(
@InjectRepository(FuelPurchase)
private readonly purchaseRepository: Repository<FuelPurchase>,
@InjectRepository(FuelConsumption)
private readonly consumptionRepository: Repository<FuelConsumption>,
) {
super(purchaseRepository);
}
async findByVehicleAndDateRange(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.purchaseRepository.find({
where: {
vehicleId,
purchaseDate: Between(startDate, endDate),
},
order: { purchaseDate: 'DESC' },
});
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
return this.consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
}
async updateMonthlyConsumption(
vehicleId: string,
month: Date,
data: Partial<FuelConsumption>,
): Promise<FuelConsumption> {
let consumption = await this.consumptionRepository.findOne({
where: {
vehicleId,
month,
},
});
if (!consumption) {
consumption = this.consumptionRepository.create({
vehicleId,
month,
...data,
});
} else {
Object.assign(consumption, data);
}
return this.consumptionRepository.save(consumption);
}
async findPurchasesByVehicle(vehicleId: string): Promise<FuelPurchase[]> {
return this.purchaseRepository.find({
where: { vehicleId },
order: { purchaseDate: 'DESC' },
});
}
}

View File

@@ -0,0 +1,121 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FuelRepository } from './fuel.repository';
import { FuelPurchase } from './entities/fuel-purchase.entity';
import { FuelConsumption } from './entities/fuel-consumption.entity';
import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto';
@Injectable()
export class FuelService {
constructor(
private readonly fuelRepository: FuelRepository,
@InjectRepository(FuelPurchase)
private readonly purchaseRepository: Repository<FuelPurchase>,
) {}
async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise<FuelPurchase> {
const totalCost = dto.liters * dto.costPerLiter;
const purchase = this.purchaseRepository.create({
...dto,
totalCost,
});
const saved = await this.purchaseRepository.save(purchase);
// Update monthly consumption
await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate));
return saved;
}
async getAllFuelPurchases(): Promise<FuelPurchase[]> {
return this.purchaseRepository
.createQueryBuilder('purchase')
.leftJoinAndSelect('purchase.vehicle', 'vehicle')
.orderBy('purchase.purchaseDate', 'DESC')
.getMany();
}
async getFuelPurchases(
vehicleId: string,
startDate: Date,
endDate: Date,
): Promise<FuelPurchase[]> {
return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate);
}
async getMonthlyConsumption(
vehicleId: string,
month: Date,
): Promise<FuelConsumption | null> {
return this.fuelRepository.getMonthlyConsumption(vehicleId, month);
}
async getFleetFuelStats(monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const purchases = await this.purchaseRepository
.createQueryBuilder('purchase')
.where('purchase.purchaseDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.getMany();
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
return {
totalPurchases: purchases.length,
totalLiters,
totalCost,
averagePricePerLiter: averagePrice,
averageEfficiency: 0, // Placeholder - would need distance data
dateRange: { startDate, endDate },
};
}
async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate);
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0;
return {
vehicleId,
totalPurchases: purchases.length,
totalLiters,
totalCost,
averagePricePerLiter: averagePrice,
dateRange: { startDate, endDate },
};
}
private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise<void> {
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1);
const purchases = await this.fuelRepository.findByVehicleAndDateRange(
vehicleId,
monthStart,
monthEnd,
);
const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0);
const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0);
const numberOfPurchases = purchases.length;
const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0;
await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, {
totalLiters,
totalCost,
numberOfPurchases,
averageCostPerLiter,
});
}
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; import { IsBoolean, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity'; import { LAST_MILE_STATUSES, LastMileStatus } from '../entities/last-mile.entity';
@@ -58,4 +58,9 @@ export class CreateLastMileDto {
@Transform(({ value }) => (value === '' ? undefined : value)) @Transform(({ value }) => (value === '' ? undefined : value))
@IsUUID() @IsUUID()
vehicleId?: string | null; vehicleId?: string | null;
@ApiPropertyOptional({ description: 'Invoice payment status', default: false })
@IsOptional()
@IsBoolean()
paid?: boolean;
} }

View File

@@ -35,6 +35,9 @@ export class LastMile extends BaseEntity {
@Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 }) @Column({ name: 'remaining_payment', type: 'numeric', precision: 14, scale: 2, default: 0 })
remainingPayment!: number; remainingPayment!: number;
@Column({ name: 'paid', type: 'boolean', default: false })
paid!: boolean;
// TODO: uncomment after migration creates column // TODO: uncomment after migration creates column
// @Column({ type: 'boolean', default: false }) // @Column({ type: 'boolean', default: false })
// isPostPaymentCompleted!: boolean; // isPostPaymentCompleted!: boolean;

View File

@@ -21,6 +21,9 @@ import { AllocateLastMileContainersDto } from './dto/allocate-containers.dto';
import { LastMileStatus } from './entities/last-mile.entity'; import { LastMileStatus } from './entities/last-mile.entity';
import { LastMileService } from './last-mile.service'; import { LastMileService } from './last-mile.service';
import { LastMileInvoiceService } from './last-mile-invoice.service'; import { LastMileInvoiceService } from './last-mile-invoice.service';
import { Freight } from '@edr/types';
import { BillingService } from '../billing/billing.service';
import { BookingsService } from '../bookings/bookings.service';
@ApiTags('last-mile') @ApiTags('last-mile')
@ApiBearerAuth() @ApiBearerAuth()
@@ -30,6 +33,8 @@ export class LastMileController {
constructor( constructor(
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly lastMileInvoiceService: LastMileInvoiceService, private readonly lastMileInvoiceService: LastMileInvoiceService,
private readonly billingService: BillingService,
private readonly bookingsService: BookingsService
) {} ) {}
@Get() @Get()
@@ -80,9 +85,36 @@ export class LastMileController {
async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) { async update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLastMileDto) {
const record = await this.lastMileService.update(id, dto); const record = await this.lastMileService.update(id, dto);
// Auto-generate invoice if distance or payment was updated // Auto-generate invoice if distance or payment was updated
if (dto.exactKm !== undefined || dto.remainingPayment !== undefined) { const booking = await this.bookingsService.findById(record.bookingId);
await this.lastMileInvoiceService.ensureInvoiceFor(record); if (dto.exactKm !== undefined || dto.exactKm != record.exactKm || dto.remainingPayment !== undefined || dto.remainingPayment !== record.remainingPayment) {
} await this.billingService.generateInvoice({
source: Freight.InvoiceSource.LastMile,
sourceId: record.id,
type: "LAST_MILE",
companyId: booking.companyId,
companyProfileId: booking.companyProfileId,
currency: "ETB",
lines: [
{
chargeType: "LAST_MILE",
description: "Last Mile Transportation Service",
quantity: 1,
unitRate: record.remainingPayment,
amount: record.remainingPayment,
currency: "ETB",
},
],
subtotalAmount: record.remainingPayment,
taxAmount: 0, // Replace if VAT/tax applies
totalAmount: record.remainingPayment,
dueInDays: 7,
status: Freight.InvoiceStatus.Pending,
});
await this.lastMileInvoiceService.ensureInvoiceFor(record);
}
return record; return record;
} }

View File

@@ -10,6 +10,8 @@ import { UpdateLastMileDto } from './dto/update-last-mile.dto';
import { LastMile, LastMileStatus } from './entities/last-mile.entity'; import { LastMile, LastMileStatus } from './entities/last-mile.entity';
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity'; import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
import { LastMileRepository } from './last-mile.repository'; import { LastMileRepository } from './last-mile.repository';
import { InvoiceEventPayload } from '../billing/billing.service';
import { OnEvent } from '@nestjs/event-emitter';
type LastMileListFilter = { type LastMileListFilter = {
status?: LastMileStatus; status?: LastMileStatus;
@@ -129,6 +131,12 @@ export class LastMileService {
} }
async create(dto: CreateLastMileDto): Promise<LastMile> { async create(dto: CreateLastMileDto): Promise<LastMile> {
const [existing] = await this.lastMileRepository.findAll({
where: { bookingId: dto.bookingId },
take: 1,
});
if (existing) return existing;
return this.lastMileRepository.create({ return this.lastMileRepository.create({
bookingId: dto.bookingId, bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT', status: dto.status ?? 'READY_TO_TRANSIT',
@@ -137,12 +145,26 @@ export class LastMileService {
estimatedKm: dto.estimatedKm ?? null, estimatedKm: dto.estimatedKm ?? null,
exactKm: dto.exactKm ?? null, exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null, vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
}); });
} }
@OnEvent("lastmile.invoice.paid")
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
try {
await this.lastMileRepository.update(payload.sourceId, { paid: true } as any);
this.logger.log(`Marked last-mile record ${payload.sourceId} as paid (invoice ${payload.invoiceId})`);
} catch (err) {
this.logger.error(
`Failed to update last-mile payment status for record ${payload.sourceId}: ${String(err)}`,
);
}
}
async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> { async update(id: string, dto: UpdateLastMileDto): Promise<LastMile> {
const existing = await this.findById(id); const existing = await this.findById(id);
const dtoAny = dto as any;
const updated = await this.lastMileRepository.update(id, { const updated = await this.lastMileRepository.update(id, {
...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}), ...(dto.bookingId !== undefined ? { bookingId: dto.bookingId } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}),
@@ -151,7 +173,8 @@ export class LastMileService {
...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}), ...(dto.estimatedKm !== undefined ? { estimatedKm: dto.estimatedKm } : {}),
...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}), ...(dto.exactKm !== undefined ? { exactKm: dto.exactKm } : {}),
...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}), ...(dto.vehicleId !== undefined ? { vehicleId: dto.vehicleId } : {}),
}); ...(dtoAny.paid !== undefined ? { paid: dtoAny.paid } : {}),
} as any);
if (!updated) { if (!updated) {
throw new NotFoundException(`Last-mile record ${id} not found`); throw new NotFoundException(`Last-mile record ${id} not found`);

View File

@@ -0,0 +1,87 @@
import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator';
import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity';
export class CreateMaintenanceScheduleDto {
@IsUUID()
vehicleId!: string;
@IsEnum(MaintenanceType)
maintenanceType!: MaintenanceType;
@IsString()
description!: string;
@IsDateString()
scheduledDate!: string;
@IsOptional()
@IsNumber()
estimatedCost?: number;
@IsOptional()
@IsString()
serviceProvider?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsNumber()
nextDueKm?: number;
@IsOptional()
@IsDateString()
nextDueDate?: string;
}
export class CreateMaintenanceCostDto {
@IsUUID()
vehicleId!: string;
@IsOptional()
@IsUUID()
maintenanceScheduleId?: string;
@IsDateString()
incurredDate!: string;
@IsNumber()
costAmount!: number;
@IsString()
costType!: string;
@IsString()
description!: string;
@IsOptional()
@IsString()
serviceProvider?: string;
@IsOptional()
@IsString()
invoiceNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateMaintenanceScheduleDto {
@IsOptional()
@IsEnum(MaintenanceStatus)
status?: MaintenanceStatus;
@IsOptional()
@IsDateString()
completedDate?: string;
@IsOptional()
@IsNumber()
actualCost?: number;
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,43 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
import { MaintenanceSchedule } from './maintenance-schedule.entity';
@Entity({ name: 'maintenance_costs', schema: 'freight' })
@Index(['vehicleId', 'incurredDate'])
export class MaintenanceCost extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true })
maintenanceScheduleId?: string;
@ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' })
@JoinColumn({ name: 'maintenance_schedule_id' })
maintenanceSchedule?: MaintenanceSchedule;
@Column({ name: 'incurred_date', type: 'timestamptz' })
incurredDate!: Date;
@Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 })
costAmount!: number;
@Column({ name: 'cost_type' })
costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER'
@Column({ name: 'description' })
description!: string;
@Column({ name: 'service_provider', nullable: true })
serviceProvider?: string;
@Column({ name: 'invoice_number', nullable: true })
invoiceNumber?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
}

View File

@@ -0,0 +1,65 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm';
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
export enum MaintenanceType {
PREVENTIVE = 'PREVENTIVE',
CORRECTIVE = 'CORRECTIVE',
INSPECTION = 'INSPECTION',
REPAIR = 'REPAIR',
}
export enum MaintenanceStatus {
SCHEDULED = 'SCHEDULED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
OVERDUE = 'OVERDUE',
}
@Entity({ name: 'maintenance_schedules', schema: 'freight' })
@Index(['vehicleId', 'scheduledDate'])
export class MaintenanceSchedule extends BaseEntity {
@Column({ name: 'vehicle_id', type: 'uuid' })
vehicleId!: string;
@ManyToOne(() => Vehicle, { eager: false })
@JoinColumn({ name: 'vehicle_id' })
vehicle!: Vehicle;
@Column({ name: 'maintenance_type', type: 'varchar' })
maintenanceType!: MaintenanceType;
@Column({ name: 'description' })
description!: string;
@Column({ name: 'scheduled_date', type: 'timestamptz' })
scheduledDate!: Date;
@Column({ name: 'completed_date', type: 'timestamptz', nullable: true })
completedDate?: Date;
@Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
estimatedCost?: number;
@Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true })
actualCost?: number;
@Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED })
status!: MaintenanceStatus;
@Column({ name: 'odometer_reading', type: 'numeric', nullable: true })
odometerReading?: number;
@Column({ name: 'service_provider', nullable: true })
serviceProvider?: string;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string;
@Column({ name: 'next_due_km', type: 'numeric', nullable: true })
nextDueKm?: number;
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
nextDueDate?: Date;
}

View File

@@ -0,0 +1,52 @@
import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { MaintenanceService } from './maintenance.service';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@ApiTags('Maintenance Management')
@Controller('maintenance')
export class MaintenanceController {
constructor(private readonly maintenanceService: MaintenanceService) {}
@Post('schedules')
@ApiOperation({ summary: 'Schedule maintenance' })
async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) {
return this.maintenanceService.scheduleMaintenanceAsync(dto);
}
@Post('costs')
@ApiOperation({ summary: 'Record maintenance cost' })
async recordCost(@Body() dto: CreateMaintenanceCostDto) {
return this.maintenanceService.recordMaintenanceCost(dto);
}
@Patch('schedules/:id')
@ApiOperation({ summary: 'Update maintenance schedule' })
async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) {
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
}
@Get('upcoming/:vehicleId')
@ApiOperation({ summary: 'Get upcoming maintenance' })
async getUpcoming(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getUpcomingMaintenance(vehicleId);
}
@Get('history/:vehicleId')
@ApiOperation({ summary: 'Get maintenance history' })
async getHistory(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getMaintenanceHistory(vehicleId);
}
@Get('stats')
@ApiOperation({ summary: 'Get fleet-wide maintenance statistics' })
async getFleetStats() {
return this.maintenanceService.getFleetMaintenanceStats();
}
@Get('stats/:vehicleId')
@ApiOperation({ summary: 'Get maintenance statistics' })
async getStats(@Param('vehicleId') vehicleId: string) {
return this.maintenanceService.getVehicleMaintenanceStats(vehicleId);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { MaintenanceService } from './maintenance.service';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceController } from './maintenance.controller';
@Module({
imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])],
providers: [MaintenanceService, MaintenanceRepository],
controllers: [MaintenanceController],
exports: [MaintenanceService],
})
export class MaintenanceModule {}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { BaseRepository } from '@edr/api-common';
import { Repository, Between } from 'typeorm';
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
@Injectable()
export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
constructor(
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
) {
super(scheduleRepository);
}
async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) {
const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000);
return this.scheduleRepository.find({
where: {
vehicleId,
scheduledDate: Between(new Date(), futureDate),
status: MaintenanceStatus.SCHEDULED,
},
order: { scheduledDate: 'ASC' },
});
}
async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) {
return this.costRepository.find({
where: {
vehicleId,
incurredDate: Between(startDate, endDate),
},
order: { incurredDate: 'DESC' },
});
}
async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) {
const result = await this.costRepository
.createQueryBuilder()
.select('SUM(cost_amount)', 'total')
.where('vehicle_id = :vehicleId', { vehicleId })
.andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
return result?.total || 0;
}
}

View File

@@ -0,0 +1,101 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MaintenanceRepository } from './maintenance.repository';
import { MaintenanceSchedule } from './entities/maintenance-schedule.entity';
import { MaintenanceCost } from './entities/maintenance-cost.entity';
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
@Injectable()
export class MaintenanceService {
constructor(
private readonly maintenanceRepository: MaintenanceRepository,
@InjectRepository(MaintenanceSchedule)
private readonly scheduleRepository: Repository<MaintenanceSchedule>,
@InjectRepository(MaintenanceCost)
private readonly costRepository: Repository<MaintenanceCost>,
) {}
async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise<MaintenanceSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined,
});
return this.scheduleRepository.save(schedule);
}
async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise<MaintenanceCost> {
const cost = this.costRepository.create({
...dto,
incurredDate: new Date(dto.incurredDate),
});
return this.costRepository.save(cost);
}
async updateMaintenanceSchedule(
id: string,
dto: UpdateMaintenanceScheduleDto,
): Promise<MaintenanceSchedule> {
await this.scheduleRepository.update(id, {
...dto,
completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined,
});
const updated = await this.scheduleRepository.findOneBy({ id });
return updated!;
}
async getUpcomingMaintenance(vehicleId: string) {
return this.maintenanceRepository.getUpcomingMaintenance(vehicleId);
}
async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
}
async getFleetMaintenanceStats(monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const costs = await this.costRepository
.createQueryBuilder('cost')
.where('cost.incurredDate BETWEEN :startDate AND :endDate', { startDate, endDate })
.getMany();
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
return {
totalCost,
numberOfMaintenanceItems: costs.length,
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
costByType: this.groupCostsByType(costs),
};
}
async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) {
const endDate = new Date();
const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1);
const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate);
const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0);
return {
vehicleId,
totalCost,
numberOfMaintenanceItems: costs.length,
averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0,
costByType: this.groupCostsByType(costs),
};
}
private groupCostsByType(costs: MaintenanceCost[]) {
const grouped: Record<string, number> = {};
costs.forEach((c) => {
if (!grouped[c.costType]) grouped[c.costType] = 0;
grouped[c.costType] += Number(c.costAmount);
});
return grouped;
}
}

View File

@@ -4,22 +4,22 @@ import {
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Post, Post,
UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { Public } from "@edr/api-common";
import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto"; import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payment.dto";
import { PaymentService } from "./payment.service"; import { PaymentService } from "./payment.service";
/** /**
* Consumer side of the payment microservice's outbox relay. * Consumer side of the payment microservice's outbox relay.
* Only the payment service may call this (shared SERVICE_AUTH_TOKEN). * WARNING: currently unauthenticated — anyone who can reach the API can mark
* payments as paid. Re-add ServiceAuthGuard before exposing beyond a trusted network.
* Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless. * Idempotent by design — the relay delivers at-least-once, so duplicates must be harmless.
* Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available; * Becomes a queue consumer via PaymentEventsConsumer when RabbitMQ is available;
* this HTTP endpoint remains as a transport-agnostic fallback. * this HTTP endpoint remains as a transport-agnostic fallback.
*/ */
@ApiTags("Internal Payments") @ApiTags("Internal Payments")
@UseGuards(ServiceAuthGuard) @Public()
@Controller("internal/payments") @Controller("internal/payments")
export class InternalPaymentController { export class InternalPaymentController {
constructor(private readonly paymentService: PaymentService) { } constructor(private readonly paymentService: PaymentService) { }

View File

@@ -13,6 +13,8 @@ import {
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
import { BillingModule } from "../billing/billing.module"; import { BillingModule } from "../billing/billing.module";
// import { FirstMileModule } from "../first-mile/first-mile.module";
// import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity";
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
import { PaymentEntity } from "./entities/payment.entity"; import { PaymentEntity } from "./entities/payment.entity";
@@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] {
HttpModule.register({ timeout: 10_000 }), HttpModule.register({ timeout: 10_000 }),
ConfigModule, ConfigModule,
forwardRef(() => BillingModule), forwardRef(() => BillingModule),
// forwardRef(() => TrainSchedulingModule),
// FirstMileModule,
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([
PaymentEntity, PaymentEntity,
PaymentWebhookEventEntity, PaymentWebhookEventEntity,

View File

@@ -15,9 +15,9 @@ export enum PaymentMethodTypeEnum {
} }
export class InitiatePaymentDto { export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" }) @ApiProperty({ example: "invoice-uuid" })
@IsString() @IsString()
bookingId!: string; invoiceId!: string;
@ApiProperty({ @ApiProperty({
enum: PaymentMethodTypeEnum, enum: PaymentMethodTypeEnum,

View File

@@ -1,19 +1,31 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator'; import {
ArrayMinSize,
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
export class CreateRouteMilestoneDto { export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
@IsUUID() @IsUUID()
yardId!: string; yardId!: string;
@ApiPropertyOptional({ description: 'Km from the previous stop (0 for origin)' })
@IsOptional()
@IsNumber()
@Min(0)
distanceKm?: number;
} }
export class CreateRouteDto { export class CreateRouteDto {
@ApiProperty()
@IsString()
@MaxLength(120)
name!: string;
@ApiProperty({ type: [CreateRouteMilestoneDto] }) @ApiProperty({ type: [CreateRouteMilestoneDto] })
@IsArray() @IsArray()
@ArrayMinSize(2) @ArrayMinSize(2)
@@ -21,8 +33,8 @@ export class CreateRouteDto {
@Type(() => CreateRouteMilestoneDto) @Type(() => CreateRouteMilestoneDto)
milestones!: CreateRouteMilestoneDto[]; milestones!: CreateRouteMilestoneDto[];
@ApiPropertyOptional() @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional() @IsOptional()
@IsBoolean() @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
isActive?: boolean; status?: RouteStatus;
} }

View File

@@ -1,16 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { IsEnum, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { RouteStatus } from '../entities/route.entity';
export class FilterRoutesDto { export class FilterRoutesDto {
@ApiPropertyOptional() @ApiPropertyOptional({ description: 'Search origin/destination yard codes or names' })
@IsOptional() @IsOptional()
@IsString() @IsString()
search?: string; search?: string;
@ApiPropertyOptional() @ApiPropertyOptional({ enum: ['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'] })
@IsOptional() @IsOptional()
@Transform(({ value }) => value === 'true' || value === true) @IsEnum(['AVAILABLE', 'MAINTENANCE', 'DAMAGED', 'STOP_WORKING'])
@IsBoolean() status?: RouteStatus;
isActive?: boolean;
} }

View File

@@ -23,4 +23,8 @@ export class RouteMilestone extends BaseEntity {
@Column({ name: 'sequence_no', type: 'int' }) @Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number; sequenceNo!: number;
/** Kilometres from the previous stop (0 for origin). */
@Column({ name: 'distance_km', type: 'decimal', precision: 10, scale: 2, nullable: true })
distanceKm?: number | null;
} }

View File

@@ -4,13 +4,11 @@ import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm
import { Yard } from '../../rule-engine/entities/yard.entity'; import { Yard } from '../../rule-engine/entities/yard.entity';
import { RouteMilestone } from './route-milestone.entity'; import { RouteMilestone } from './route-milestone.entity';
@Entity({ schema: 'freight', name: 'routes' }) export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
@Index(['name'])
@Index(['isActive'])
export class Route extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
name!: string;
@Entity({ schema: 'freight', name: 'routes' })
@Index(['status'])
export class Route extends BaseEntity {
@Column({ name: 'origin_yard_id', type: 'uuid' }) @Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string; originYardId!: string;
@@ -25,9 +23,24 @@ export class Route extends BaseEntity {
@JoinColumn({ name: 'destination_yard_id' }) @JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard; destinationYard?: Yard;
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
isActive!: boolean; status!: RouteStatus;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[]; milestones?: RouteMilestone[];
} }
export function formatRouteLabel(route: {
originYard?: { code?: string; name?: string } | null;
destinationYard?: { code?: string; name?: string } | null;
}): string {
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
return `${origin}${dest}`;
}
export function totalRouteDistanceKm(
milestones: Array<{ distanceKm?: number | string | null }>,
): number {
return milestones.reduce((sum, m) => sum + Number(m.distanceKm ?? 0), 0);
}

View File

@@ -1,12 +1,12 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, ILike } from 'typeorm'; import { DataSource } from 'typeorm';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto'; import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto'; import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity'; import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity'; import { formatRouteLabel, Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository'; import { RoutesRepository } from './routes.repository';
@Injectable() @Injectable()
@@ -16,11 +16,10 @@ export class RoutesService {
private readonly routesRepository: RoutesRepository, private readonly routesRepository: RoutesRepository,
) {} ) {}
findAll(filter: FilterRoutesDto): Promise<Route[]> { async findAll(filter: FilterRoutesDto): Promise<Route[]> {
return this.routesRepository.findAll({ const routes = await this.routesRepository.findAll({
where: { where: {
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}), ...(filter.status ? { status: filter.status } : {}),
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
}, },
relations: { relations: {
originYard: true, originYard: true,
@@ -28,10 +27,33 @@ export class RoutesService {
milestones: { yard: true }, milestones: { yard: true },
}, },
order: { order: {
name: 'ASC',
milestones: { sequenceNo: 'ASC' }, milestones: { sequenceNo: 'ASC' },
}, },
}); });
const sorted = [...routes].sort((a, b) =>
formatRouteLabel(a).localeCompare(formatRouteLabel(b)),
);
const query = filter.search?.trim().toLowerCase();
if (!query) return sorted;
return sorted.filter((route) => {
const haystack = [
formatRouteLabel(route),
route.originYard?.label,
route.originYard?.code,
route.destinationYard?.label,
route.destinationYard?.code,
...(route.milestones ?? []).map(
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
} }
async findById(id: string): Promise<Route> { async findById(id: string): Promise<Route> {
@@ -53,16 +75,14 @@ export class RoutesService {
} }
async create(dto: CreateRouteDto): Promise<Route> { async create(dto: CreateRouteDto): Promise<Route> {
await this.validateRouteName(dto.name);
const validated = await this.validateMilestones(dto.milestones); const validated = await this.validateMilestones(dto.milestones);
const route = await this.dataSource.transaction(async (manager) => { const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save( const savedRoute = await manager.getRepository(Route).save(
manager.getRepository(Route).create({ manager.getRepository(Route).create({
name: dto.name.trim(),
originYardId: validated.originYardId, originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId, destinationYardId: validated.destinationYardId,
isActive: dto.isActive ?? true, status: dto.status ?? 'AVAILABLE',
}), }),
); );
@@ -72,6 +92,7 @@ export class RoutesService {
routeId: savedRoute.id, routeId: savedRoute.id,
yardId: milestone.yardId, yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo, sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}), }),
), ),
); );
@@ -85,20 +106,16 @@ export class RoutesService {
async update(id: string, dto: UpdateRouteDto): Promise<Route> { async update(id: string, dto: UpdateRouteDto): Promise<Route> {
const existing = await this.findById(id); const existing = await this.findById(id);
if (dto.name && dto.name.trim() !== existing.name) {
await this.validateRouteName(dto.name, id);
}
const milestoneInput = dto.milestones const milestoneInput = dto.milestones
? await this.validateMilestones(dto.milestones) ? await this.validateMilestones(dto.milestones)
: null; : null;
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, { await manager.getRepository(Route).update(id, {
name: dto.name?.trim() ?? existing.name,
originYardId: milestoneInput?.originYardId ?? existing.originYardId, originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId, destinationYardId:
isActive: dto.isActive ?? existing.isActive, milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(dto.status !== undefined ? { status: dto.status } : {}),
}); });
if (milestoneInput) { if (milestoneInput) {
@@ -109,6 +126,7 @@ export class RoutesService {
routeId: id, routeId: id,
yardId: milestone.yardId, yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo, sequenceNo: milestone.sequenceNo,
distanceKm: milestone.distanceKm,
}), }),
), ),
); );
@@ -120,7 +138,9 @@ export class RoutesService {
async deactivate(id: string): Promise<Route> { async deactivate(id: string): Promise<Route> {
await this.findById(id); await this.findById(id);
const updated = await this.routesRepository.update(id, { isActive: false }); const updated = await this.routesRepository.update(id, {
status: 'STOP_WORKING',
} as never);
if (!updated) { if (!updated) {
throw new NotFoundException(`Route ${id} not found`); throw new NotFoundException(`Route ${id} not found`);
@@ -129,27 +149,32 @@ export class RoutesService {
return this.findById(id); return this.findById(id);
} }
private async validateRouteName(name: string, routeId?: string) { private async validateMilestones(
const trimmedName = name.trim(); milestones: Array<{ yardId: string; distanceKm?: number }>,
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } }); ) {
if (existing && existing.id !== routeId) {
throw new ConflictException(`Route name ${trimmedName} already exists`);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) { if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards'); throw new BadRequestException('A route requires at least two yards');
} }
const normalized = milestones.map((milestone, index) => ({ const normalized = milestones.map((milestone, index) => {
yardId: milestone.yardId, const distanceKm =
sequenceNo: index + 1, index === 0 ? 0 : milestone.distanceKm != null ? milestone.distanceKm : null;
})); if (index > 0 && (distanceKm == null || distanceKm < 0)) {
throw new BadRequestException(
`Enter segment KM for stop ${index + 1} (from previous yard).`,
);
}
return {
yardId: milestone.yardId,
sequenceNo: index + 1,
distanceKm,
};
});
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))]; const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) }); const yards = await this.dataSource
.getRepository(Yard)
.find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id)); const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) { for (const milestone of normalized) {

View File

@@ -21,11 +21,6 @@ export class CreateCargoTypeDto {
@IsUUID() @IsUUID()
parentGroupId?: string; parentGroupId?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
showFreeTextBox?: boolean;
@ApiPropertyOptional({ default: false }) @ApiPropertyOptional({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()

View File

@@ -17,9 +17,6 @@ export class CargoType extends BaseEntity {
@Column({ name: 'parent_group_id', type: 'uuid', nullable: true }) @Column({ name: 'parent_group_id', type: 'uuid', nullable: true })
parentGroupId?: string | null; parentGroupId?: string | null;
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
showFreeTextBox!: boolean;
/** /**
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
* (break-bulk). Nullable for container/legacy cargo, which is counted by * (break-bulk). Nullable for container/legacy cargo, which is counted by

View File

@@ -331,16 +331,14 @@ export class RuleEngineService {
): Promise<BookingApprovalStep[]> { ): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules(); await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = options.freightType === 'BULK'; let requiresDirectorApproval = false;
if (options.cargoTypeId) { if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId); const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) { if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`); throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
} }
if (cargoType.requiresDirectorApproval) { requiresDirectorApproval = cargoType.requiresDirectorApproval;
requiresDirectorApproval = true;
}
} }
const chain = await this.approvalRulesRepo.findChainForCargo( const chain = await this.approvalRulesRepo.findChainForCargo(

View File

@@ -79,7 +79,6 @@ export class CargoTypesService {
code, code,
cargoTypeName: dto.cargoTypeName, cargoTypeName: dto.cargoTypeName,
parentGroupId: dto.parentGroupId ?? null, parentGroupId: dto.parentGroupId ?? null,
showFreeTextBox: dto.showFreeTextBox ?? false,
requiresDirectorApproval: dto.requiresDirectorApproval ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null, unitOfMeasure: dto.unitOfMeasure ?? null,

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