mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'alpha' of github.com:Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -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",
|
||||||
@@ -32,7 +33,8 @@
|
|||||||
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
"iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert",
|
||||||
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
"iam:migration:show": "pnpm run iam:typeorm:cli migration:show",
|
||||||
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
"iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js",
|
||||||
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts"
|
"migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts",
|
||||||
|
"script": "ts-node -r tsconfig-paths/register src/scripts/main.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
@@ -84,13 +86,15 @@
|
|||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/pg": "^8.6.7",
|
"@types/pg": "^8.6.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"@types/vorpal": "^1.12.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4",
|
||||||
|
"vorpal": "^1.12.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: '—',
|
||||||
|
|||||||
@@ -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()' },
|
||||||
|
|||||||
@@ -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);`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the `EXPIRED` invoice status. An invoice expires when its source's pay
|
||||||
|
* window closes before settlement (e.g. a booking whose `paymentDeadline`
|
||||||
|
* lapses) — driven event-style from the domain via `BillingService.expirePayable`,
|
||||||
|
* which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out
|
||||||
|
* of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and
|
||||||
|
* `OVERDUE` (still payable).
|
||||||
|
*
|
||||||
|
* Matches Freight.InvoiceStatus in packages/types. ADD VALUE only — additive and
|
||||||
|
* not referenced in this same transaction, so it is PG 12+ safe.
|
||||||
|
*/
|
||||||
|
export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface {
|
||||||
|
name = "AddExpiredInvoiceStatus1830000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Postgres cannot drop individual enum values; EXPIRED is left on
|
||||||
|
// freight.invoices_status_enum (harmless, unused after down).
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, Param, ParseUUIDPipe } 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 { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
@@ -7,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) { }
|
||||||
|
|
||||||
@@ -21,4 +23,26 @@ export class BillingController {
|
|||||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.billingService.findById(id);
|
return this.billingService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("invoices/:id/document")
|
||||||
|
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||||
|
async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.billingService.document(id);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("invoices/:id/receipt")
|
||||||
|
@ApiOperation({ summary: "Download the sealed payment receipt PDF" })
|
||||||
|
async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.billingService.receipt(id);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stream a generated PDF as a file download. */
|
||||||
|
export function sendPdf(res: Response, filename: string, buffer: Buffer): void {
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader("Content-Length", buffer.length);
|
||||||
|
res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -151,16 +150,22 @@ export class BillingService {
|
|||||||
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
/** Sealed PDF invoice for any source, rendered by the shared document service. */
|
||||||
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async document(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "INVOICE"));
|
return this.invoiceDocuments.render(
|
||||||
|
this.toDocumentModel(invoice, "INVOICE"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sealed PDF receipt; available once any payment has been recorded. */
|
/** Sealed PDF receipt; available once any payment has been recorded. */
|
||||||
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const invoice = await this.findById(id);
|
const invoice = await this.findById(id);
|
||||||
if (Number(invoice.paidAmount) <= 0) {
|
if (Number(invoice.paidAmount) <= 0) {
|
||||||
throw new BadRequestException("A receipt is available only after payment is recorded.");
|
throw new BadRequestException(
|
||||||
|
"A receipt is available only after payment is recorded.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.invoiceDocuments.render(this.toDocumentModel(invoice, "RECEIPT"));
|
return this.invoiceDocuments.render(
|
||||||
|
this.toDocumentModel(invoice, "RECEIPT"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
/** Map a global invoice (+ lines) onto the source-agnostic document model. */
|
||||||
@@ -177,7 +182,11 @@ export class BillingService {
|
|||||||
if (Number(invoice.taxAmount) > 0) {
|
if (Number(invoice.taxAmount) > 0) {
|
||||||
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
totals.push({ label: "Tax", amount: Number(invoice.taxAmount) });
|
||||||
}
|
}
|
||||||
totals.push({ label: "Total", amount: Number(invoice.totalAmount), grand: true });
|
totals.push({
|
||||||
|
label: "Total",
|
||||||
|
amount: Number(invoice.totalAmount),
|
||||||
|
grand: true,
|
||||||
|
});
|
||||||
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
totals.push({ label: "Paid", amount: Number(invoice.paidAmount) });
|
||||||
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) });
|
||||||
|
|
||||||
@@ -193,8 +202,18 @@ export class BillingService {
|
|||||||
{ label: "Type", value: invoice.type },
|
{ label: "Type", value: invoice.type },
|
||||||
{ label: "Reference", value: invoice.sourceId },
|
{ label: "Reference", value: invoice.sourceId },
|
||||||
{ label: "Currency", value: invoice.currency },
|
{ label: "Currency", value: invoice.currency },
|
||||||
{ label: "Issued", value: invoice.issuedAt ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") : null },
|
{
|
||||||
{ label: "Due", value: invoice.dueAt ? new Date(invoice.dueAt).toLocaleDateString("en-GB") : null },
|
label: "Issued",
|
||||||
|
value: invoice.issuedAt
|
||||||
|
? new Date(invoice.issuedAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Due",
|
||||||
|
value: invoice.dueAt
|
||||||
|
? new Date(invoice.dueAt).toLocaleDateString("en-GB")
|
||||||
|
: null,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
categoryHeader: "Charge type",
|
categoryHeader: "Charge type",
|
||||||
lines: invoice.lines.map((l) => ({
|
lines: invoice.lines.map((l) => ({
|
||||||
@@ -221,19 +240,33 @@ export class BillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every invoice billed to a company, newest first, with billing relations. */
|
/**
|
||||||
findByCompany(companyId: string): Promise<Invoice[]> {
|
* Every invoice billed to a company, newest first, with billing relations.
|
||||||
|
* Optionally narrow to a single source record (e.g. a booking's invoices) via
|
||||||
|
* `{ source, sourceId }`.
|
||||||
|
*/
|
||||||
|
findByCompany(
|
||||||
|
companyId: string,
|
||||||
|
filter: { source?: string; sourceId?: string } = {},
|
||||||
|
): Promise<Invoice[]> {
|
||||||
return this.invoices.findAll({
|
return this.invoices.findAll({
|
||||||
where: { companyId },
|
where: {
|
||||||
|
companyId,
|
||||||
|
...(filter.source ? { source: filter.source } : {}),
|
||||||
|
...(filter.sourceId ? { sourceId: filter.sourceId } : {}),
|
||||||
|
},
|
||||||
relations: { company: true, companyProfile: true },
|
relations: { company: true, companyProfile: true },
|
||||||
order: { createdAt: "DESC" },
|
order: { createdAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Invoices for the signed-in customer; empty when they have no company. */
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
async findForUser(userId: string): Promise<Invoice[]> {
|
async findForUser(
|
||||||
|
userId: string,
|
||||||
|
filter: { source?: string; sourceId?: string } = {},
|
||||||
|
): Promise<Invoice[]> {
|
||||||
const companyId = await this.resolveCompanyId(userId);
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
return companyId ? this.findByCompany(companyId) : [];
|
return companyId ? this.findByCompany(companyId, filter) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||||
@@ -251,27 +284,43 @@ 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). */
|
||||||
);
|
async documentForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
await this.findByIdForUser(id, userId);
|
||||||
|
return this.document(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */
|
||||||
|
async receiptForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
await this.findByIdForUser(id, userId);
|
||||||
|
return this.receipt(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generation ───────────────────────────────────────────────────────────────
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
/** `<CODE>-YYYYMMDD-00001` — sequential per day & prefix, within the active transaction. */
|
||||||
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
private nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
return nextDailyInvoiceNumber(mg, { table: "freight.invoices", code:"INV" });
|
return nextDailyInvoiceNumber(mg, {
|
||||||
|
table: "freight.invoices",
|
||||||
|
code: "INV",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,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);
|
||||||
}
|
}
|
||||||
@@ -319,8 +369,7 @@ export class BillingService {
|
|||||||
input.subtotalAmount ??
|
input.subtotalAmount ??
|
||||||
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||||
const taxAmount = input.taxAmount ?? 0;
|
const taxAmount = input.taxAmount ?? 0;
|
||||||
const totalAmount =
|
const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
||||||
input.totalAmount ?? round2(subtotalAmount + taxAmount);
|
|
||||||
|
|
||||||
const dueAt =
|
const dueAt =
|
||||||
input.dueAt ??
|
input.dueAt ??
|
||||||
@@ -368,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),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,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,
|
||||||
@@ -406,69 +523,76 @@ export class BillingService {
|
|||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
): Promise<Invoice> {
|
): Promise<Invoice> {
|
||||||
if (!(input.amount > 0)) {
|
if (!(input.amount > 0)) {
|
||||||
throw new BadRequestException("Payment amount must be greater than zero.");
|
throw new BadRequestException(
|
||||||
|
"Payment amount must be greater than zero.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
const patch = {
|
||||||
Invoice,
|
|
||||||
{ id: invoice.id },
|
|
||||||
{
|
|
||||||
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,
|
||||||
@@ -480,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,
|
||||||
@@ -497,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,
|
||||||
@@ -514,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. */
|
||||||
@@ -548,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,
|
||||||
@@ -568,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" },
|
||||||
@@ -576,74 +726,130 @@ 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`.
|
* Expire a source's currently-open invoice (its pay window closed before
|
||||||
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
* settlement), then emit `${source}.invoice.expired`. Resolves the open invoice
|
||||||
* No-op (returns null) when the source has no paid invoice.
|
* and transitions it to EXPIRED — a terminal, non-payable status (kept out of
|
||||||
|
* `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice
|
||||||
|
* (already paid/cancelled/expired).
|
||||||
*
|
*
|
||||||
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
* Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in
|
||||||
* to enlist in its DB transaction.
|
* the batch engine) to enlist in its DB transaction.
|
||||||
*/
|
*/
|
||||||
async refundPayable(
|
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: Freight.InvoiceStatus.Paid },
|
where: {
|
||||||
|
source,
|
||||||
|
sourceId,
|
||||||
|
status: In(OPEN_STATUSES),
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
if (!invoice) return null;
|
if (!invoice) return null;
|
||||||
|
|
||||||
return this.markInvoiceAsRefunded(invoice.id, mg);
|
return this.transition(
|
||||||
|
invoice.id,
|
||||||
|
Freight.InvoiceStatus.Expired,
|
||||||
|
"expired",
|
||||||
|
{},
|
||||||
|
mg,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a source's open invoice `dueAt` to its real pay-window deadline. The
|
||||||
|
* booking invoice is generated before the pay window opens (at booking
|
||||||
|
* creation/approval), so its printed due date is refreshed when the batch engine
|
||||||
|
* sets `paymentDeadline`. No-op when the source has no open invoice.
|
||||||
|
*/
|
||||||
|
async syncPayableDueDate(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
dueAt: Date,
|
||||||
|
type?: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: {
|
||||||
|
source,
|
||||||
|
sourceId,
|
||||||
|
status: In(OPEN_STATUSES),
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
},
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return;
|
||||||
|
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(
|
||||||
|
invoiceId: string,
|
||||||
|
status: Freight.InvoiceStatus,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: { id: invoiceId, status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]) },
|
||||||
|
});
|
||||||
|
if (!invoice) return;
|
||||||
|
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";
|
||||||
@@ -652,13 +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(`No open invoice to charge for ${source}:${sourceId}`);
|
throw new NotFoundException(
|
||||||
|
`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
|
||||||
@@ -667,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",
|
||||||
@@ -702,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) },
|
||||||
@@ -711,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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -5,14 +5,18 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import type { Response } from "express";
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from "../../common/resolve-auth-user-id";
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { sendPdf } from "./billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||||
|
|
||||||
@@ -29,8 +33,15 @@ export class PortalBillingController {
|
|||||||
|
|
||||||
@Get("my-invoices")
|
@Get("my-invoices")
|
||||||
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||||
findMine(@CurrentUser() user: AuthUserPayload) {
|
findMine(
|
||||||
return this.billingService.findForUser(resolveAuthUserId(user));
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Query("source") source?: string,
|
||||||
|
@Query("sourceId") sourceId?: string,
|
||||||
|
) {
|
||||||
|
return this.billingService.findForUser(resolveAuthUserId(user), {
|
||||||
|
source,
|
||||||
|
sourceId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("my-invoices/:id")
|
@Get("my-invoices/:id")
|
||||||
@@ -42,6 +53,34 @@ export class PortalBillingController {
|
|||||||
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("my-invoices/:id/document")
|
||||||
|
@ApiOperation({ summary: "Download one of the customer's invoice PDFs" })
|
||||||
|
async document(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.billingService.documentForUser(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("my-invoices/:id/receipt")
|
||||||
|
@ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" })
|
||||||
|
async receipt(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.billingService.receiptForUser(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
sendPdf(res, filename, buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Post("my-invoices/:id/pay")
|
@Post("my-invoices/:id/pay")
|
||||||
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||||
pay(
|
pay(
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
import {
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
BadRequestException,
|
||||||
import { Freight } from '@edr/types';
|
forwardRef,
|
||||||
import { DataSource } from 'typeorm';
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { OnEvent } from "@nestjs/event-emitter";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import { DataSource, EntityManager } from "typeorm";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BillingService,
|
BillingService,
|
||||||
GenerateInvoiceInput,
|
GenerateInvoiceInput,
|
||||||
InvoiceEventPayload,
|
InvoiceEventPayload,
|
||||||
InvoiceLineInput,
|
InvoiceLineInput,
|
||||||
} from '../billing/billing.service';
|
} from "../billing/billing.service";
|
||||||
import { Invoice } from '../billing/entities/invoice.entity';
|
import { Invoice } from "../billing/entities/invoice.entity";
|
||||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
import { FirstMileService } from "../first-mile/first-mile.service";
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from "./bookings.repository";
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from "./entities/booking.entity";
|
||||||
|
|
||||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||||
interface StoredPricingBreakdown {
|
interface StoredPricingBreakdown {
|
||||||
@@ -23,6 +29,12 @@ interface StoredPricingBreakdown {
|
|||||||
currency?: string;
|
currency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvoiceOptions {
|
||||||
|
dueDate?: Date;
|
||||||
|
invoiceType?: string;
|
||||||
|
invoiceStatus?: Freight.InvoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
/** Round to 2 decimals, avoiding binary float drift. */
|
/** Round to 2 decimals, avoiding binary float drift. */
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
@@ -52,32 +64,28 @@ 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(booking: Booking): Promise<Invoice | null> {
|
async ensureInvoiceForBooking(
|
||||||
|
booking: Booking,
|
||||||
|
invoiceOptions: InvoiceOptions = {},
|
||||||
|
): Promise<Invoice> {
|
||||||
const existing = await this.billing.findPayable(
|
const existing = await this.billing.findPayable(
|
||||||
Freight.InvoiceSource.Booking,
|
Freight.InvoiceSource.Booking,
|
||||||
booking.id,
|
booking.id,
|
||||||
Freight.InvoiceType.Prepaid,
|
"PREPAID",
|
||||||
);
|
);
|
||||||
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.`,
|
||||||
);
|
);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = this.buildInput(booking);
|
const input = this.buildInput(booking, invoiceOptions);
|
||||||
if (!input) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.billing.generateInvoice(input);
|
return this.billing.generateInvoice(input);
|
||||||
}
|
}
|
||||||
@@ -87,10 +95,10 @@ export class BookingInvoiceService {
|
|||||||
* reactions live here (not in the payment process): each invoice type advances
|
* reactions live here (not in the payment process): each invoice type advances
|
||||||
* the booking its own way. Only PREPAID exists today.
|
* the booking its own way. Only PREPAID exists today.
|
||||||
*/
|
*/
|
||||||
@OnEvent('booking.invoice.paid')
|
@OnEvent("booking.invoice.paid")
|
||||||
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case Freight.InvoiceType.Prepaid:
|
case "PREPAID":
|
||||||
await this.advanceBookingOnPayment(payload.sourceId);
|
await this.advanceBookingOnPayment(payload.sourceId);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -100,6 +108,14 @@ export class BookingInvoiceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
* of payment, relocated out of the payment service: the booking becomes PAID
|
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||||
@@ -114,16 +130,18 @@ export class BookingInvoiceService {
|
|||||||
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
this.logger.warn(
|
||||||
|
`Cannot advance unknown booking ${bookingId} on payment.`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (booking.paymentStatus === 'PAID') return;
|
if (booking.paymentStatus === "PAID") return;
|
||||||
|
|
||||||
await this.dataSource.transaction(async (mg) => {
|
await this.dataSource.transaction(async (mg) => {
|
||||||
await mg.update(
|
await mg.update(
|
||||||
Booking,
|
Booking,
|
||||||
{ id: bookingId },
|
{ id: bookingId },
|
||||||
{ paymentStatus: 'PAID', status: 'PAID' },
|
{ paymentStatus: "PAID", status: "PAID" },
|
||||||
);
|
);
|
||||||
await this.firstMile.acceptBooking(bookingId);
|
await this.firstMile.acceptBooking(bookingId);
|
||||||
});
|
});
|
||||||
@@ -138,9 +156,13 @@ export class BookingInvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Map a booking's pricing snapshot into a generic invoice request. */
|
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||||
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
private buildInput(
|
||||||
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
booking: Booking,
|
||||||
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
invoiceOptions: InvoiceOptions = {},
|
||||||
|
): GenerateInvoiceInput {
|
||||||
|
const breakdown = (booking.pricingBreakdown ??
|
||||||
|
{}) as StoredPricingBreakdown;
|
||||||
|
const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB";
|
||||||
|
|
||||||
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||||
chargeType: l.code,
|
chargeType: l.code,
|
||||||
@@ -155,10 +177,14 @@ 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) return null;
|
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",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitRate: amount,
|
unitRate: amount,
|
||||||
amount,
|
amount,
|
||||||
@@ -166,7 +192,9 @@ export class BookingInvoiceService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
const subtotal = round2(
|
||||||
|
lines.reduce((sum, l) => sum + Number(l.amount), 0),
|
||||||
|
);
|
||||||
let totalAmount = subtotal;
|
let totalAmount = subtotal;
|
||||||
|
|
||||||
// Honor a staff price override: bill the adjusted total, recording the delta
|
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||||
@@ -176,8 +204,8 @@ export class BookingInvoiceService {
|
|||||||
const delta = round2(Number(adjusted) - subtotal);
|
const delta = round2(Number(adjusted) - subtotal);
|
||||||
if (delta !== 0) {
|
if (delta !== 0) {
|
||||||
lines.push({
|
lines.push({
|
||||||
chargeType: 'ADJUSTMENT',
|
chargeType: "ADJUSTMENT",
|
||||||
description: 'Staff price adjustment',
|
description: "Staff price adjustment",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitRate: delta,
|
unitRate: delta,
|
||||||
amount: delta,
|
amount: delta,
|
||||||
@@ -190,12 +218,14 @@ export class BookingInvoiceService {
|
|||||||
return {
|
return {
|
||||||
source: Freight.InvoiceSource.Booking,
|
source: Freight.InvoiceSource.Booking,
|
||||||
sourceId: booking.id,
|
sourceId: booking.id,
|
||||||
type: Freight.InvoiceType.Prepaid,
|
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId,
|
companyProfileId: booking.companyProfileId,
|
||||||
currency,
|
currency,
|
||||||
lines,
|
lines,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
|
dueAt: invoiceOptions.dueDate,
|
||||||
|
type: invoiceOptions.invoiceType ?? "PREPAID",
|
||||||
|
status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
} 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';
|
||||||
@@ -15,7 +15,6 @@ 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 { BookingInvoiceService } from './booking-invoice.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';
|
||||||
@@ -25,32 +24,47 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
|
|||||||
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||||
|
import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service';
|
||||||
|
import { ContractDocPhase } from '@edr/types';
|
||||||
|
|
||||||
|
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
import { BookingInvoiceService } from "./booking-invoice.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingTransitionService {
|
export class BookingTransitionService {
|
||||||
private readonly logger = new Logger(BookingTransitionService.name);
|
private readonly logger = new Logger(BookingTransitionService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly pricingService: BookingPricingService,
|
private readonly pricingService: BookingPricingService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
private readonly invoiceService: BookingInvoiceService,
|
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
@Inject(forwardRef(() => BookingsService))
|
@Inject(forwardRef(() => BookingsService))
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
|
@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);
|
||||||
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
|
||||||
|
|
||||||
if (Number(booking.totalAmount) <= 0) {
|
if (Number(booking.totalAmount) <= 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Generate a price before submitting (POST /bookings/:id/generate-price)',
|
"Generate a price before submitting (POST /bookings/:id/generate-price)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +83,8 @@ export class BookingTransitionService {
|
|||||||
totalAmount?: number;
|
totalAmount?: number;
|
||||||
} | null;
|
} | null;
|
||||||
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
const unchanged = this.pricingService.pricesMatch(stored, computed);
|
||||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
const priorityScore =
|
||||||
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||||
|
|
||||||
if (unchanged) {
|
if (unchanged) {
|
||||||
await this.pricingService.createPricingSnapshots(
|
await this.pricingService.createPricingSnapshots(
|
||||||
@@ -79,7 +94,7 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'SUBMITTED',
|
status: "SUBMITTED",
|
||||||
priorityScore,
|
priorityScore,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
@@ -109,7 +124,7 @@ export class BookingTransitionService {
|
|||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
status: 'PRICE_CHANGED_PENDING_CONFIRM',
|
status: "PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const updatedBooking = await this.bookingsService.findById(bookingId);
|
const updatedBooking = await this.bookingsService.findById(bookingId);
|
||||||
@@ -121,16 +136,17 @@ export class BookingTransitionService {
|
|||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
message: 'Price has changed since preview. Confirm to submit with the updated price.',
|
message:
|
||||||
|
"Price has changed since preview. Confirm to submit with the updated price.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
|
assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]);
|
||||||
|
|
||||||
if (Number(booking.totalAmount) <= 0) {
|
if (Number(booking.totalAmount) <= 0) {
|
||||||
throw new BadRequestException('No price to confirm');
|
throw new BadRequestException("No price to confirm");
|
||||||
}
|
}
|
||||||
|
|
||||||
const computed = await this.pricingService.computePriceForBooking(booking);
|
const computed = await this.pricingService.computePriceForBooking(booking);
|
||||||
@@ -149,9 +165,10 @@ export class BookingTransitionService {
|
|||||||
computed.appliedModifiers,
|
computed.appliedModifiers,
|
||||||
);
|
);
|
||||||
|
|
||||||
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
const priorityScore =
|
||||||
|
await this.pricingService.computeSubmitPriorityScore(booking);
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'SUBMITTED',
|
status: "SUBMITTED",
|
||||||
priorityScore,
|
priorityScore,
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
pricingBreakdown: {
|
pricingBreakdown: {
|
||||||
@@ -173,7 +190,7 @@ export class BookingTransitionService {
|
|||||||
totalAmount: Number(finalBooking.totalAmount),
|
totalAmount: Number(finalBooking.totalAmount),
|
||||||
currency: finalBooking.paymentCurrency,
|
currency: finalBooking.paymentCurrency,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
message: 'Booking submitted with confirmed price.',
|
message: "Booking submitted with confirmed price.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,17 +200,17 @@ export class BookingTransitionService {
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['SUBMITTED']);
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
note,
|
note,
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'CHANGES_REQUESTED',
|
status: "CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
@@ -203,7 +220,7 @@ export class BookingTransitionService {
|
|||||||
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
if ((booking.approvalSteps?.length ?? 0) > 0) return;
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||||
cargoTypeId: booking.cargoTypeId,
|
cargoTypeId: booking.cargoTypeId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -217,14 +234,14 @@ export class BookingTransitionService {
|
|||||||
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
// Only SUBMITTED bookings are acceptable. A booking that still needs
|
||||||
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
// consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and
|
||||||
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
// is therefore never offered for accept until a partner moves it to SUBMITTED.
|
||||||
assertBookingStatus(booking, ['SUBMITTED']);
|
assertBookingStatus(booking, ["SUBMITTED"]);
|
||||||
|
|
||||||
// The backoffice must define how long the accepted contract stays valid.
|
// The backoffice must define how long the accepted contract stays valid.
|
||||||
// Without a window the contract has no end date and cannot be relied on, so
|
// Without a window the contract has no end date and cannot be relied on, so
|
||||||
// accept is blocked until a positive number of days is supplied.
|
// accept is blocked until a positive number of days is supplied.
|
||||||
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
if (!Number.isInteger(validityDays) || validityDays < 1) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'A contract validity (in days) is required to accept this booking.',
|
"A contract validity (in days) is required to accept this booking.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,12 +251,12 @@ export class BookingTransitionService {
|
|||||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||||
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
freightType: booking.freightType as "CONTAINER" | "BULK",
|
||||||
cargoTypeId: booking.cargoTypeId,
|
cargoTypeId: booking.cargoTypeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'PENDING_APPROVAL',
|
status: "PENDING_APPROVAL",
|
||||||
approvedByStaffId: actorId,
|
approvedByStaffId: actorId,
|
||||||
approvedByStaffAt: validFrom,
|
approvedByStaffAt: validFrom,
|
||||||
contractValidityDays: validityDays,
|
contractValidityDays: validityDays,
|
||||||
@@ -255,17 +272,17 @@ export class BookingTransitionService {
|
|||||||
actorId: string,
|
actorId: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]);
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
reason,
|
reason,
|
||||||
'REJECTION',
|
"REJECTION",
|
||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'REJECTED',
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
@@ -283,8 +300,8 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
let booking = await this.bookingsService.findById(bookingId);
|
let booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'PENDING_APPROVAL',
|
"PENDING_APPROVAL",
|
||||||
'APPROVED_PENDING_SIGNATURE',
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
if ((booking.approvalSteps?.length ?? 0) === 0) {
|
||||||
@@ -296,14 +313,17 @@ export class BookingTransitionService {
|
|||||||
bookingId,
|
bookingId,
|
||||||
stepId,
|
stepId,
|
||||||
);
|
);
|
||||||
if (!step || step.status !== 'PENDING') {
|
if (!step || step.status !== "PENDING") {
|
||||||
throw new BadRequestException('Approval step not found or already actioned');
|
throw new BadRequestException(
|
||||||
|
"Approval step not found or already actioned",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
const next =
|
||||||
|
await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||||
if (!next || next.id !== step.id) {
|
if (!next || next.id !== step.id) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Approval steps must be completed in order',
|
"Approval steps must be completed in order",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,29 +335,36 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
const blocksRole = step.blocksRole;
|
const blocksRole = step.blocksRole;
|
||||||
if (blocksRole && blocksRole === requiredRole) {
|
if (blocksRole && blocksRole === requiredRole) {
|
||||||
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
throw new BadRequestException(
|
||||||
|
`Role ${requiredRole} is blocked for this step`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
await this.bookingsRepository.completeApprovalStep(
|
||||||
|
step.id,
|
||||||
|
actorId,
|
||||||
|
"APPROVED",
|
||||||
|
);
|
||||||
|
|
||||||
const updates: Record<string, unknown> = {};
|
const updates: Record<string, unknown> = {};
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
if (requiredRole === 'LINE_STAFF') {
|
if (requiredRole === "LINE_STAFF") {
|
||||||
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
updates.status = "APPROVED_PENDING_SIGNATURE";
|
||||||
updates.approvedByStaffId = actorId;
|
updates.approvedByStaffId = actorId;
|
||||||
updates.approvedByStaffAt = now;
|
updates.approvedByStaffAt = now;
|
||||||
} else if (requiredRole === 'DIRECTOR') {
|
} else if (requiredRole === "DIRECTOR") {
|
||||||
updates.signedByDirectorId = actorId;
|
updates.signedByDirectorId = actorId;
|
||||||
updates.signedByDirectorAt = now;
|
updates.signedByDirectorAt = now;
|
||||||
} else if (requiredRole === 'CEO') {
|
} else if (requiredRole === "CEO") {
|
||||||
updates.signedByCeoId = actorId;
|
updates.signedByCeoId = actorId;
|
||||||
updates.signedByCeoAt = now;
|
updates.signedByCeoAt = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
const allDone =
|
||||||
|
await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
updates.status = 'APPROVED';
|
updates.status = "APPROVED";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
if (Object.keys(updates).length > 0) {
|
||||||
@@ -359,90 +386,64 @@ export class BookingTransitionService {
|
|||||||
reason: string,
|
reason: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
assertBookingStatus(booking, [
|
||||||
|
"PENDING_APPROVAL",
|
||||||
|
"APPROVED_PENDING_SIGNATURE",
|
||||||
|
]);
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findApprovalStepById(
|
const step = await this.bookingsRepository.findApprovalStepById(
|
||||||
bookingId,
|
bookingId,
|
||||||
stepId,
|
stepId,
|
||||||
);
|
);
|
||||||
if (!step) throw new BadRequestException('Approval step not found');
|
if (!step) throw new BadRequestException("Approval step not found");
|
||||||
|
|
||||||
await this.bookingsRepository.completeApprovalStep(
|
await this.bookingsRepository.completeApprovalStep(
|
||||||
step.id,
|
step.id,
|
||||||
actorId,
|
actorId,
|
||||||
'REJECTED',
|
"REJECTED",
|
||||||
reason,
|
reason,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
reason,
|
reason,
|
||||||
'REJECTION',
|
"REJECTION",
|
||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'REJECTED',
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async customerSign(bookingId: string): Promise<Booking> {
|
async customerSign(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['CONTRACT_READY']);
|
assertBookingStatus(booking, ["CONTRACT_READY"]);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'SIGNED_CUSTOMER',
|
status: "SIGNED_CUSTOMER",
|
||||||
customerSignedAt: new Date(),
|
customerSignedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
|
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
|
||||||
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
|
||||||
status: 'FULLY_EXECUTED',
|
|
||||||
fullyExecutedAt: new Date(),
|
|
||||||
marketingApprovedById: actorId,
|
|
||||||
marketingApprovedAt: new Date(),
|
|
||||||
lockedAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
|
|
||||||
const executed = await this.bookingsService.findById(updated!.id);
|
|
||||||
|
|
||||||
// Billable state reached — generate the invoice payment will settle.
|
|
||||||
// Non-blocking: a billing hiccup must not undo the execution.
|
|
||||||
await this.invoiceService
|
|
||||||
.ensureInvoiceForBooking(executed)
|
|
||||||
.catch((err) =>
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to generate invoice for booking ${executed.reference}: ${
|
|
||||||
err instanceof Error ? err.message : String(err)
|
|
||||||
}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return executed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async startTransit(bookingId: string): Promise<Booking> {
|
async startTransit(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['PAID']);
|
assertBookingStatus(booking, ["PAID"]);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'IN_TRANSIT',
|
status: "IN_TRANSIT",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(bookingId: string): Promise<Booking> {
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['IN_TRANSIT']);
|
assertBookingStatus(booking, ["IN_TRANSIT"]);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'COMPLETED',
|
status: "COMPLETED",
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
@@ -451,22 +452,23 @@ export class BookingTransitionService {
|
|||||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'DRAFT',
|
"DRAFT",
|
||||||
'SUBMITTED',
|
"SUBMITTED",
|
||||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
'PENDING_APPROVAL',
|
"PENDING_APPROVAL",
|
||||||
'CONTRACT_READY',
|
"CONTRACT_READY",
|
||||||
|
"OPERATION_REQUEST_PENDING",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
reason,
|
reason,
|
||||||
'REJECTION',
|
"REJECTION",
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'CANCELLED',
|
status: "CANCELLED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
@@ -479,20 +481,20 @@ export class BookingTransitionService {
|
|||||||
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
async reject(bookingId: string, reason?: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, [
|
assertBookingStatus(booking, [
|
||||||
'DRAFT',
|
"DRAFT",
|
||||||
'SUBMITTED',
|
"SUBMITTED",
|
||||||
'PRICE_CHANGED_PENDING_CONFIRM',
|
"PRICE_CHANGED_PENDING_CONFIRM",
|
||||||
'PENDING_CONSOLIDATION',
|
"PENDING_CONSOLIDATION",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
reason?.trim() || 'Customer rejected the price estimate.',
|
reason?.trim() || "Customer rejected the price estimate.",
|
||||||
'REJECTION',
|
"REJECTION",
|
||||||
);
|
);
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(bookingId, {
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'REJECTED',
|
status: "REJECTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
return this.bookingsService.findById(updated!.id);
|
||||||
}
|
}
|
||||||
@@ -513,32 +515,44 @@ export class BookingTransitionService {
|
|||||||
fileKey: string;
|
fileKey: string;
|
||||||
label: string;
|
label: string;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
uploadedBy: 'customer' | 'gl';
|
uploadedBy: "customer" | "gl";
|
||||||
settingCode: string;
|
settingCode: string;
|
||||||
file: { id: string; name: string; url: string } | null;
|
file: { id: string; name: string; url: string } | null;
|
||||||
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
|
reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null;
|
||||||
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);
|
||||||
|
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
const files = await this.filesService.findByResource(bookingId, "bookings");
|
||||||
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
const fileByCode = new Map(files.map((f) => [f.code, f]));
|
||||||
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
|
const reviews =
|
||||||
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||||
const reviewByKey = new Map(
|
const reviewByKey = new Map(
|
||||||
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const documents: Awaited<
|
const documents: Awaited<
|
||||||
ReturnType<BookingTransitionService['getClearanceView']>
|
ReturnType<BookingTransitionService["getClearanceView"]>
|
||||||
>['documents'] = [];
|
>["documents"] = [];
|
||||||
|
|
||||||
const pushSetting = async (
|
const pushSetting = async (
|
||||||
code: string | null,
|
code: string | null,
|
||||||
uploadedBy: 'customer' | 'gl',
|
uploadedBy: "customer" | "gl",
|
||||||
) => {
|
) => {
|
||||||
if (!code) return;
|
if (!code) return;
|
||||||
let setting;
|
let setting;
|
||||||
@@ -556,28 +570,26 @@ export class BookingTransitionService {
|
|||||||
required: field.isRequired,
|
required: field.isRequired,
|
||||||
uploadedBy,
|
uploadedBy,
|
||||||
settingCode: code,
|
settingCode: code,
|
||||||
file: file
|
file: file ? { id: file.id, name: file.name, url: file.url } : null,
|
||||||
? { id: file.id, name: file.name, url: file.url }
|
|
||||||
: null,
|
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await pushSetting(inputCode, 'customer');
|
await pushSetting(inputCode, "customer");
|
||||||
await pushSetting(outputCode, 'gl');
|
await pushSetting(outputCode, "gl");
|
||||||
|
|
||||||
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
// Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set.
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f.code?.startsWith('custom_')) continue;
|
if (!f.code?.startsWith("custom_")) continue;
|
||||||
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
const review = reviewByKey.get(`custom:${f.code}`) ?? null;
|
||||||
documents.push({
|
documents.push({
|
||||||
fileKey: f.code,
|
fileKey: f.code,
|
||||||
label: f.name,
|
label: f.name,
|
||||||
required: false,
|
required: false,
|
||||||
uploadedBy: 'customer',
|
uploadedBy: "customer",
|
||||||
settingCode: 'custom',
|
settingCode: "custom",
|
||||||
file: { id: f.id, name: f.name, url: f.url },
|
file: { id: f.id, name: f.name, url: f.url },
|
||||||
reviewStatus: review?.status ?? null,
|
reviewStatus: review?.status ?? null,
|
||||||
note: review?.note ?? null,
|
note: review?.note ?? null,
|
||||||
@@ -611,13 +623,15 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||||
if (required.length === 0) return true;
|
if (required.length === 0) return true;
|
||||||
const reviews = await this.bookingsRepository.findDocumentReviews(booking.id);
|
const reviews = await this.bookingsRepository.findDocumentReviews(
|
||||||
|
booking.id,
|
||||||
|
);
|
||||||
return required.every((field) =>
|
return required.every((field) =>
|
||||||
reviews.some(
|
reviews.some(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.settingCode === inputCode &&
|
r.settingCode === inputCode &&
|
||||||
r.fileKey === field.fileKey &&
|
r.fileKey === field.fileKey &&
|
||||||
r.status === 'APPROVED',
|
r.status === "APPROVED",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -632,33 +646,38 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']);
|
assertBookingStatus(booking, [
|
||||||
|
"AWAITING_DOCUMENTS",
|
||||||
|
"DOCUMENTS_UNDER_REVIEW",
|
||||||
|
]);
|
||||||
const { inputCode } = clearanceCodesForBooking(booking);
|
const { inputCode } = clearanceCodesForBooking(booking);
|
||||||
if (!inputCode) {
|
if (!inputCode) {
|
||||||
throw new BadRequestException('This booking has no document-clearance step');
|
throw new BadRequestException(
|
||||||
|
"This booking has no document-clearance step",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No documents uploaded');
|
throw new BadRequestException("No documents uploaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
// First submission (nothing in review yet): every required input field must
|
// First submission (nothing in review yet): every required input field must
|
||||||
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
// be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer
|
||||||
// is only fixing queried/pending docs, so the already-uploaded required docs
|
// is only fixing queried/pending docs, so the already-uploaded required docs
|
||||||
// stay in place and we don't re-gate on the full required set.
|
// stay in place and we don't re-gate on the full required set.
|
||||||
if (booking.status === 'AWAITING_DOCUMENTS') {
|
if (booking.status === "AWAITING_DOCUMENTS") {
|
||||||
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
await this.assertRequiredInputsPresent(bookingId, inputCode, files);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const record = await this.filesService.upsertByCode({
|
const record = await this.filesService.upsertByCode({
|
||||||
resourceId: bookingId,
|
resourceId: bookingId,
|
||||||
resource: 'bookings',
|
resource: "bookings",
|
||||||
code: file.fieldname,
|
code: file.fieldname,
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
// Ad-hoc docs (custom_*) are not part of the required gate; still tracked.
|
||||||
const settingCode = file.fieldname.startsWith('custom_')
|
const settingCode = file.fieldname.startsWith("custom_")
|
||||||
? 'custom'
|
? "custom"
|
||||||
: inputCode;
|
: inputCode;
|
||||||
await this.bookingsRepository.upsertDocumentReviewPending({
|
await this.bookingsRepository.upsertDocumentReviewPending({
|
||||||
bookingId,
|
bookingId,
|
||||||
@@ -669,8 +688,20 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,7 +725,10 @@ export class BookingTransitionService {
|
|||||||
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
const required = (setting.fields ?? []).filter((f) => f.isRequired);
|
||||||
if (required.length === 0) return;
|
if (required.length === 0) return;
|
||||||
|
|
||||||
const existing = await this.filesService.findByResource(bookingId, 'bookings');
|
const existing = await this.filesService.findByResource(
|
||||||
|
bookingId,
|
||||||
|
"bookings",
|
||||||
|
);
|
||||||
const presentKeys = new Set<string>([
|
const presentKeys = new Set<string>([
|
||||||
...existing.map((f) => f.code),
|
...existing.map((f) => f.code),
|
||||||
...files.map((f) => f.fieldname),
|
...files.map((f) => f.fieldname),
|
||||||
@@ -702,7 +736,7 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
const missing = required.filter((f) => !presentKeys.has(f.fileKey));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
const labels = missing.map((f) => f.fileLabel).join(', ');
|
const labels = missing.map((f) => f.fileLabel).join(", ");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Please upload all required documents before submitting: ${labels}`,
|
`Please upload all required documents before submitting: ${labels}`,
|
||||||
);
|
);
|
||||||
@@ -713,22 +747,36 @@ export class BookingTransitionService {
|
|||||||
async reviewDocument(
|
async reviewDocument(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
status: 'APPROVED' | 'QUERIED',
|
status: "APPROVED" | "QUERIED",
|
||||||
staffId: string,
|
staffId: string,
|
||||||
note?: string,
|
note?: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||||
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
const { inputCode, outputCode } = clearanceCodesForBooking(booking);
|
||||||
|
|
||||||
const existing = await this.bookingsRepository.findDocumentReviews(bookingId);
|
const existing =
|
||||||
|
await this.bookingsRepository.findDocumentReviews(bookingId);
|
||||||
const match = existing.find((r) => r.fileKey === fileKey);
|
const match = existing.find((r) => r.fileKey === fileKey);
|
||||||
const settingCode =
|
const settingCode =
|
||||||
match?.settingCode ??
|
match?.settingCode ??
|
||||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
(fileKey.startsWith("custom_")
|
||||||
|
? "custom"
|
||||||
|
: (inputCode ?? outputCode ?? "custom"));
|
||||||
|
|
||||||
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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(
|
||||||
@@ -739,15 +787,37 @@ export class BookingTransitionService {
|
|||||||
staffId,
|
staffId,
|
||||||
note,
|
note,
|
||||||
);
|
);
|
||||||
if (status === 'QUERIED') {
|
if (status === "QUERIED") {
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
`Document "${fileKey}" queried: ${note}`,
|
`Document "${fileKey}" queried: ${note}`,
|
||||||
'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.). */
|
||||||
@@ -756,18 +826,20 @@ export class BookingTransitionService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]);
|
||||||
const { outputCode } = clearanceCodesForBooking(booking);
|
const { outputCode } = clearanceCodesForBooking(booking);
|
||||||
if (!outputCode) {
|
if (!outputCode) {
|
||||||
throw new BadRequestException('This booking has no customs output documents');
|
throw new BadRequestException(
|
||||||
|
"This booking has no customs output documents",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
throw new BadRequestException('No documents uploaded');
|
throw new BadRequestException("No documents uploaded");
|
||||||
}
|
}
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await this.filesService.upsertByCode({
|
await this.filesService.upsertByCode({
|
||||||
resourceId: bookingId,
|
resourceId: bookingId,
|
||||||
resource: 'bookings',
|
resource: "bookings",
|
||||||
code: file.fieldname,
|
code: file.fieldname,
|
||||||
file,
|
file,
|
||||||
});
|
});
|
||||||
@@ -781,19 +853,28 @@ 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);
|
||||||
|
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']);
|
assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']);
|
||||||
|
|
||||||
const approved = await this.isClearanceFullyApproved(booking);
|
const approved = await this.isClearanceFullyApproved(booking);
|
||||||
if (!approved) {
|
if (!approved) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'All required documents must be approved before clearance can be finalized',
|
"All required documents must be approved before clearance can be finalized",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { outputCode } = clearanceCodesForBooking(booking);
|
const { outputCode } = clearanceCodesForBooking(booking);
|
||||||
if (outputCode) {
|
if (outputCode) {
|
||||||
const setting = await this.fileUploadSettingsService.getByCode(outputCode);
|
const setting =
|
||||||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
await this.fileUploadSettingsService.getByCode(outputCode);
|
||||||
|
const files = await this.filesService.findByResource(
|
||||||
|
bookingId,
|
||||||
|
"bookings",
|
||||||
|
);
|
||||||
const uploaded = new Set(files.map((f) => f.code));
|
const uploaded = new Set(files.map((f) => f.code));
|
||||||
const missing = (setting.fields ?? []).filter(
|
const missing = (setting.fields ?? []).filter(
|
||||||
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
(f) => f.isRequired && !uploaded.has(f.fileKey),
|
||||||
@@ -802,13 +883,13 @@ export class BookingTransitionService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Upload all required customs output documents first: ${missing
|
`Upload all required customs output documents first: ${missing
|
||||||
.map((m) => m.fileLabel)
|
.map((m) => m.fileLabel)
|
||||||
.join(', ')}`,
|
.join(", ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'CLEARANCE_READY',
|
status: "CLEARANCE_READY",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -827,11 +908,14 @@ export class BookingTransitionService {
|
|||||||
scheduledDate: string,
|
scheduledDate: string,
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']);
|
assertBookingStatus(booking, [
|
||||||
|
"CLEARANCE_READY",
|
||||||
|
"OPERATION_CHANGES_REQUESTED",
|
||||||
|
]);
|
||||||
|
|
||||||
const date = new Date(scheduledDate);
|
const date = new Date(scheduledDate);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
throw new BadRequestException('A valid schedule date is required');
|
throw new BadRequestException("A valid schedule date is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
// The binding shipment day must have at least one OPEN departure on the
|
// The binding shipment day must have at least one OPEN departure on the
|
||||||
@@ -844,12 +928,12 @@ export class BookingTransitionService {
|
|||||||
);
|
);
|
||||||
if (!hasDeparture) {
|
if (!hasDeparture) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'No departures available on the selected day for this route',
|
"No departures available on the selected day for this route",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'OPERATION_REQUEST_PENDING',
|
status: "OPERATION_REQUEST_PENDING",
|
||||||
scheduledDate: date,
|
scheduledDate: date,
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
@@ -865,27 +949,27 @@ export class BookingTransitionService {
|
|||||||
*/
|
*/
|
||||||
async reviewOperationRequest(
|
async reviewOperationRequest(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
decision: 'ACCEPT' | 'REQUEST_CHANGES',
|
decision: "ACCEPT" | "REQUEST_CHANGES",
|
||||||
actorId: string,
|
actorId: string,
|
||||||
options: { note?: string } = {},
|
options: { note?: string } = {},
|
||||||
): Promise<Booking> {
|
): Promise<Booking> {
|
||||||
const booking = await this.bookingsService.findById(bookingId);
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
|
assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]);
|
||||||
|
|
||||||
if (decision === 'REQUEST_CHANGES') {
|
if (decision === "REQUEST_CHANGES") {
|
||||||
if (!options.note?.trim()) {
|
if (!options.note?.trim()) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'A note is required when requesting changes',
|
"A note is required when requesting changes",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.bookingsRepository.createReviewNote(
|
await this.bookingsRepository.createReviewNote(
|
||||||
bookingId,
|
bookingId,
|
||||||
options.note,
|
options.note,
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
actorId,
|
actorId,
|
||||||
);
|
);
|
||||||
await this.bookingsRepository.update(bookingId, {
|
await this.bookingsRepository.update(bookingId, {
|
||||||
status: 'OPERATION_CHANGES_REQUESTED',
|
status: "OPERATION_CHANGES_REQUESTED",
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
@@ -907,9 +991,17 @@ export class BookingTransitionService {
|
|||||||
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
|
this.logger.log(
|
||||||
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
|
||||||
|
);
|
||||||
|
await this.invoiceService.updateStatus(
|
||||||
|
invoice.id,
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
);
|
||||||
if (isRoadService(booking.serviceType)) {
|
if (isRoadService(booking.serviceType)) {
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
status: 'ROAD_DISPATCH_PENDING',
|
status: "ROAD_DISPATCH_PENDING",
|
||||||
fullyExecutedAt: now,
|
fullyExecutedAt: now,
|
||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
@@ -917,7 +1009,7 @@ export class BookingTransitionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
status: 'FULLY_EXECUTED',
|
status: "FULLY_EXECUTED",
|
||||||
fullyExecutedAt: now,
|
fullyExecutedAt: now,
|
||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
@@ -932,21 +1024,23 @@ export class BookingTransitionService {
|
|||||||
return this.bookingsService.findById(booking.id);
|
return this.bookingsService.findById(booking.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
async enrichBookingResponse(booking: Booking): Promise<
|
||||||
latestChangeRequestNote?: string | null;
|
Booking & {
|
||||||
contractSummary?: string | null;
|
latestChangeRequestNote?: string | null;
|
||||||
nextStep: BookingNextStep | null;
|
contractSummary?: string | null;
|
||||||
}> {
|
nextStep: BookingNextStep | null;
|
||||||
|
}
|
||||||
|
> {
|
||||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
booking.id,
|
booking.id,
|
||||||
'CHANGES_REQUESTED',
|
"CHANGES_REQUESTED",
|
||||||
);
|
);
|
||||||
const summary =
|
const summary =
|
||||||
booking.contractSummary ??
|
booking.contractSummary ??
|
||||||
this.contractService.buildContractSummary(booking);
|
this.contractService.buildContractSummary(booking);
|
||||||
const nextPending =
|
const nextPending =
|
||||||
booking.status === 'PENDING_APPROVAL' ||
|
booking.status === "PENDING_APPROVAL" ||
|
||||||
booking.status === 'APPROVED_PENDING_SIGNATURE'
|
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||||
: null;
|
: null;
|
||||||
const nextStep = computeNextStep(booking, nextPending);
|
const nextStep = computeNextStep(booking, nextPending);
|
||||||
@@ -957,4 +1051,4 @@ export class BookingTransitionService {
|
|||||||
nextStep,
|
nextStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
|
UploadedFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -19,7 +20,7 @@ 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,
|
||||||
@@ -27,12 +28,17 @@ import {
|
|||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} 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 { BookingClearanceService } from '../contracts/booking-clearance.service';
|
||||||
|
import {
|
||||||
|
AdviseContractDutyDto,
|
||||||
|
RoAmendmentDto,
|
||||||
|
} from '../contracts/dto/phased-clearance.dto';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
@@ -54,16 +60,20 @@ import {
|
|||||||
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 { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
} from '../../common/resolve-auth-user-id';
|
} from "../../common/resolve-auth-user-id";
|
||||||
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
|
import {
|
||||||
|
assertFreightPermission,
|
||||||
|
hasFreightPermission,
|
||||||
|
} from "../../common/freight-permission.util";
|
||||||
|
|
||||||
@ApiTags('bookings')
|
@ApiTags("bookings")
|
||||||
@Controller('bookings')
|
@Controller("bookings")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -72,12 +82,13 @@ 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())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
@ApiOperation({ summary: "Create a new freight booking (DRAFT)" })
|
||||||
@ApiBody({ type: CreateBookingDto })
|
@ApiBody({ type: CreateBookingDto })
|
||||||
async create(
|
async create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@@ -87,15 +98,24 @@ export class BookingsController {
|
|||||||
if (dto.isGovernment) {
|
if (dto.isGovernment) {
|
||||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||||
}
|
}
|
||||||
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
|
const result = await this.bookingsService.create(
|
||||||
|
dto,
|
||||||
|
files ?? [],
|
||||||
|
user?.id,
|
||||||
|
);
|
||||||
|
|
||||||
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
|
||||||
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
const isStaff = hasFreightPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.bookings.staffAccept,
|
||||||
|
);
|
||||||
if (isStaff && !dto.isGovernment) {
|
if (isStaff && !dto.isGovernment) {
|
||||||
try {
|
try {
|
||||||
await this.pricingService.generatePrice(result.booking.id);
|
await this.pricingService.generatePrice(result.booking.id);
|
||||||
await this.transitionService.submit(result.booking.id);
|
await this.transitionService.submit(result.booking.id);
|
||||||
const submitted = await this.bookingsService.findById(result.booking.id);
|
const submitted = await this.bookingsService.findById(
|
||||||
|
result.booking.id,
|
||||||
|
);
|
||||||
return { booking: submitted, warnings: result.warnings };
|
return { booking: submitted, warnings: result.warnings };
|
||||||
} catch {
|
} catch {
|
||||||
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
|
||||||
@@ -105,16 +125,16 @@ export class BookingsController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(":id")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Update booking',
|
summary: "Update booking",
|
||||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
description: "Allowed when status is DRAFT or CHANGES_REQUESTED.",
|
||||||
})
|
})
|
||||||
@ApiBody({ type: UpdateBookingDto })
|
@ApiBody({ type: UpdateBookingDto })
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateBookingDto,
|
@Body() dto: UpdateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
@@ -122,7 +142,7 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
@ApiOperation({ summary: "List freight bookings (paginated)" })
|
||||||
async findAll(
|
async findAll(
|
||||||
@Query() filter: FilterBookingDto,
|
@Query() filter: FilterBookingDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@@ -139,7 +159,7 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findClearanceQueue(filter);
|
return this.bookingsService.findClearanceQueue(filter);
|
||||||
}
|
}
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
if (!userId) throw new UnauthorizedException('Authentication required');
|
if (!userId) throw new UnauthorizedException("Authentication required");
|
||||||
const companyId =
|
const companyId =
|
||||||
await this.bookingsService.resolveCustomerCompanyId(userId);
|
await this.bookingsService.resolveCustomerCompanyId(userId);
|
||||||
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
// No linked company yet → no bookings to show (avoids leaking all bookings).
|
||||||
@@ -165,27 +185,29 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findAll(filter, companyId);
|
return this.bookingsService.findAll(filter, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-company/:companyId/customer-view')
|
@Get("by-company/:companyId/customer-view")
|
||||||
@ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' })
|
@ApiOperation({
|
||||||
|
summary: "List bookings for a company (customer-view shape, backoffice)",
|
||||||
|
})
|
||||||
findByCompanyCustomerView(
|
findByCompanyCustomerView(
|
||||||
@Param('companyId', ParseUUIDPipe) companyId: string,
|
@Param("companyId", ParseUUIDPipe) companyId: string,
|
||||||
) {
|
) {
|
||||||
return this.bookingsService.findCustomerBookings(companyId);
|
return this.bookingsService.findCustomerBookings(companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('list-summary')
|
@Get("list-summary")
|
||||||
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
|
@ApiOperation({ summary: "Booking list metrics and tab counts (backoffice)" })
|
||||||
@ApiOkResponse({ type: BookingListSummaryDto })
|
@ApiOkResponse({ type: BookingListSummaryDto })
|
||||||
findListSummary(@Query() filter: FilterBookingDto) {
|
findListSummary(@Query() filter: FilterBookingDto) {
|
||||||
return this.bookingsService.getListSummary(filter);
|
return this.bookingsService.getListSummary(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('my')
|
@Get("my")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "List the current customer's bookings ready for payment",
|
summary: "List the current customer's bookings ready for payment",
|
||||||
description:
|
description:
|
||||||
'Bookings owned by the authenticated user\'s company that are payable ' +
|
"Bookings owned by the authenticated user's company that are payable " +
|
||||||
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
|
"(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.",
|
||||||
})
|
})
|
||||||
findMyPayable(
|
findMyPayable(
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@@ -194,32 +216,32 @@ export class BookingsController {
|
|||||||
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('queues/:queue')
|
@Get("queues/:queue")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'List bookings for a dashboard queue',
|
summary: "List bookings for a dashboard queue",
|
||||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
description: "Queues: intake, approval, signatures, marketing, finance",
|
||||||
})
|
})
|
||||||
findQueue(
|
findQueue(
|
||||||
@Param('queue') queue: string,
|
@Param("queue") queue: string,
|
||||||
@Query() filter: FilterBookingDto,
|
@Query() filter: FilterBookingDto,
|
||||||
@Query('excludeBulk') excludeBulk?: string,
|
@Query("excludeBulk") excludeBulk?: string,
|
||||||
) {
|
) {
|
||||||
return this.bookingsService.findQueue(queue, filter, {
|
return this.bookingsService.findQueue(queue, filter, {
|
||||||
excludeBulk: excludeBulk === 'true',
|
excludeBulk: excludeBulk === "true",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('reference-data')
|
@Get("reference-data")
|
||||||
@ApiOperation({ summary: 'Booking form catalog' })
|
@ApiOperation({ summary: "Booking form catalog" })
|
||||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
return this.bookingReferenceDataService.getReferenceData();
|
return this.bookingReferenceDataService.getReferenceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('by-reference/:reference')
|
@Get("by-reference/:reference")
|
||||||
@ApiOperation({ summary: 'Get booking by reference' })
|
@ApiOperation({ summary: "Get booking by reference" })
|
||||||
async findByReference(
|
async findByReference(
|
||||||
@Param('reference') reference: string,
|
@Param("reference") reference: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findByReference(reference);
|
const booking = await this.bookingsService.findByReference(reference);
|
||||||
@@ -233,10 +255,10 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(":id")
|
||||||
@ApiOperation({ summary: 'Get booking by ID' })
|
@ApiOperation({ summary: "Get booking by ID" })
|
||||||
async findOne(
|
async findOne(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
@@ -254,15 +276,48 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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')
|
@Get(':id/tracking')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Shipment tracking timeline for a booking',
|
summary: "Shipment tracking timeline for a booking",
|
||||||
description:
|
description:
|
||||||
"Returns the booking's consignment (once dispatched) and its ordered " +
|
"Returns the booking's consignment (once dispatched) and its ordered " +
|
||||||
'tracking events. Scoped to the customer\'s own company.',
|
"tracking events. Scoped to the customer's own company.",
|
||||||
})
|
})
|
||||||
async findTracking(
|
async findTracking(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
@@ -276,66 +331,66 @@ export class BookingsController {
|
|||||||
return this.bookingsService.getBookingTracking(id);
|
return this.bookingsService.getBookingTracking(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(":id")
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
@ApiOperation({ summary: "Soft-delete DRAFT booking" })
|
||||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.remove(id);
|
return this.bookingsService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/documents')
|
@Post(":id/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
|
@ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" })
|
||||||
async uploadDocuments(
|
async uploadDocuments(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/generate-price')
|
@Post(":id/generate-price")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
|
summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)",
|
||||||
description:
|
description:
|
||||||
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
|
"Computes and stores a price preview on the booking. Does not create rate snapshots.",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
generatePrice(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.pricingService.generatePrice(id);
|
return this.pricingService.generatePrice(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/submit')
|
@Post(":id/submit")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Customer submit booking',
|
summary: "Customer submit booking",
|
||||||
description:
|
description:
|
||||||
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
|
"Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||||
submit(@Param('id', ParseUUIDPipe) id: string) {
|
submit(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.transitionService.submit(id);
|
return this.transitionService.submit(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/confirm-submit')
|
@Post(":id/confirm-submit")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Confirm submit after price change',
|
summary: "Confirm submit after price change",
|
||||||
description:
|
description:
|
||||||
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
|
"Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.",
|
||||||
})
|
})
|
||||||
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
@ApiOkResponse({ type: SubmitBookingResponseDto })
|
||||||
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
|
confirmSubmit(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.transitionService.confirmSubmit(id);
|
return this.transitionService.confirmSubmit(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reject')
|
@Post(":id/reject")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Customer reject price estimate',
|
summary: "Customer reject price estimate",
|
||||||
description:
|
description:
|
||||||
'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.',
|
"Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.",
|
||||||
})
|
})
|
||||||
async reject(
|
async reject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RejectBookingDto,
|
@Body() dto: RejectBookingDto,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.reject(id, dto.reason);
|
const booking = await this.transitionService.reject(id, dto.reason);
|
||||||
@@ -344,22 +399,37 @@ export class BookingsController {
|
|||||||
|
|
||||||
// ── Document clearance (post counter-sign) ────────────────────────────────
|
// ── Document clearance (post counter-sign) ────────────────────────────────
|
||||||
|
|
||||||
|
@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')
|
@Get(':id/clearance')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Document-clearance grid (required docs + upload + GL review status)',
|
summary:
|
||||||
|
"Document-clearance grid (required docs + upload + GL review status)",
|
||||||
})
|
})
|
||||||
getClearance(@Param('id', ParseUUIDPipe) id: string) {
|
getClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.transitionService.getClearanceView(id);
|
return this.transitionService.getClearanceView(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/documents')
|
@Post(":id/clearance/documents")
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Customer uploads clearance documents (fieldname = document key)',
|
summary: "Customer uploads clearance documents (fieldname = document key)",
|
||||||
})
|
})
|
||||||
async submitClearanceDocuments(
|
async submitClearanceDocuments(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.submitClearanceDocuments(
|
const booking = await this.transitionService.submitClearanceDocuments(
|
||||||
@@ -369,14 +439,14 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/proceed')
|
@Post(":id/clearance/proceed")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Customer requests operation with a schedule day ' +
|
"Customer requests operation with a schedule day " +
|
||||||
'(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)',
|
"(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED → OPERATION_REQUEST_PENDING)",
|
||||||
})
|
})
|
||||||
async proceedToOperation(
|
async proceedToOperation(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestOperationDto,
|
@Body() dto: RequestOperationDto,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.requestOperation(
|
const booking = await this.transitionService.requestOperation(
|
||||||
@@ -386,15 +456,15 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/operation/review')
|
@Post(":id/operation/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Operations reviews an operation request: ACCEPT (→ batch pool), ' +
|
"Operations reviews an operation request: ACCEPT (→ batch pool), " +
|
||||||
'REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)',
|
"REQUEST_CHANGES (→ back to customer), or ADJUST_PRICE (→ customer re-confirm)",
|
||||||
})
|
})
|
||||||
async reviewOperationRequest(
|
async reviewOperationRequest(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: OperationReviewDto,
|
@Body() dto: OperationReviewDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -407,11 +477,13 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/review')
|
@Post(":id/clearance/review")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
@BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments)
|
||||||
@ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' })
|
@ApiOperation({
|
||||||
|
summary: "GL reviews a clearance document (Approve | Query)",
|
||||||
|
})
|
||||||
async reviewClearanceDocument(
|
async reviewClearanceDocument(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: ReviewDocumentDto,
|
@Body() dto: ReviewDocumentDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -425,13 +497,13 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/output-documents')
|
@Post(":id/clearance/output-documents")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes("multipart/form-data")
|
||||||
@ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' })
|
@ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" })
|
||||||
async uploadClearanceOutput(
|
async uploadClearanceOutput(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
const booking = await this.transitionService.uploadClearanceOutputDocuments(
|
||||||
@@ -441,21 +513,176 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/clearance/finalize')
|
@Post(":id/clearance/finalize")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
@BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'GL finalizes clearance (requires 100% approved) → CLEARANCE_READY',
|
summary:
|
||||||
|
"GL finalizes clearance (requires 100% approved) → CLEARANCE_READY",
|
||||||
})
|
})
|
||||||
async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) {
|
async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.transitionService.finalizeClearance(id);
|
const booking = await this.transitionService.finalizeClearance(id);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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')
|
@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(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: RequestChangesDto,
|
@Body() dto: RequestChangesDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -467,14 +694,14 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/accept')
|
@Post(":id/staff/accept")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary:
|
summary:
|
||||||
'Staff accept intake → set contract validity window + start approval chain',
|
"Staff accept intake → set contract validity window + start approval chain",
|
||||||
})
|
})
|
||||||
async acceptIntake(
|
async acceptIntake(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: AcceptIntakeDto,
|
@Body() dto: AcceptIntakeDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -486,11 +713,11 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/staff/reject')
|
@Post(":id/staff/reject")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
@BookingStaff(FREIGHT_PERMS.bookings.reject)
|
||||||
@ApiOperation({ summary: 'Staff final reject' })
|
@ApiOperation({ summary: "Staff final reject" })
|
||||||
async staffReject(
|
async staffReject(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: StaffRejectDto,
|
@Body() dto: StaffRejectDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -502,11 +729,13 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/government-expedite')
|
@Post(":id/government-expedite")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
@ApiOperation({
|
||||||
|
summary: "Expedite government booking to PAID / ELIGIBLE for scheduling",
|
||||||
|
})
|
||||||
async governmentExpedite(
|
async governmentExpedite(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.governmentExpedite(
|
const booking = await this.bookingsService.governmentExpedite(
|
||||||
@@ -516,16 +745,16 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/approve')
|
@Post(":id/approval-steps/:stepId/approve")
|
||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||||
FREIGHT_PERMS.bookings.approveDirector,
|
FREIGHT_PERMS.bookings.approveDirector,
|
||||||
FREIGHT_PERMS.bookings.approveCeo,
|
FREIGHT_PERMS.bookings.approveCeo,
|
||||||
])
|
])
|
||||||
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
@ApiOperation({ summary: "Approve one approval step in sequence" })
|
||||||
async approveStep(
|
async approveStep(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||||
@Body() dto: ApproveStepDto,
|
@Body() dto: ApproveStepDto,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
) {
|
) {
|
||||||
@@ -539,12 +768,12 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approval-steps/:stepId/reject')
|
@Post(":id/approval-steps/:stepId/reject")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
|
||||||
@ApiOperation({ summary: 'Reject at approval step' })
|
@ApiOperation({ summary: "Reject at approval step" })
|
||||||
async rejectStep(
|
async rejectStep(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Param('stepId', ParseUUIDPipe) stepId: string,
|
@Param("stepId", ParseUUIDPipe) stepId: string,
|
||||||
@Body() dto: RejectStepDto,
|
@Body() dto: RejectStepDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
) {
|
) {
|
||||||
@@ -557,53 +786,53 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contract/generate')
|
@Post(":id/contract/generate")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
|
||||||
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
@ApiOperation({ summary: "Generate contract PDF from template" })
|
||||||
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
async generateContract(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.contractService.generateContract(id);
|
const booking = await this.contractService.generateContract(id);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract/view')
|
@Get(":id/contract/view")
|
||||||
@ApiOkResponse({ type: ContractViewDto })
|
@ApiOkResponse({ type: ContractViewDto })
|
||||||
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
@ApiOperation({ summary: "Contract HTML view for portal and backoffice" })
|
||||||
getContractView(
|
getContractView(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Request() req: { user?: { id?: string; sub?: string } },
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
) {
|
) {
|
||||||
const userId = req.user?.id ?? req.user?.sub;
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
return this.contractService.getContractView(id, userId);
|
return this.contractService.getContractView(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract/document')
|
@Get(":id/contract/document")
|
||||||
@ApiOperation({ summary: 'Download contract PDF' })
|
@ApiOperation({ summary: "Download contract PDF" })
|
||||||
async downloadContractDocument(
|
async downloadContractDocument(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { stream, record } = await this.contractService.streamContract(id);
|
const { stream, record } = await this.contractService.streamContract(id);
|
||||||
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
|
res.setHeader("Content-Type", record.mimeType ?? "application/pdf");
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Disposition',
|
"Content-Disposition",
|
||||||
`attachment; filename="${record.name}"`,
|
`attachment; filename="${record.name}"`,
|
||||||
);
|
);
|
||||||
stream.pipe(res);
|
stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract')
|
@Get(":id/contract")
|
||||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
@ApiOperation({ summary: "Download contract file (alias)" })
|
||||||
async downloadContract(
|
async downloadContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.downloadContractDocument(id, res);
|
return this.downloadContractDocument(id, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/contract/sign')
|
@Post(":id/contract/sign")
|
||||||
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
@ApiOperation({ summary: "Apply digital signature (customer or staff)" })
|
||||||
async signContract(
|
async signContract(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
) {
|
) {
|
||||||
@@ -615,28 +844,28 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/contract/signatures')
|
@Get(":id/contract/signatures")
|
||||||
@ApiOperation({ summary: 'List contract signatures' })
|
@ApiOperation({ summary: "List contract signatures" })
|
||||||
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
getContractSignatures(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.contractService.getSignatures(id);
|
return this.contractService.getSignatures(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/summary')
|
@Get(":id/summary")
|
||||||
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
@ApiOperation({ summary: "Contract summary string for dashboard" })
|
||||||
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
getSummary(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.contractService.getSummary(id);
|
return this.contractService.getSummary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/customer/sign')
|
@Post(":id/customer/sign")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
summary: "Customer digital signature (deprecated — use POST contract/sign)",
|
||||||
})
|
})
|
||||||
async customerSign(
|
async customerSign(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
) {
|
) {
|
||||||
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
const payload: SignContractDto = { ...dto, role: "CUSTOMER" };
|
||||||
const booking = await this.contractService.signContract(id, payload, {
|
const booking = await this.contractService.signContract(id, payload, {
|
||||||
signerUserId: req.user?.id ?? req.user?.sub,
|
signerUserId: req.user?.id ?? req.user?.sub,
|
||||||
ipAddress: req.ip,
|
ipAddress: req.ip,
|
||||||
@@ -644,20 +873,21 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/marketing/approve')
|
@Post(":id/marketing/approve")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
summary:
|
||||||
|
"Staff contract signature and fully execute (use contract/sign STAFF preferred)",
|
||||||
})
|
})
|
||||||
async marketingApprove(
|
async marketingApprove(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: SignContractDto,
|
@Body() dto: SignContractDto,
|
||||||
@CurrentUser() user: AuthUserPayload,
|
@CurrentUser() user: AuthUserPayload,
|
||||||
@Request() req: { ip?: string },
|
@Request() req: { ip?: string },
|
||||||
) {
|
) {
|
||||||
const payload: SignContractDto = {
|
const payload: SignContractDto = {
|
||||||
...dto,
|
...dto,
|
||||||
role: 'STAFF',
|
role: "STAFF",
|
||||||
};
|
};
|
||||||
const booking = await this.contractService.signContract(id, payload, {
|
const booking = await this.contractService.signContract(id, payload, {
|
||||||
signerUserId: resolveAuthUserId(user),
|
signerUserId: resolveAuthUserId(user),
|
||||||
@@ -666,48 +896,48 @@ export class BookingsController {
|
|||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/operations/start-transit')
|
@Post(":id/operations/start-transit")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Mark in transit' })
|
@ApiOperation({ summary: "Mark in transit" })
|
||||||
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
async startTransit(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.transitionService.startTransit(id);
|
const booking = await this.transitionService.startTransit(id);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/operations/complete')
|
@Post(":id/operations/complete")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Mark completed' })
|
@ApiOperation({ summary: "Mark completed" })
|
||||||
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
async complete(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
const booking = await this.transitionService.complete(id);
|
const booking = await this.transitionService.complete(id);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(":id/cancel")
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
|
||||||
@ApiOperation({ summary: 'Cancel booking' })
|
@ApiOperation({ summary: "Cancel booking" })
|
||||||
async cancel(
|
async cancel(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
@Body() dto: CancelBookingDto,
|
@Body() dto: CancelBookingDto,
|
||||||
) {
|
) {
|
||||||
const booking = await this.transitionService.cancel(id, dto.reason);
|
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||||
return this.transitionService.enrichBookingResponse(booking);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/consolidation')
|
@Post(":id/consolidation")
|
||||||
@ApiOperation({ summary: 'Request freight consolidation' })
|
@ApiOperation({ summary: "Request freight consolidation" })
|
||||||
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.requestConsolidation(id);
|
return this.bookingsService.requestConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id/consolidation')
|
@Delete(":id/consolidation")
|
||||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
@ApiOperation({ summary: "Remove consolidation pairing" })
|
||||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.removeConsolidation(id);
|
return this.bookingsService.removeConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/consolidation')
|
@Get(":id/consolidation")
|
||||||
@ApiOperation({ summary: 'Get consolidation details' })
|
@ApiOperation({ summary: "Get consolidation details" })
|
||||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.getConsolidationDetails(id);
|
return this.bookingsService.getConsolidationDetails(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module, forwardRef } 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";
|
||||||
|
|
||||||
// import { CustomersModule } from '../customers/customers.module';
|
// import { CustomersModule } from '../customers/customers.module';
|
||||||
import { CompaniesModule } from '../companies/companies.module';
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
@@ -14,13 +14,13 @@ 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';
|
||||||
@@ -32,13 +32,15 @@ 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 { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
|
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||||
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||||
|
|
||||||
|
|
||||||
@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,
|
||||||
@@ -66,10 +70,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
config.get<ExchangeOptions>("app.cbeExchange") ?? {},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController, BookingPaymentController],
|
controllers: [BookingsController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
@@ -79,13 +83,17 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
BookingPaymentService,
|
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
exports: [
|
||||||
|
BookingsService,
|
||||||
|
BookingsRepository,
|
||||||
|
BookingPricingService,
|
||||||
|
BookingInvoiceService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule { }
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
/** 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],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
60
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal file
60
apps/edr-freight-api/src/modules/fuel/fuel.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal file
15
apps/edr-freight-api/src/modules/fuel/fuel.module.ts
Normal 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 {}
|
||||||
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal file
76
apps/edr-freight-api/src/modules/fuel/fuel.repository.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
121
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal file
121
apps/edr-freight-api/src/modules/fuel/fuel.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) { }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user