mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 07:15:45 +00:00
merge conflict
This commit is contained in:
@@ -34,4 +34,4 @@ RUN addgroup --system --gid 1001 nodejs \
|
|||||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
USER nestjs
|
USER nestjs
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["sh", "-c", "pnpm run migrate && node dist/main.js"]
|
||||||
|
|||||||
@@ -22,13 +22,16 @@
|
|||||||
"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",
|
||||||
|
"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:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||||
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
"iam:migration:run": "pnpm run iam:typeorm:cli migration:run",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common";
|
|||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
import { EventEmitterModule } from "@nestjs/event-emitter";
|
||||||
import { DataSource, DataSourceOptions } from "typeorm";
|
import { DataSource, DataSourceOptions } from "typeorm";
|
||||||
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas";
|
||||||
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
import { IamModule, DataSeeder } from "@tria-plc/iamapi-common";
|
||||||
@@ -53,8 +54,10 @@ import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
|||||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||||
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||||
|
import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder";
|
||||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.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";
|
||||||
//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";
|
||||||
@@ -78,7 +81,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig],
|
||||||
}),
|
}),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
// EventEmitterModule.forRoot(),
|
EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -143,6 +146,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
FileUploadSettingsSeeder,
|
FileUploadSettingsSeeder,
|
||||||
FreightPermissionKeyMigrationSeeder,
|
FreightPermissionKeyMigrationSeeder,
|
||||||
DemoFreightDataSeeder,
|
DemoFreightDataSeeder,
|
||||||
|
GovCompaniesSeeder,
|
||||||
IndodeFacilitySeeder,
|
IndodeFacilitySeeder,
|
||||||
Batch14TestDataSeeder,
|
Batch14TestDataSeeder,
|
||||||
Batch5TestDataSeeder,
|
Batch5TestDataSeeder,
|
||||||
@@ -150,6 +154,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
|||||||
Batch8TestDataSeeder,
|
Batch8TestDataSeeder,
|
||||||
WarehouseDemoSeeder,
|
WarehouseDemoSeeder,
|
||||||
ExportDjiboutiInterchangeDemoSeeder,
|
ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
MarshallingDemoTrainsSeeder,
|
||||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -168,8 +173,10 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||||
|
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||||
|
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
@@ -187,6 +194,7 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
await this.batch8TestDataSeeder.run();
|
await this.batch8TestDataSeeder.run();
|
||||||
await this.warehouseDemoSeeder.run();
|
await this.warehouseDemoSeeder.run();
|
||||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||||
|
await this.marshallingDemoTrainsSeeder.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,
|
||||||
@@ -195,5 +203,8 @@ export class AppModule implements OnApplicationBootstrap {
|
|||||||
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
// demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval
|
||||||
// rules are disabled inside the seeder). Kept running for the staff users.
|
// rules are disabled inside the seeder). Kept running for the staff users.
|
||||||
await this.demoFreightDataSeeder.run();
|
await this.demoFreightDataSeeder.run();
|
||||||
|
// Government entities (with importer/exporter profiles) that government
|
||||||
|
// bookings bill to. Idempotent — keyed by fixed IDs.
|
||||||
|
await this.govCompaniesSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface {
|
||||||
|
name = 'AddPostPaymentCompletedColumn1810000000004';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||||
|
if (firstMileTable) {
|
||||||
|
const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
if (!hasColumn) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.first_mile_deliveries',
|
||||||
|
new TableColumn({
|
||||||
|
name: 'is_post_payment_completed',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
isNullable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||||
|
if (lastMileTable) {
|
||||||
|
const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
if (!hasColumn) {
|
||||||
|
await queryRunner.addColumn(
|
||||||
|
'freight.last_mile_deliveries',
|
||||||
|
new TableColumn({
|
||||||
|
name: 'is_post_payment_completed',
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
isNullable: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries');
|
||||||
|
if (lastMileTable) {
|
||||||
|
await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries');
|
||||||
|
if (firstMileTable) {
|
||||||
|
await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freight billing — `invoices` + `invoice_lines` tables.
|
||||||
|
*
|
||||||
|
* Matches:
|
||||||
|
* - billing/entities/invoice.entity.ts
|
||||||
|
* - billing/entities/invoice-line.entity.ts
|
||||||
|
*
|
||||||
|
* The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default
|
||||||
|
* enum-type name (`<table>_<column>_enum`) so the entity's `type: "enum"`
|
||||||
|
* column resolves to it without an explicit `enumName`.
|
||||||
|
*/
|
||||||
|
export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||||
|
name = "CreateInvoices1821000000002";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TYPE freight.invoices_status_enum AS ENUM (
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING',
|
||||||
|
'PAID',
|
||||||
|
'OVERDUE',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDED'
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.invoices (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
invoice_number varchar(64) NOT NULL,
|
||||||
|
company_id uuid NOT NULL,
|
||||||
|
company_profile_id uuid NOT NULL,
|
||||||
|
total_amount numeric(14, 2) NOT NULL,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||||
|
status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
|
||||||
|
source varchar(255) NOT NULL,
|
||||||
|
source_id varchar(255) NOT NULL,
|
||||||
|
type varchar(255) NOT NULL,
|
||||||
|
issued_at timestamptz,
|
||||||
|
payment_id uuid,
|
||||||
|
due_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_invoices PRIMARY KEY (id),
|
||||||
|
CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number),
|
||||||
|
CONSTRAINT fk_invoices_company FOREIGN KEY (company_id)
|
||||||
|
REFERENCES freight.companies (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id)
|
||||||
|
REFERENCES freight.company_profiles (id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id)
|
||||||
|
REFERENCES freight.payments (id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE freight.invoice_lines (
|
||||||
|
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
invoice_id uuid NOT NULL,
|
||||||
|
charge_type varchar NOT NULL,
|
||||||
|
description varchar(255),
|
||||||
|
quantity numeric(12, 2) NOT NULL DEFAULT 1,
|
||||||
|
unit_rate numeric(14, 2) NOT NULL DEFAULT 0,
|
||||||
|
amount numeric(14, 2) NOT NULL,
|
||||||
|
currency varchar(8) NOT NULL DEFAULT 'ETB',
|
||||||
|
metadata jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz,
|
||||||
|
CONSTRAINT pk_invoice_lines PRIMARY KEY (id),
|
||||||
|
CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id)
|
||||||
|
REFERENCES freight.invoices (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`);
|
||||||
|
await queryRunner.query(`DROP TYPE IF EXISTS freight.invoices_status_enum;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Government bookings now bill to a real seeded government company + an explicit
|
||||||
|
* importer/exporter profile, instead of carrying a null company + free-text
|
||||||
|
* institution. This migration:
|
||||||
|
*
|
||||||
|
* 1. Adds `companies.kind` (commercial | government).
|
||||||
|
* 2. Seeds the Ethiopian government entities + their importer/exporter
|
||||||
|
* profiles (mirrors src/seed/data/gov-companies.data.ts — keep in sync).
|
||||||
|
* 3. Backfills every booking with a NULL company_id / company_profile_id so
|
||||||
|
* the NOT NULL constraints below can be applied:
|
||||||
|
* - NULL company_id → the default government company.
|
||||||
|
* - NULL company_profile_id → the company's profile matching the booking
|
||||||
|
* trade direction; else any profile of the company; else the default
|
||||||
|
* government importer profile.
|
||||||
|
* 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id.
|
||||||
|
*/
|
||||||
|
export class AddCompanyKindAndGovBookingLinks1821000000003
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddCompanyKindAndGovBookingLinks1821000000003";
|
||||||
|
|
||||||
|
// Mirrors src/seed/data/gov-companies.data.ts
|
||||||
|
private readonly govCompanies = [
|
||||||
|
{ id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" },
|
||||||
|
{ id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" },
|
||||||
|
{ id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" },
|
||||||
|
{ id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" },
|
||||||
|
{ id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" },
|
||||||
|
{ id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" },
|
||||||
|
];
|
||||||
|
|
||||||
|
private get defaultCompanyId(): string {
|
||||||
|
return this.govCompanies[0].id;
|
||||||
|
}
|
||||||
|
private get defaultImporterProfileId(): string {
|
||||||
|
return this.govCompanies[0].im;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// 1. kind column
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. seed government companies + importer/exporter profiles (idempotent)
|
||||||
|
for (const g of this.govCompanies) {
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone")
|
||||||
|
VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5)
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.id, g.name, g.tin, g.email, g.phone],
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status")
|
||||||
|
VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active')
|
||||||
|
ON CONFLICT ("id") DO NOTHING`,
|
||||||
|
[g.im, g.id, g.imRef, g.ex, g.exRef],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3a. backfill NULL company_id → default government company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`,
|
||||||
|
[this.defaultCompanyId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3b. backfill NULL company_profile_id → profile matching trade direction
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = cp."id"
|
||||||
|
FROM "freight"."company_profiles" cp
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND cp."company_id" = b."company_id"
|
||||||
|
AND cp."deleted_at" IS NULL
|
||||||
|
AND cp."type" = CASE b."trade_direction"
|
||||||
|
WHEN 'IMPORT' THEN 'importer'
|
||||||
|
WHEN 'EXPORT' THEN 'exporter'
|
||||||
|
ELSE NULL END`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3c. fallback → any profile of the booking's company
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" b
|
||||||
|
SET "company_profile_id" = (
|
||||||
|
SELECT cp."id" FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL
|
||||||
|
ORDER BY cp."created_at" ASC LIMIT 1)
|
||||||
|
WHERE b."company_profile_id" IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM "freight"."company_profiles" cp
|
||||||
|
WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3d. final fallback → default government importer profile
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`,
|
||||||
|
[this.defaultImporterProfileId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. enforce NOT NULL
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`,
|
||||||
|
);
|
||||||
|
// Seeded government rows are intentionally left in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the payment projection source-agnostic so any domain (not just bookings)
|
||||||
|
* can own a payment intent.
|
||||||
|
*
|
||||||
|
* - `payments.type` enum `('booking')` → `varchar(50)`. It now stores the
|
||||||
|
* invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a
|
||||||
|
* new domain no longer needs an enum migration to write its intents.
|
||||||
|
* - adds `payments.reference_type varchar(40)` — the gateway reference type
|
||||||
|
* (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll
|
||||||
|
* path can query the provider without hardcoding it.
|
||||||
|
*
|
||||||
|
* Matches payment/entities/payment.entity.ts.
|
||||||
|
*/
|
||||||
|
export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface {
|
||||||
|
name = "MakePaymentsTypeGeneric1821000000004";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`);
|
||||||
|
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore the single-value enum. Any non-'booking' rows would block the cast;
|
||||||
|
// collapse them first so the down migration is safe.
|
||||||
|
await queryRunner.query(
|
||||||
|
`UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { BillingService } from "./billing.service";
|
|||||||
@Controller("billing")
|
@Controller("billing")
|
||||||
@FreightAdmin()
|
@FreightAdmin()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
constructor(private readonly billingService: BillingService) {}
|
constructor(private readonly billingService: BillingService) { }
|
||||||
|
|
||||||
@Get("invoices")
|
@Get("invoices")
|
||||||
@ApiOperation({ summary: "List all invoices" })
|
@ApiOperation({ summary: "List all invoices" })
|
||||||
@@ -16,9 +16,9 @@ export class BillingController {
|
|||||||
return this.billingService.findAll();
|
return this.billingService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("invoices/booking/:bookingId")
|
@Get("invoices/:id")
|
||||||
@ApiOperation({ summary: "List invoices for a booking" })
|
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||||
findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) {
|
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
return this.billingService.findByBooking(bookingId);
|
return this.billingService.findById(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { forwardRef, Module } from "@nestjs/common";
|
||||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
|
||||||
import { BillingController } from "./billing.controller";
|
import { BillingController } from "./billing.controller";
|
||||||
|
import { PortalBillingController } from "./portal-billing.controller";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Invoice])],
|
imports: [
|
||||||
controllers: [BillingController],
|
TypeOrmModule.forFeature([Invoice, InvoiceLine]),
|
||||||
providers: [BillingService],
|
forwardRef(() => PaymentModule),
|
||||||
|
CompaniesModule,
|
||||||
|
],
|
||||||
|
controllers: [BillingController, PortalBillingController],
|
||||||
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
exports: [BillingService],
|
exports: [BillingService],
|
||||||
})
|
})
|
||||||
export class BillingModule {}
|
export class BillingModule {}
|
||||||
|
|||||||
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
250
apps/edr-freight-api/src/modules/billing/billing.service.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory EntityManager stand-in covering the methods
|
||||||
|
* `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager.
|
||||||
|
*/
|
||||||
|
function makeManager(savedLines: unknown[]) {
|
||||||
|
return {
|
||||||
|
create: (_entity: unknown, data: Record<string, unknown>) => data,
|
||||||
|
save: (data: Record<string, unknown>) => {
|
||||||
|
const row = { id: data.id ?? "gen-1", ...data };
|
||||||
|
if (data.invoiceId) savedLines.push(row);
|
||||||
|
return Promise.resolve(row);
|
||||||
|
},
|
||||||
|
query: () => Promise.resolve([{ seq: 0 }]),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEvents() {
|
||||||
|
return { emit: jest.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInput(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: "booking-1",
|
||||||
|
type: "prepaid",
|
||||||
|
companyId: "company-1",
|
||||||
|
companyProfileId: "profile-1",
|
||||||
|
currency: "ETB",
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
chargeType: "RAIL_FREIGHT",
|
||||||
|
description: "Rail freight",
|
||||||
|
quantity: 2,
|
||||||
|
unitRate: 500,
|
||||||
|
amount: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
chargeType: "HAZARD_SURCHARGE",
|
||||||
|
description: "Hazard surcharge",
|
||||||
|
quantity: 2,
|
||||||
|
unitRate: 250,
|
||||||
|
amount: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("BillingService.generateInvoice", () => {
|
||||||
|
let savedLines: unknown[];
|
||||||
|
let manager: ReturnType<typeof makeManager>;
|
||||||
|
let events: ReturnType<typeof makeEvents>;
|
||||||
|
let dataSource: { transaction: jest.Mock; manager: unknown };
|
||||||
|
let service: BillingService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
savedLines = [];
|
||||||
|
manager = makeManager(savedLines);
|
||||||
|
events = makeEvents();
|
||||||
|
dataSource = {
|
||||||
|
transaction: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)),
|
||||||
|
manager,
|
||||||
|
};
|
||||||
|
service = new BillingService(
|
||||||
|
dataSource as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
events as never,
|
||||||
|
{} as never, // payment
|
||||||
|
{} as never, // companies
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a PENDING invoice with one line per input line", async () => {
|
||||||
|
const invoice = await service.generateInvoice(generateInput());
|
||||||
|
|
||||||
|
expect(invoice.status).toBe(Freight.InvoiceStatus.Pending);
|
||||||
|
expect(invoice.companyId).toBe("company-1");
|
||||||
|
expect(invoice.source).toBe("booking");
|
||||||
|
expect(invoice.sourceId).toBe("booking-1");
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
expect(invoice.issuedAt).toBeInstanceOf(Date);
|
||||||
|
expect(invoice.invoiceNumber).toMatch(/^FRT-\d{8}-00001$/);
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums line amounts when no explicit totalAmount is given", async () => {
|
||||||
|
const invoice = await service.generateInvoice(
|
||||||
|
generateInput({ totalAmount: undefined }),
|
||||||
|
);
|
||||||
|
expect(invoice.totalAmount).toBe(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves issuedAt null for a DRAFT invoice", async () => {
|
||||||
|
const invoice = await service.generateInvoice(
|
||||||
|
generateInput({ status: Freight.InvoiceStatus.Draft }),
|
||||||
|
);
|
||||||
|
expect(invoice.status).toBe(Freight.InvoiceStatus.Draft);
|
||||||
|
expect(invoice.issuedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enlists in a caller's transaction when a manager is passed", async () => {
|
||||||
|
await service.generateInvoice(generateInput(), manager as never);
|
||||||
|
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||||
|
expect(savedLines).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BillingService.markInvoiceAsPaid", () => {
|
||||||
|
it("marks the invoice PAID, links the payment, and emits ${source}.invoice.paid", async () => {
|
||||||
|
const open = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Pending,
|
||||||
|
source: "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
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
{ id: "inv-1" },
|
||||||
|
{ status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" },
|
||||||
|
);
|
||||||
|
expect(events.emit).toHaveBeenCalledWith(
|
||||||
|
"booking.invoice.paid",
|
||||||
|
expect.objectContaining({
|
||||||
|
invoiceId: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Paid,
|
||||||
|
paymentId: "pay-1",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op (no event) when the invoice is already paid", async () => {
|
||||||
|
const paid = {
|
||||||
|
id: "inv-1",
|
||||||
|
status: Freight.InvoiceStatus.Paid,
|
||||||
|
source: "booking",
|
||||||
|
};
|
||||||
|
const mg = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(paid),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
|
|
||||||
|
expect(mg.update).not.toHaveBeenCalled();
|
||||||
|
expect(events.emit).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
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,26 +1,542 @@
|
|||||||
import { Injectable } from "@nestjs/common";
|
import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { Repository } from "typeorm";
|
import { Freight, PaymentReferenceType } from "@edr/types";
|
||||||
|
import { DataSource, EntityManager, In } from "typeorm";
|
||||||
|
|
||||||
import { Invoice } from "./entities/invoice.entity";
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
import { InvoiceRepository } from "./invoice.repository";
|
||||||
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
|
import { PaymentService } from "../payment/payment.service";
|
||||||
|
import { InitiateResponseDto } from "../payment/payments.dto";
|
||||||
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
|
||||||
|
/** Options forwarded to the payment gateway when settling an invoice. */
|
||||||
|
export interface PayInvoiceOptions {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default invoice payment-term window, in days, used to compute `dueAt`. */
|
||||||
|
const DEFAULT_DUE_DAYS = 14;
|
||||||
|
|
||||||
|
/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */
|
||||||
|
const OPEN_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
|
Freight.InvoiceStatus.Draft,
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A single line to bill on a generated invoice. */
|
||||||
|
export interface InvoiceLineInput {
|
||||||
|
chargeType: string;
|
||||||
|
description?: string;
|
||||||
|
/** Units this line bills for; defaults to 1. */
|
||||||
|
quantity?: number;
|
||||||
|
/** Price per unit; defaults to 0. */
|
||||||
|
unitRate?: number;
|
||||||
|
/** Line total; defaults to `quantity * unitRate`. */
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything needed to generate an invoice for any source. */
|
||||||
|
export interface GenerateInvoiceInput {
|
||||||
|
/** Originating subsystem; namespaces events (`${source}.invoice.<event>`). */
|
||||||
|
source: Freight.InvoiceSource;
|
||||||
|
/** Identifier of the source record (e.g. booking id). */
|
||||||
|
sourceId: string;
|
||||||
|
/** What the invoice is for (e.g. "prepaid", "credit"). */
|
||||||
|
type: string;
|
||||||
|
companyId: string;
|
||||||
|
companyProfileId: string;
|
||||||
|
lines: InvoiceLineInput[];
|
||||||
|
currency?: string;
|
||||||
|
/** Explicit total; defaults to the sum of line amounts. */
|
||||||
|
totalAmount?: number;
|
||||||
|
/** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */
|
||||||
|
dueAt?: Date;
|
||||||
|
dueInDays?: number;
|
||||||
|
/**
|
||||||
|
* Initial status. DRAFT leaves `issuedAt` null; any issued status
|
||||||
|
* (default PENDING) stamps `issuedAt`.
|
||||||
|
*/
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload broadcast on `${source}.invoice.<event>`. */
|
||||||
|
export interface InvoiceEventPayload {
|
||||||
|
invoiceId: string;
|
||||||
|
invoiceNumber: string;
|
||||||
|
source: Freight.InvoiceSource;
|
||||||
|
sourceId: string;
|
||||||
|
type: string;
|
||||||
|
companyId: string;
|
||||||
|
companyProfileId: string;
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
status: Freight.InvoiceStatus;
|
||||||
|
paymentId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillingService {
|
export class BillingService {
|
||||||
|
private readonly logger = new Logger(BillingService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Invoice)
|
private readonly dataSource: DataSource,
|
||||||
private readonly invoicesRepository: Repository<Invoice>,
|
private readonly invoices: InvoiceRepository,
|
||||||
) {}
|
private readonly invoiceLines: InvoiceLineRepository,
|
||||||
|
private readonly events: EventEmitter2,
|
||||||
|
@Inject(forwardRef(() => PaymentService))
|
||||||
|
private readonly payment: PaymentService,
|
||||||
|
private readonly companies: CompaniesService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** List every invoice (most recent first). */
|
/** List every invoice (most recent first). */
|
||||||
findAll(): Promise<Invoice[]> {
|
findAll(): Promise<Invoice[]> {
|
||||||
return this.invoicesRepository.find({ order: { issuedAt: "DESC" } });
|
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List invoices for a given booking. */
|
/** Invoice header plus its line items. */
|
||||||
findByBooking(bookingId: string): Promise<Invoice[]> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
return this.invoicesRepository.find({
|
const invoice = await this.invoices.findById(id);
|
||||||
where: { bookingId },
|
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
const lines = await this.invoiceLines.findAll({
|
||||||
|
where: { invoiceId: id },
|
||||||
|
order: { createdAt: "ASC" },
|
||||||
|
});
|
||||||
|
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-scoped reads (portal) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Resolve the customer's company id from their IAM user id (null if none). */
|
||||||
|
async resolveCompanyId(userId: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const { company } = await this.companies.getCompanyInfoByUserId(userId);
|
||||||
|
return company?.id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every invoice billed to a company, newest first, with billing relations. */
|
||||||
|
findByCompany(companyId: string): Promise<Invoice[]> {
|
||||||
|
return this.invoices.findAll({
|
||||||
|
where: { companyId },
|
||||||
|
relations: { company: true, companyProfile: true },
|
||||||
|
order: { createdAt: "DESC" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoices for the signed-in customer; empty when they have no company. */
|
||||||
|
async findForUser(userId: string): Promise<Invoice[]> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
return companyId ? this.findByCompany(companyId) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */
|
||||||
|
async findByIdForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const companyId = await this.resolveCompanyId(userId);
|
||||||
|
const invoice = await this.findById(id);
|
||||||
|
if (!companyId || invoice.companyId !== companyId) {
|
||||||
|
throw new NotFoundException(`Invoice ${id} not found`);
|
||||||
|
}
|
||||||
|
return invoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate gateway payment for one of the customer's own invoices. Verifies
|
||||||
|
* ownership, then charges whichever open invoice the source currently has
|
||||||
|
* (see {@link payInvoice}).
|
||||||
|
*/
|
||||||
|
async payInvoiceForUser(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
opts: PayInvoiceOptions = {},
|
||||||
|
): Promise<InitiateResponseDto> {
|
||||||
|
const invoice = await this.findByIdForUser(id, userId);
|
||||||
|
return this.payInvoice(
|
||||||
|
invoice.source as Freight.InvoiceSource,
|
||||||
|
invoice.sourceId,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generation ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `FRT-YYYYMMDD-00001` — sequential per day, within the active transaction. */
|
||||||
|
private async nextInvoiceNumber(mg: EntityManager): Promise<string> {
|
||||||
|
const now = new Date();
|
||||||
|
const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`;
|
||||||
|
const prefix = `FRT-${ymd}-`;
|
||||||
|
const [row] = await mg.query(
|
||||||
|
`SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq
|
||||||
|
FROM freight.invoices WHERE invoice_number LIKE $1`,
|
||||||
|
[`${prefix}%`],
|
||||||
|
);
|
||||||
|
const next = Number(row?.seq ?? 0) + 1;
|
||||||
|
return `${prefix}${String(next).padStart(5, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an invoice for any source (booking, demurrage, manual, …).
|
||||||
|
*
|
||||||
|
* Persists the header plus its lines in one transaction and assigns the next
|
||||||
|
* sequential `invoice_number`. The total defaults to the sum of line amounts
|
||||||
|
* unless `totalAmount` is given. Issued invoices (default PENDING) stamp
|
||||||
|
* `issuedAt`; pass `status: DRAFT` to leave it unissued.
|
||||||
|
*
|
||||||
|
* Pass `manager` to enlist in a caller's transaction (e.g. when generating an
|
||||||
|
* invoice as part of a larger booking flow).
|
||||||
|
*/
|
||||||
|
async generateInvoice(
|
||||||
|
input: GenerateInvoiceInput,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const run = (mg: EntityManager) => this.createInvoice(input, mg);
|
||||||
|
return manager ? run(manager) : this.dataSource.transaction(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createInvoice(
|
||||||
|
input: GenerateInvoiceInput,
|
||||||
|
mg: EntityManager,
|
||||||
|
): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
|
const currency = input.currency ?? "ETB";
|
||||||
|
const status = input.status ?? Freight.InvoiceStatus.Pending;
|
||||||
|
const issued = status !== Freight.InvoiceStatus.Draft;
|
||||||
|
|
||||||
|
const lines = input.lines.map((l) => {
|
||||||
|
const quantity = l.quantity ?? 1;
|
||||||
|
const unitRate = l.unitRate ?? 0;
|
||||||
|
return {
|
||||||
|
chargeType: l.chargeType,
|
||||||
|
description: l.description,
|
||||||
|
quantity,
|
||||||
|
unitRate,
|
||||||
|
amount: l.amount ?? quantity * unitRate,
|
||||||
|
currency: l.currency ?? currency,
|
||||||
|
metadata: l.metadata ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount =
|
||||||
|
input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0);
|
||||||
|
|
||||||
|
const dueAt =
|
||||||
|
input.dueAt ??
|
||||||
|
new Date(
|
||||||
|
Date.now() +
|
||||||
|
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
const invoiceNumber = await this.nextInvoiceNumber(mg);
|
||||||
|
|
||||||
|
const invoice = await mg.save(
|
||||||
|
mg.create(Invoice, {
|
||||||
|
invoiceNumber,
|
||||||
|
source: input.source,
|
||||||
|
sourceId: input.sourceId,
|
||||||
|
type: input.type,
|
||||||
|
companyId: input.companyId,
|
||||||
|
companyProfileId: input.companyProfileId,
|
||||||
|
totalAmount,
|
||||||
|
currency,
|
||||||
|
status,
|
||||||
|
issuedAt: issued ? new Date() : null,
|
||||||
|
dueAt,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedLines = await Promise.all(
|
||||||
|
lines.map((l) =>
|
||||||
|
mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...invoice, lines: savedLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State transitions ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice paid and 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.
|
||||||
|
*/
|
||||||
|
async markInvoiceAsPaid(
|
||||||
|
invoiceId: string,
|
||||||
|
paymentId: string | null = null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Paid,
|
||||||
|
"paid",
|
||||||
|
{ paymentId: paymentId ?? undefined },
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice refunded and emit `${source}.invoice.refunded`.
|
||||||
|
* No-op when already refunded.
|
||||||
|
*/
|
||||||
|
async markInvoiceAsRefunded(
|
||||||
|
invoiceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Refunded,
|
||||||
|
"refunded",
|
||||||
|
{},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark an invoice cancelled and emit `${source}.invoice.cancelled`.
|
||||||
|
* No-op when already cancelled.
|
||||||
|
*/
|
||||||
|
async cancelInvoice(
|
||||||
|
invoiceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.transition(
|
||||||
|
invoiceId,
|
||||||
|
Freight.InvoiceStatus.Cancelled,
|
||||||
|
"cancelled",
|
||||||
|
{},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the invoice, apply the new status (+ extra columns), then emit
|
||||||
|
* `${source}.invoice.<event>`. No-op (returns the invoice) when it is already
|
||||||
|
* in the target status. Throws when the invoice does not exist.
|
||||||
|
*
|
||||||
|
* Note: the event fires in-process synchronously. When a `manager` from an
|
||||||
|
* outer transaction is passed, listeners run before that transaction commits.
|
||||||
|
*/
|
||||||
|
private async transition(
|
||||||
|
invoiceId: string,
|
||||||
|
status: Freight.InvoiceStatus,
|
||||||
|
event: string,
|
||||||
|
extra: { paymentId?: string },
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } });
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (invoice.status === status) return invoice;
|
||||||
|
|
||||||
|
await mg.update(Invoice, { id: invoice.id }, { status, ...extra });
|
||||||
|
|
||||||
|
const updated = { ...invoice, ...extra, status } as Invoice;
|
||||||
|
this.emitInvoiceEvent(event, updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast `${invoice.source}.invoice.<event>` to in-process listeners. */
|
||||||
|
private emitInvoiceEvent(event: string, invoice: Invoice): void {
|
||||||
|
const payload: InvoiceEventPayload = {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
|
source: invoice.source as Freight.InvoiceSource,
|
||||||
|
sourceId: invoice.sourceId,
|
||||||
|
type: invoice.type,
|
||||||
|
companyId: invoice.companyId,
|
||||||
|
companyProfileId: invoice.companyProfileId,
|
||||||
|
totalAmount: invoice.totalAmount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
status: invoice.status,
|
||||||
|
paymentId: invoice.paymentId ?? null,
|
||||||
|
};
|
||||||
|
this.events.emit(`${invoice.source}.invoice.${event}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment reconciliation (by source) ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invoice a gateway payment should settle for a source record, or null if
|
||||||
|
* none. This is the billing document of record for "what is owed" — callers
|
||||||
|
* (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than
|
||||||
|
* recomputing from the source's own total, so discounts/penalties/adjustments
|
||||||
|
* carried on the invoice are honored.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* invoice is currently open. Returns the most recent matching open (unpaid,
|
||||||
|
* non-cancelled) invoice.
|
||||||
|
*/
|
||||||
|
findPayable(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
type?: string,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
return this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: {
|
||||||
|
source,
|
||||||
|
sourceId,
|
||||||
|
status: In(OPEN_STATUSES),
|
||||||
|
...(type ? { type } : {}),
|
||||||
|
},
|
||||||
order: { issuedAt: "DESC" },
|
order: { issuedAt: "DESC" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle a source's currently-open invoice as paid and link the gateway
|
||||||
|
* payment, then emit `${source}.invoice.paid`. Resolves the open invoice then
|
||||||
|
* delegates to {@link markInvoiceAsPaid}. Full-payment only — no partial
|
||||||
|
* settlement. No-op (returns null) when the source has no open 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(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
paymentId: string | null,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: { source, sourceId, status: In(OPEN_STATUSES) },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsPaid(invoice.id, paymentId, mg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refund a source's paid invoice, then emit `${source}.invoice.refunded`.
|
||||||
|
* Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}.
|
||||||
|
* No-op (returns null) when the source has no paid invoice.
|
||||||
|
*
|
||||||
|
* Pass the caller's transaction `manager` (e.g. from `payment.service.refund`)
|
||||||
|
* to enlist in its DB transaction.
|
||||||
|
*/
|
||||||
|
async refundPayable(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const mg = manager ?? this.dataSource.manager;
|
||||||
|
const invoice = await mg.findOne(Invoice, {
|
||||||
|
where: { source, sourceId, status: Freight.InvoiceStatus.Paid },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsRefunded(invoice.id, mg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payment initiation & settlement (the gateway boundary) ───────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge a source's open invoice through the payment gateway. Billing is the
|
||||||
|
* single place that turns "what is owed" (the invoice) into a payment intent —
|
||||||
|
* the domain never talks to the payment service directly. Resolves the open
|
||||||
|
* invoice, opens an intent for `invoice.totalAmount`, 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 —
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
async payInvoice(
|
||||||
|
source: Freight.InvoiceSource,
|
||||||
|
sourceId: string,
|
||||||
|
opts: {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<InitiateResponseDto> {
|
||||||
|
const invoice = await this.findPayable(source, sourceId);
|
||||||
|
if (!invoice) {
|
||||||
|
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.payment.initiate({
|
||||||
|
referenceId: sourceId,
|
||||||
|
source: invoice.source,
|
||||||
|
// Gateway reference type derives from the invoice source by convention
|
||||||
|
// (source.toUpperCase() ∈ PaymentReferenceType) — no domain word here, and
|
||||||
|
// the domain never supplies it. New sources add their uppercased value to
|
||||||
|
// the PaymentReferenceType enum.
|
||||||
|
referenceType: invoice.source.toUpperCase() as PaymentReferenceType,
|
||||||
|
orderRef: invoice.invoiceNumber,
|
||||||
|
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||||
|
currency: invoice.currency,
|
||||||
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
|
method: opts.method ?? "TELEBIRR",
|
||||||
|
platform: opts.platform,
|
||||||
|
payerAccount: opts.payerAccount,
|
||||||
|
returnUrl: opts.returnUrl,
|
||||||
|
failureUrl: opts.failureUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||||
|
await this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||||
|
|
||||||
|
if (result.immediateSuccess) {
|
||||||
|
await this.settleByPaymentId(
|
||||||
|
result.intentId,
|
||||||
|
result.providerTxnId,
|
||||||
|
result.paidAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the open invoice linked to a gateway intent id, if any. Called by the
|
||||||
|
* payment service when an intent succeeds: finds the invoice linked by
|
||||||
|
* `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain
|
||||||
|
* to advance on. Idempotent — no-op when no open invoice is linked (already
|
||||||
|
* settled, or settled inline by {@link payInvoice}).
|
||||||
|
*/
|
||||||
|
async settleByPaymentId(
|
||||||
|
paymentId: string,
|
||||||
|
_providerTxnId?: string,
|
||||||
|
_paidAt?: Date,
|
||||||
|
): Promise<Invoice | null> {
|
||||||
|
const invoice = await this.dataSource.getRepository(Invoice).findOne({
|
||||||
|
where: { paymentId, status: In(OPEN_STATUSES) },
|
||||||
|
order: { issuedAt: "DESC" },
|
||||||
|
});
|
||||||
|
if (!invoice) return null;
|
||||||
|
|
||||||
|
return this.markInvoiceAsPaid(invoice.id, paymentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
|
import { IsIn, IsOptional, IsString } from "class-validator";
|
||||||
|
|
||||||
|
/** Gateway options for paying an invoice from the customer portal. */
|
||||||
|
export class PayInvoiceDto {
|
||||||
|
@ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
method?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["web", "mobile"])
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
payerAccount?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on success." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
returnUrl?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: "Browser redirect URL on failure." })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { BaseEntity } from "@edr/api-common";
|
||||||
|
import { Column, Entity, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "./invoice.entity";
|
||||||
|
|
||||||
|
@Entity({ schema: "freight", name: "invoice_lines" })
|
||||||
|
export class InvoiceLine extends BaseEntity {
|
||||||
|
@Column({ name: "invoice_id", type: "uuid", nullable: false })
|
||||||
|
invoiceId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Invoice, { onDelete: "CASCADE" })
|
||||||
|
@JoinColumn({ name: "invoice_id" })
|
||||||
|
invoice!: Invoice;
|
||||||
|
|
||||||
|
@Column({ name: "charge_type", type: "varchar", nullable: false })
|
||||||
|
chargeType!: string;
|
||||||
|
|
||||||
|
@Column({ name: "description", type: "varchar", length: 255, nullable: true })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Units this line bills for (e.g. container count, wagon count, tons). */
|
||||||
|
@Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 })
|
||||||
|
quantity!: number;
|
||||||
|
|
||||||
|
/** Price per unit; `amount` is normally `quantity * unitRate`. */
|
||||||
|
@Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 })
|
||||||
|
unitRate!: number;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
name: "amount",
|
||||||
|
type: "numeric",
|
||||||
|
precision: 14,
|
||||||
|
scale: 2,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@Column({ name: "metadata", type: "jsonb", nullable: true })
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
@@ -1,17 +1,35 @@
|
|||||||
import { BaseEntity } from "@edr/api-common";
|
import { BaseEntity } from "@edr/api-common";
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
import { Column, Entity } from "typeorm";
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||||
|
import { PaymentEntity } from "../../payment/entities/payment.entity";
|
||||||
|
import { Company } from "../../companies/entities/company.entity";
|
||||||
|
import { CompanyProfile } from "../../companies/entities/company-profile.entity";
|
||||||
|
|
||||||
@Entity({schema:"freight", name: "invoices" })
|
@Entity({ schema: "freight", name: "invoices" })
|
||||||
|
@Index(["companyId"])
|
||||||
|
@Index(["companyProfileId"])
|
||||||
export class Invoice extends BaseEntity {
|
export class Invoice extends BaseEntity {
|
||||||
@Column({ name: "booking_id", type: "uuid" })
|
|
||||||
bookingId!: string;
|
|
||||||
|
|
||||||
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
@Column({ name: "invoice_number", type: "varchar", length: 64, unique: true })
|
||||||
invoiceNumber!: string;
|
invoiceNumber!: string;
|
||||||
|
|
||||||
@Column({ name: "amount", type: "numeric", precision: 14, scale: 2 })
|
/** The customer (company) this invoice is billed to. */
|
||||||
amount!: number;
|
@Column({ name: "company_id", type: "uuid" })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: "company_id" })
|
||||||
|
company?: Company;
|
||||||
|
|
||||||
|
/** The specific company profile (importer/exporter/forwarder/...) billed. */
|
||||||
|
@Column({ name: "company_profile_id", type: "uuid" })
|
||||||
|
companyProfileId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CompanyProfile)
|
||||||
|
@JoinColumn({ name: "company_profile_id" })
|
||||||
|
companyProfile?: CompanyProfile;
|
||||||
|
|
||||||
|
@Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 })
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
@Column({ name: "currency", type: "varchar", length: 8, default: "ETB" })
|
||||||
currency!: string;
|
currency!: string;
|
||||||
@@ -19,13 +37,38 @@ export class Invoice extends BaseEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "enum",
|
type: "enum",
|
||||||
enum: Freight.PaymentStatus,
|
enum: Freight.InvoiceStatus,
|
||||||
default: Freight.PaymentStatus.Pending,
|
default: Freight.InvoiceStatus.Draft,
|
||||||
})
|
})
|
||||||
status!: Freight.PaymentStatus;
|
status!: Freight.InvoiceStatus;
|
||||||
|
|
||||||
@Column({ name: "issued_at", type: "timestamptz" })
|
/** The source of the payment (e.g. booking, customer, etc.). */
|
||||||
issuedAt!: Date;
|
@Column({ name: "source", type: "varchar", length: 255, nullable: false })
|
||||||
|
source!: string;
|
||||||
|
|
||||||
|
/** The ID of the source (e.g. booking ID, customer ID, etc.). */
|
||||||
|
@Column({ name: "source_id", type: "varchar", length: 255, nullable: false })
|
||||||
|
sourceId!: string;
|
||||||
|
|
||||||
|
/** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */
|
||||||
|
@Column({
|
||||||
|
type: "varchar",
|
||||||
|
length: 255,
|
||||||
|
nullable: false,
|
||||||
|
})
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
/** Set when the invoice is actually issued (DRAFT invoices leave this null). */
|
||||||
|
@Column({ name: "issued_at", type: "timestamptz", nullable: true })
|
||||||
|
issuedAt?: Date | null;
|
||||||
|
|
||||||
|
/** The ID of the payment that generated this invoice. */
|
||||||
|
@Column({ name: "payment_id", type: "uuid", nullable: true })
|
||||||
|
paymentId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => PaymentEntity)
|
||||||
|
@JoinColumn({ name: "payment_id" })
|
||||||
|
payment?: PaymentEntity;
|
||||||
|
|
||||||
@Column({ name: "due_at", type: "timestamptz" })
|
@Column({ name: "due_at", type: "timestamptz" })
|
||||||
dueAt!: Date;
|
dueAt!: Date;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { InvoiceLine } from "./entities/invoice-line.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvoiceLineRepository extends BaseRepository<InvoiceLine> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(InvoiceLine) repository: Repository<InvoiceLine>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { Invoice } from "./entities/invoice.entity";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InvoiceRepository extends BaseRepository<Invoice> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Invoice) repository: Repository<Invoice>,
|
||||||
|
) {
|
||||||
|
super(repository);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
import { CurrentUser } from "@edr/api-common";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from "../../common/resolve-auth-user-id";
|
||||||
|
import { BillingService } from "./billing.service";
|
||||||
|
import { PayInvoiceDto } from "./dto/pay-invoice.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing billing endpoints. Unlike {@link BillingController} (admin,
|
||||||
|
* org-wide), every route here is force-scoped to the signed-in customer's
|
||||||
|
* company — they only ever see and pay their own invoices.
|
||||||
|
*/
|
||||||
|
@ApiTags("billing")
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller("billing")
|
||||||
|
export class PortalBillingController {
|
||||||
|
constructor(private readonly billingService: BillingService) {}
|
||||||
|
|
||||||
|
@Get("my-invoices")
|
||||||
|
@ApiOperation({ summary: "List the signed-in customer's invoices" })
|
||||||
|
findMine(@CurrentUser() user: AuthUserPayload) {
|
||||||
|
return this.billingService.findForUser(resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("my-invoices/:id")
|
||||||
|
@ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" })
|
||||||
|
findMineById(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.billingService.findByIdForUser(id, resolveAuthUserId(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("my-invoices/:id/pay")
|
||||||
|
@ApiOperation({ summary: "Initiate payment for one of the customer's invoices" })
|
||||||
|
pay(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Body() dto: PayInvoiceDto,
|
||||||
|
) {
|
||||||
|
return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), {
|
||||||
|
method: dto.method,
|
||||||
|
platform: dto.platform ?? "web",
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
|
returnUrl: dto.returnUrl,
|
||||||
|
failureUrl: dto.failureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
BillingService,
|
||||||
|
GenerateInvoiceInput,
|
||||||
|
InvoiceEventPayload,
|
||||||
|
InvoiceLineInput,
|
||||||
|
} from '../billing/billing.service';
|
||||||
|
import { Invoice } from '../billing/entities/invoice.entity';
|
||||||
|
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||||
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
|
import { PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||||
|
interface StoredPricingBreakdown {
|
||||||
|
lineItems?: PriceLineItemDto[];
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Round to 2 decimals, avoiding binary float drift. */
|
||||||
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the booking ⇄ invoice mapping — the one place that knows how a booking
|
||||||
|
* turns into invoices, which type to use, and how it advances when paid. Bookings
|
||||||
|
* are the billable business entity, so they generate their own invoices directly
|
||||||
|
* via {@link BillingService} (billing stays source-agnostic). All booking-specific
|
||||||
|
* type branching lives here, at the two points it belongs: invoice creation and
|
||||||
|
* settlement (the paid handler).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class BookingInvoiceService {
|
||||||
|
private readonly logger = new Logger(BookingInvoiceService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly billing: BillingService,
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
@Inject(forwardRef(() => FirstMileService))
|
||||||
|
private readonly firstMile: FirstMileService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatch: BookingBatchService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the booking has its invoice, generating one from the snapshotted
|
||||||
|
* pricing breakdown if absent. Called when a booking reaches a billable state.
|
||||||
|
* Idempotent — returns the existing open invoice instead of a duplicate.
|
||||||
|
* Returns `null` (and logs) when the booking is not billable: no company to
|
||||||
|
* bill (e.g. government bookings whose `companyId` is null, which the invoices
|
||||||
|
* FK requires), or no priced amount.
|
||||||
|
*/
|
||||||
|
async ensureInvoiceForBooking(booking: Booking): Promise<Invoice | null> {
|
||||||
|
const existing = await this.billing.findPayable(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
booking.id,
|
||||||
|
Freight.InvoiceType.Prepaid,
|
||||||
|
);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
if (!booking.companyId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = this.buildInput(booking);
|
||||||
|
if (!input) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.billing.generateInvoice(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||||
|
* reactions live here (not in the payment process): each invoice type advances
|
||||||
|
* the booking its own way. Only PREPAID exists today.
|
||||||
|
*/
|
||||||
|
@OnEvent('booking.invoice.paid')
|
||||||
|
async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise<void> {
|
||||||
|
switch (payload.type) {
|
||||||
|
case Freight.InvoiceType.Prepaid:
|
||||||
|
await this.advanceBookingOnPayment(payload.sourceId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.logger.warn(
|
||||||
|
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance a booking once its prepaid invoice settles — the domain side-effect
|
||||||
|
* of payment, relocated out of the payment service: the booking becomes PAID
|
||||||
|
* and is allocated into its batch. Idempotent — no-op when already PAID.
|
||||||
|
*
|
||||||
|
* General contracts are a separate aggregate now: their CONTRACT_ACTIVE
|
||||||
|
* lifecycle and ordering window live in the contracts module, advanced by the
|
||||||
|
* contract transition/clearance services — not by booking payment. Every
|
||||||
|
* booking that settles here is a ONE_TIME shipment, so there is no contract
|
||||||
|
* branch (legacy GENERAL_CONTRACT booking creation now 410s).
|
||||||
|
*/
|
||||||
|
private async advanceBookingOnPayment(bookingId: string): Promise<void> {
|
||||||
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
this.logger.warn(`Cannot advance unknown booking ${bookingId} on payment.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (booking.paymentStatus === 'PAID') return;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (mg) => {
|
||||||
|
await mg.update(
|
||||||
|
Booking,
|
||||||
|
{ id: bookingId },
|
||||||
|
{ paymentStatus: 'PAID', status: 'PAID' },
|
||||||
|
);
|
||||||
|
await this.firstMile.acceptBooking(bookingId);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.bookingBatch.ensurePaidBookingAllocated(bookingId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a booking's pricing snapshot into a generic invoice request. */
|
||||||
|
private buildInput(booking: Booking): GenerateInvoiceInput | null {
|
||||||
|
const breakdown = (booking.pricingBreakdown ?? {}) as StoredPricingBreakdown;
|
||||||
|
const currency = breakdown.currency ?? booking.paymentCurrency ?? 'ETB';
|
||||||
|
|
||||||
|
const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({
|
||||||
|
chargeType: l.code,
|
||||||
|
description: l.description,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitRate: l.unitAmount,
|
||||||
|
amount: l.amount,
|
||||||
|
currency: l.currency ?? currency,
|
||||||
|
metadata: l.unit ? { unit: l.unit } : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fall back to a single freight line when no breakdown was snapshotted.
|
||||||
|
if (lines.length === 0) {
|
||||||
|
const amount = Number(booking.totalAmount);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return null;
|
||||||
|
lines.push({
|
||||||
|
chargeType: 'FREIGHT',
|
||||||
|
description: 'Rail freight',
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: amount,
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtotal = round2(lines.reduce((sum, l) => sum + Number(l.amount), 0));
|
||||||
|
let totalAmount = subtotal;
|
||||||
|
|
||||||
|
// Honor a staff price override: bill the adjusted total, recording the delta
|
||||||
|
// as an ADJUSTMENT line so the lines still sum to the invoice total.
|
||||||
|
const adjusted = booking.adjustedTotalAmount;
|
||||||
|
if (adjusted != null && Number.isFinite(Number(adjusted))) {
|
||||||
|
const delta = round2(Number(adjusted) - subtotal);
|
||||||
|
if (delta !== 0) {
|
||||||
|
lines.push({
|
||||||
|
chargeType: 'ADJUSTMENT',
|
||||||
|
description: 'Staff price adjustment',
|
||||||
|
quantity: 1,
|
||||||
|
unitRate: delta,
|
||||||
|
amount: delta,
|
||||||
|
currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
totalAmount = round2(Number(adjusted));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: Freight.InvoiceSource.Booking,
|
||||||
|
sourceId: booking.id,
|
||||||
|
type: Freight.InvoiceType.Prepaid,
|
||||||
|
companyId: booking.companyId,
|
||||||
|
companyProfileId: booking.companyProfileId,
|
||||||
|
currency,
|
||||||
|
lines,
|
||||||
|
totalAmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Res,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiProduces,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
|
import { Response } from "express";
|
||||||
|
import { Public } from "@edr/api-common";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
import {
|
||||||
|
InitiatePaymentDto,
|
||||||
|
InitiateResponseDto,
|
||||||
|
PaymentMethodTypeEnum,
|
||||||
|
PaymentPlatformDto,
|
||||||
|
} from "../payment/payments.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking-payment entrypoints. This is the ONE place that knows a payment is for a
|
||||||
|
* booking — it maps the request to {@link Freight.InvoiceSource.Booking} and hands
|
||||||
|
* off to billing, which resolves the invoice/amount and drives the gateway. Billing
|
||||||
|
* and payment stay source-agnostic; the booking knowledge lives here, in the domain.
|
||||||
|
* Routes are unchanged (`/payments/*`) so the portal is unaffected.
|
||||||
|
*/
|
||||||
|
@ApiTags("Payment")
|
||||||
|
@Controller("payments")
|
||||||
|
export class BookingPaymentController {
|
||||||
|
constructor(private readonly billing: BillingService) { }
|
||||||
|
|
||||||
|
@Post("initiate")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Initiate payment for a freight booking",
|
||||||
|
description: "Charges the booking's open invoice through the payment gateway.",
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: InitiateResponseDto })
|
||||||
|
initiate(@Body() dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
||||||
|
return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, {
|
||||||
|
method: dto.method,
|
||||||
|
platform: dto.platform,
|
||||||
|
payerAccount: dto.payerAccount,
|
||||||
|
returnUrl: dto.returnUrl,
|
||||||
|
failureUrl: dto.failureUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("checkout")
|
||||||
|
@Public()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Browser checkout redirect",
|
||||||
|
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.",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "bookingId", required: true })
|
||||||
|
@ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true })
|
||||||
|
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
||||||
|
@ApiProduces("text/html")
|
||||||
|
async checkout(
|
||||||
|
@Query("bookingId") bookingId: string,
|
||||||
|
@Query("method") method: PaymentMethodTypeEnum,
|
||||||
|
@Query("platform") platform: PaymentPlatformDto = "web",
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
if (!bookingId) {
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
||||||
|
}
|
||||||
|
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.billing.payInvoice(
|
||||||
|
Freight.InvoiceSource.Booking,
|
||||||
|
bookingId,
|
||||||
|
{ method, platform },
|
||||||
|
);
|
||||||
|
const url =
|
||||||
|
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
.status(HttpStatus.OK)
|
||||||
|
.type("html")
|
||||||
|
.send(this.buildStatusHtml(result.status, result.intentId));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||||||
|
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRedirectHtml(url: string): string {
|
||||||
|
const escaped = url.replace(/\"/g, """);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url=${escaped}">
|
||||||
|
<title>Redirecting to payment…</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
p { color: #555; margin: 0 0 16px; }
|
||||||
|
a { color: #1a73e8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Redirecting to payment provider…</p>
|
||||||
|
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
||||||
|
</div>
|
||||||
|
<script>window.location.href = "${escaped}";</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildStatusHtml(status: string, intentId: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment status</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||||
|
small { color: #888; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="status">${status}</div>
|
||||||
|
<small>Intent: ${intentId}</small>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorHtml(message: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Payment error</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
||||||
|
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
p { color: #555; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="error">Payment could not be initiated</div>
|
||||||
|
<p>${message}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,37 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Freight } from '@edr/types';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { BillingService } from '../billing/billing.service';
|
||||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
|
||||||
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
|
||||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||||
|
|
||||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
|
||||||
"action-required",
|
|
||||||
"processing",
|
|
||||||
"success",
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly paymentService: PaymentService,
|
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 }> {
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
|
||||||
|
|
||||||
const existing = await this.paymentService.findBookingById(bookingId);
|
const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, {
|
||||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
|
||||||
if (existing.clientAction) {
|
|
||||||
const action = existing.clientAction as { type?: string; url?: string };
|
|
||||||
if (action.type === "REDIRECT" && action.url) {
|
|
||||||
return { redirectUrl: action.url };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resp = await this.paymentService.initiatePayment({
|
|
||||||
bookingId,
|
|
||||||
method: PaymentMethodTypeEnum.TELEBIRR,
|
method: PaymentMethodTypeEnum.TELEBIRR,
|
||||||
platform: "web",
|
platform: 'web',
|
||||||
});
|
});
|
||||||
|
|
||||||
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
const action = resp.clientAction as { type?: string; url?: string } | undefined;
|
||||||
return {
|
return {
|
||||||
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
|
redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
|
|||||||
ruleEngineService as never,
|
ruleEngineService as never,
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never, // bookingBatchService
|
{} as never, // bookingBatchService
|
||||||
@@ -122,6 +123,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -189,6 +191,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // invoiceService
|
||||||
filesService as never,
|
filesService as never,
|
||||||
fileUploadSettingsService as never,
|
fileUploadSettingsService as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ describe('BookingTransitionService — operation review', () => {
|
|||||||
{} as never, // ruleEngineService
|
{} as never, // ruleEngineService
|
||||||
{} as never, // pricingService
|
{} as never, // pricingService
|
||||||
{} as never, // contractService
|
{} as never, // contractService
|
||||||
|
{} as never, // invoiceService
|
||||||
{} as never, // filesService
|
{} as never, // filesService
|
||||||
{} as never, // fileUploadSettingsService
|
{} as never, // fileUploadSettingsService
|
||||||
bookingBatchService as never,
|
bookingBatchService as never,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ 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';
|
||||||
@@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingTransitionService {
|
export class BookingTransitionService {
|
||||||
|
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))
|
||||||
@@ -404,7 +409,22 @@ export class BookingTransitionService {
|
|||||||
marketingApprovedAt: new Date(),
|
marketingApprovedAt: new Date(),
|
||||||
lockedAt: new Date(),
|
lockedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
return this.bookingsService.findById(updated!.id);
|
|
||||||
|
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> {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import { MinioModule } from '../minio/minio.module';
|
|||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||||
import { SignaturesModule } from '../signatures/signatures.module';
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
|
import { BillingModule } from '../billing/billing.module';
|
||||||
|
import { 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 { 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';
|
||||||
@@ -33,7 +37,6 @@ import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing
|
|||||||
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 { PaymentModule } from '../payment/payment.module';
|
|
||||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -48,7 +51,8 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
]),
|
]),
|
||||||
PaymentModule,
|
BillingModule,
|
||||||
|
forwardRef(() => FirstMileModule),
|
||||||
forwardRef(() => TrainSchedulingModule),
|
forwardRef(() => TrainSchedulingModule),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
@@ -63,7 +67,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [BookingsController, PayController],
|
controllers: [BookingsController, PayController, BookingPaymentController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
@@ -72,6 +76,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingTransitionService,
|
BookingTransitionService,
|
||||||
BookingContractService,
|
BookingContractService,
|
||||||
|
BookingInvoiceService,
|
||||||
BookingPaymentService,
|
BookingPaymentService,
|
||||||
ContractTemplateResolver,
|
ContractTemplateResolver,
|
||||||
ContractViewModelBuilder,
|
ContractViewModelBuilder,
|
||||||
@@ -79,6 +84,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu
|
|||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService, BookingsRepository, BookingPricingService],
|
exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService],
|
||||||
})
|
})
|
||||||
export class BookingsModule {}
|
export class BookingsModule {}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ProfileType } from '../companies/entities/company-profile.entity';
|
import { ProfileType } from '../companies/entities/company-profile.entity';
|
||||||
import { CompanyStatus } from '../companies/entities/company.entity';
|
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
@@ -307,10 +307,23 @@ export class BookingsService {
|
|||||||
|
|
||||||
let companyId: string | null | undefined = dto.companyId;
|
let companyId: string | null | undefined = dto.companyId;
|
||||||
if (isGovernment) {
|
if (isGovernment) {
|
||||||
if (!dto.governmentInstitution?.trim()) {
|
// Government bookings bill to a real seeded government company + an
|
||||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
// explicitly-chosen importer/exporter profile (no more null company +
|
||||||
|
// free-text institution).
|
||||||
|
if (!dto.companyId) {
|
||||||
|
throw new BadRequestException('A government company is required for government bookings');
|
||||||
}
|
}
|
||||||
companyId = dto.companyId ?? null;
|
const govCompany = await this.companiesService.findCompanyById(dto.companyId);
|
||||||
|
if (govCompany.kind !== CompanyKind.Government) {
|
||||||
|
throw new BadRequestException('Selected company is not a government entity');
|
||||||
|
}
|
||||||
|
if (govCompany.status !== CompanyStatus.Active) {
|
||||||
|
throw new BadRequestException('Selected government company is not active');
|
||||||
|
}
|
||||||
|
if (!dto.companyProfileId) {
|
||||||
|
throw new BadRequestException('A government company profile is required for government bookings');
|
||||||
|
}
|
||||||
|
companyId = govCompany.id;
|
||||||
} else if (!companyId) {
|
} else if (!companyId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -383,7 +396,16 @@ export class BookingsService {
|
|||||||
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
|
||||||
// for non-government bookings with a resolved company; never blocks creation.
|
// for non-government bookings with a resolved company; never blocks creation.
|
||||||
let companyProfileId: string | null = null;
|
let companyProfileId: string | null = null;
|
||||||
if (!isGovernment && companyId) {
|
if (dto.companyProfileId && companyId) {
|
||||||
|
// Explicit profile pin (government booking, or staff booking on behalf):
|
||||||
|
// must belong to the chosen company and be active.
|
||||||
|
const profile =
|
||||||
|
await this.companiesService.getActiveCompanyProfileForBooking(
|
||||||
|
companyId,
|
||||||
|
dto.companyProfileId,
|
||||||
|
);
|
||||||
|
companyProfileId = profile.id;
|
||||||
|
} else if (companyId) {
|
||||||
let fallbackType: ProfileType | null = null;
|
let fallbackType: ProfileType | null = null;
|
||||||
if (userId) {
|
if (userId) {
|
||||||
try {
|
try {
|
||||||
@@ -413,6 +435,16 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every booking must link to a company and a company profile.
|
||||||
|
if (!companyId) {
|
||||||
|
throw new BadRequestException('A company is required to create a booking');
|
||||||
|
}
|
||||||
|
if (!companyProfileId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'A company profile is required to create a booking — none could be resolved for this company',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const needsConsolidation =
|
const needsConsolidation =
|
||||||
dto.freightType === 'CONTAINER'
|
dto.freightType === 'CONTAINER'
|
||||||
? await this.needsConsolidation(containers)
|
? await this.needsConsolidation(containers)
|
||||||
@@ -443,10 +475,10 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
companyId: companyId ?? null,
|
companyId,
|
||||||
companyProfileId,
|
companyProfileId,
|
||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
governmentInstitution: dto.governmentInstitution?.trim() || null,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
trainScheduleId: dto.trainScheduleId ?? null,
|
trainScheduleId: dto.trainScheduleId ?? null,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Max,
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
|
||||||
Validate,
|
Validate,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -104,19 +103,31 @@ export class CreateBookingDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isGovernment?: boolean;
|
isGovernment?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
/** @deprecated Government bookings now bill to a real government company. */
|
||||||
@ValidateIf((o) => o.isGovernment === true)
|
@ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' })
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
governmentInstitution?: string;
|
governmentInstitution?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
@ApiPropertyOptional({
|
||||||
@ValidateIf((o) => o.isGovernment !== true)
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
companyProfileId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
|
|||||||
@@ -105,8 +105,10 @@ export class Booking extends BaseEntity {
|
|||||||
// @JoinColumn({ name: 'customer_id' })
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
// customer?: Customer;
|
// customer?: Customer;
|
||||||
|
|
||||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
// Every booking is billed to a company — government bookings bill to a seeded
|
||||||
companyId?: string | null;
|
// government company (companies.kind = 'government'). Enforced NOT NULL.
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => Company, { nullable: true })
|
@ManyToOne(() => Company, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_id' })
|
@JoinColumn({ name: 'company_id' })
|
||||||
@@ -116,11 +118,12 @@ export class Booking extends BaseEntity {
|
|||||||
* The operational profile (importer/exporter/forwarder) this booking belongs
|
* The operational profile (importer/exporter/forwarder) this booking belongs
|
||||||
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
* to. Stamped at creation from the booking's trade direction (IMPORT→importer,
|
||||||
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
* EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder.
|
||||||
* Customer portal lists and dashboard KPIs are scoped by this. Nullable for
|
* Customer portal lists and dashboard KPIs are scoped by this. Required:
|
||||||
* legacy/government/staff-created bookings.
|
* commercial bookings resolve it from trade direction / active mode;
|
||||||
|
* government bookings carry the explicitly-picked government profile.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'company_profile_id', type: 'uuid', nullable: true })
|
@Column({ name: 'company_profile_id', type: 'uuid' })
|
||||||
companyProfileId?: string | null;
|
companyProfileId!: string;
|
||||||
|
|
||||||
@ManyToOne(() => CompanyProfile, { nullable: true })
|
@ManyToOne(() => CompanyProfile, { nullable: true })
|
||||||
@JoinColumn({ name: 'company_profile_id' })
|
@JoinColumn({ name: 'company_profile_id' })
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
async findPaginated(
|
async findPaginated(
|
||||||
query: ListCompaniesQueryDto,
|
query: ListCompaniesQueryDto,
|
||||||
): Promise<{ items: Company[]; total: number }> {
|
): Promise<{ items: Company[]; total: number }> {
|
||||||
const { page = 1, pageSize = 20, search, type, status } = query;
|
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||||
|
|
||||||
const qb = this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('company')
|
.createQueryBuilder('company')
|
||||||
@@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
|||||||
qb.andWhere('company.type = :type', { type });
|
qb.andWhere('company.type = :type', { type });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind) {
|
||||||
|
qb.andWhere('company.kind = :kind', { kind });
|
||||||
|
}
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
qb.andWhere('company.status = :status', { status });
|
qb.andWhere('company.status = :status', { status });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -337,6 +337,29 @@ export class CompaniesService {
|
|||||||
return company;
|
return company;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an explicitly-chosen company profile for a booking: it must belong
|
||||||
|
* to the booking's company and be Active. Used for government bookings (staff
|
||||||
|
* pick the profile) and any staff booking that pins a profile directly.
|
||||||
|
*/
|
||||||
|
async getActiveCompanyProfileForBooking(
|
||||||
|
companyId: string,
|
||||||
|
profileId: string,
|
||||||
|
): Promise<CompanyProfile> {
|
||||||
|
const profile = await this.companyProfilesRepo.findById(profileId);
|
||||||
|
if (!profile || profile.companyId !== companyId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile does not belong to the chosen company",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Selected company profile is not active",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
async getCompanyInfoByUserId(
|
async getCompanyInfoByUserId(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ profile: ExternalProfile; company: Company }> {
|
): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||||
import { Transform } from "class-transformer";
|
import { Transform } from "class-transformer";
|
||||||
import { CompanyStatus, CompanyType } from "../entities/company.entity";
|
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||||
|
|
||||||
export class ListCompaniesQueryDto {
|
export class ListCompaniesQueryDto {
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@@ -28,6 +28,11 @@ export class ListCompaniesQueryDto {
|
|||||||
@IsIn(Object.values(CompanyType))
|
@IsIn(Object.values(CompanyType))
|
||||||
type?: CompanyType;
|
type?: CompanyType;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: CompanyKind })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(Object.values(CompanyKind))
|
||||||
|
kind?: CompanyKind;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: CompanyStatus })
|
@ApiPropertyOptional({ enum: CompanyStatus })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(Object.values(CompanyStatus))
|
@IsIn(Object.values(CompanyStatus))
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export enum CompanyType {
|
|||||||
Transporter = "transporter",
|
Transporter = "transporter",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sector of the company — orthogonal to {@link CompanyType} (the trade role).
|
||||||
|
* Government bookings are billed to a single seeded `GOVERNMENT` company instead
|
||||||
|
* of carrying a null company + free-text institution.
|
||||||
|
*/
|
||||||
|
export enum CompanyKind {
|
||||||
|
Commercial = "commercial",
|
||||||
|
Government = "government",
|
||||||
|
}
|
||||||
|
|
||||||
export enum CompanyStatus {
|
export enum CompanyStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Pending = "pending",
|
Pending = "pending",
|
||||||
@@ -25,6 +35,7 @@ export enum CompanyNationality {
|
|||||||
@Entity({ schema: "freight", name: "companies" })
|
@Entity({ schema: "freight", name: "companies" })
|
||||||
@Index(["tin"])
|
@Index(["tin"])
|
||||||
@Index(["type"])
|
@Index(["type"])
|
||||||
|
@Index(["kind"])
|
||||||
export class Company extends BaseEntity {
|
export class Company extends BaseEntity {
|
||||||
@Column({ name: "name", type: "varchar", length: 200 })
|
@Column({ name: "name", type: "varchar", length: 200 })
|
||||||
name!: string;
|
name!: string;
|
||||||
@@ -32,6 +43,16 @@ export class Company extends BaseEntity {
|
|||||||
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
@Column({ name: "type", type: "varchar", length: 32, enum: CompanyType })
|
||||||
type!: CompanyType;
|
type!: CompanyType;
|
||||||
|
|
||||||
|
/** Commercial customer vs. the seeded government entity. */
|
||||||
|
@Column({
|
||||||
|
name: "kind",
|
||||||
|
type: "varchar",
|
||||||
|
length: 20,
|
||||||
|
default: CompanyKind.Commercial,
|
||||||
|
enum: CompanyKind,
|
||||||
|
})
|
||||||
|
kind!: CompanyKind;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
name: "status",
|
name: "status",
|
||||||
type: "varchar",
|
type: "varchar",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
@@ -11,6 +12,7 @@ import { BookingContainer } from '../bookings/entities/booking-container.entity'
|
|||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||||
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
@@ -45,6 +47,8 @@ export interface CreateBookingUnderContractResult {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractBookingService {
|
export class ContractBookingService {
|
||||||
|
private readonly logger = new Logger(ContractBookingService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly contractsRepository: ContractsRepository,
|
private readonly contractsRepository: ContractsRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
@@ -52,6 +56,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 invoiceService: BookingInvoiceService,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -199,6 +204,22 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||||
|
|
||||||
|
// Contract bookings are born past the billable gate (the contract is already
|
||||||
|
// executed), so the invoice is generated here — they never pass through the
|
||||||
|
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
|
||||||
|
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
|
||||||
|
// Skips silently when unbillable (no company / no priced amount).
|
||||||
|
await this.invoiceService
|
||||||
|
.ensureInvoiceForBooking(result ?? booking)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return { booking: result ?? booking, warnings };
|
return { booking: result ?? booking, warnings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,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({ type: 'boolean', default: false })
|
||||||
|
// isPostPaymentCompleted!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
estimatedKm?: number | null;
|
estimatedKm?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
|
|||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
import { CreateFirstMileDto } from './dto/create-first-mile.dto';
|
||||||
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
import { UpdateFirstMileDto } from './dto/update-first-mile.dto';
|
||||||
@@ -36,7 +36,7 @@ export class FirstMileService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly smsClient: SmsClientService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,16 +251,19 @@ export class FirstMileService {
|
|||||||
|
|
||||||
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
const booking = (record as FirstMile & { booking?: { reference?: string; firstMilePickupAddress?: string | null; originYard?: { label?: string } | null } }).booking;
|
||||||
|
|
||||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||||
driverPhone: driver.phoneNumber,
|
const message =
|
||||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
`Dear ${driverName}, you have been assigned to a first-mile pickup. ` +
|
||||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||||
bookingReference: booking?.reference ?? record.bookingId,
|
(booking?.firstMilePickupAddress ? `Pickup: ${booking.firstMilePickupAddress}. ` : '') +
|
||||||
pickupAddress: booking?.firstMilePickupAddress,
|
(booking?.originYard?.label ? `Destination: ${booking.originYard.label}.` : '');
|
||||||
destinationYard: booking?.originYard?.label,
|
|
||||||
|
void this.smsClient.sendSms({
|
||||||
|
to: driver.phoneNumber,
|
||||||
|
message,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,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({ type: 'boolean', default: false })
|
||||||
|
// isPostPaymentCompleted!: boolean;
|
||||||
|
|
||||||
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
@Column({ name: 'estimated_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||||
estimatedKm?: number | null;
|
estimatedKm?: number | null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { FindOptionsWhere } from 'typeorm';
|
|||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { DriversService } from '../drivers/drivers.service';
|
import { DriversService } from '../drivers/drivers.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { SmsClientService } from '../notifications/sms-client.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
@@ -32,12 +32,11 @@ export class LastMileService {
|
|||||||
private readonly logger = new Logger(LastMileService.name);
|
private readonly logger = new Logger(LastMileService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
||||||
private readonly lastMileRepository: LastMileRepository,
|
private readonly lastMileRepository: LastMileRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly vehiclesService: VehiclesService,
|
private readonly vehiclesService: VehiclesService,
|
||||||
private readonly driversService: DriversService,
|
private readonly driversService: DriversService,
|
||||||
private readonly notificationsService: NotificationsService,
|
private readonly smsClient: SmsClientService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
async acceptBooking(bookingReference: string): Promise<LastMile | null> {
|
||||||
@@ -185,16 +184,19 @@ export class LastMileService {
|
|||||||
};
|
};
|
||||||
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
|
const booking = (record as LastMile & { booking?: BookingWithYards }).booking;
|
||||||
|
|
||||||
await this.notificationsService.notifyDriverVehicleAssignment({
|
const driverName = `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim();
|
||||||
driverPhone: driver.phoneNumber,
|
const message =
|
||||||
driverName: `${driver.firstName ?? ''} ${driver.lastName ?? ''}`.trim(),
|
`Dear ${driverName}, you have been assigned to a last-mile delivery. ` +
|
||||||
vehiclePlateNumber: vehicle.plateNumber ?? vehicleId,
|
`Booking: ${booking?.reference ?? record.bookingId}. Vehicle: ${vehicle.plateNumber ?? vehicleId}. ` +
|
||||||
bookingReference: booking?.reference ?? record.bookingId,
|
(booking?.destinationYard?.label ? `Pickup: ${booking.destinationYard.label}. ` : '') +
|
||||||
pickupAddress: booking?.destinationYard?.label,
|
(booking?.lastMileDeliveryAddress ? `Destination: ${booking.lastMileDeliveryAddress}.` : '');
|
||||||
destinationYard: booking?.lastMileDeliveryAddress,
|
|
||||||
|
void this.smsClient.sendSms({
|
||||||
|
to: driver.phoneNumber,
|
||||||
|
message,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`SMS sent to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
this.logger.log(`SMS queued to driver ${driver.phoneNumber} for vehicle ${vehicleId} assignment`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
this.logger.error(`Failed to notify driver for vehicle ${vehicleId}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { BaseEntity, Column, CreateDateColumn, Entity, OneToMany, PrimaryGenerat
|
|||||||
import { PaymentRefundEntity } from "./payment-refund.entity";
|
import { PaymentRefundEntity } from "./payment-refund.entity";
|
||||||
|
|
||||||
|
|
||||||
type PaymentType = "booking"
|
/** Invoice source that owns the intent ('booking', 'demurrage', …) — caller-supplied. */
|
||||||
|
type PaymentType = string
|
||||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" | "waafi" | "card" | "dmoney" | "cac-bank"
|
||||||
type Currency = "ETB" | "USD"
|
type Currency = "ETB" | "USD"
|
||||||
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||||
@@ -15,9 +16,12 @@ export class PaymentEntity extends BaseEntity {
|
|||||||
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
@Column({ type: 'varchar', length: 255, name: "ref_id" })
|
||||||
refId!: string
|
refId!: string
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["booking"] })
|
@Column({ type: "varchar", length: 50 })
|
||||||
type!: PaymentType;
|
type!: PaymentType;
|
||||||
|
|
||||||
|
@Column({ type: "varchar", length: 40, nullable: true, name: "reference_type" })
|
||||||
|
referenceType?: string;
|
||||||
|
|
||||||
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
@Column({ type: "enum", enum: ["telebirr", "cbe-birr", "ebirr", "waafi", "card", "dmoney", "cac-bank"] })
|
||||||
method!: PaymentMethod
|
method!: PaymentMethod
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
Post,
|
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
Body,
|
||||||
|
Post,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -20,14 +20,7 @@ import { Response } from "express";
|
|||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
import { BookingView, FreightAdmin } from "../../common/booking-guards";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import {
|
import { IntentStatusDto, RefundDto } from "./payments.dto";
|
||||||
InitiatePaymentDto,
|
|
||||||
InitiateResponseDto,
|
|
||||||
IntentStatusDto,
|
|
||||||
PaymentMethodTypeEnum,
|
|
||||||
PaymentPlatformDto,
|
|
||||||
RefundDto,
|
|
||||||
} from "./payments.dto";
|
|
||||||
|
|
||||||
@ApiTags("Payment")
|
@ApiTags("Payment")
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
@@ -73,16 +66,6 @@ export class PaymentController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post("initiate")
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Initiate payment for a freight booking",
|
|
||||||
description: `Initiates payment via the central payment microservice.\n\n**Supported methods:**\n- TELEBIRR — Ethiopian mobile money\n- CBE_BIRR — Commercial Bank of Ethiopia\n- EBIRR — Electronic payment gateway\n- WAAFI — Djibouti mobile money\n- CARD — Visa/Mastercard\n- DMONEY — Djibouti D-money\n- CAC_BANK — CAC Int Bank (OTP)`,
|
|
||||||
})
|
|
||||||
@ApiOkResponse({ type: InitiateResponseDto })
|
|
||||||
initiatePayment(@Body() dto: InitiatePaymentDto) {
|
|
||||||
return this.paymentService.initiatePayment(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("intents/:bookingId")
|
@Get("intents/:bookingId")
|
||||||
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
@ApiOperation({ summary: "Get payment intent status for a booking" })
|
||||||
@ApiOkResponse({ type: IntentStatusDto })
|
@ApiOkResponse({ type: IntentStatusDto })
|
||||||
@@ -97,54 +80,6 @@ export class PaymentController {
|
|||||||
return this.paymentService.refund(dto);
|
return this.paymentService.refund(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("checkout")
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({
|
|
||||||
summary: "Browser checkout redirect",
|
|
||||||
description:
|
|
||||||
"Initiates payment 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: "method", enum: PaymentMethodTypeEnum, required: true })
|
|
||||||
@ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false })
|
|
||||||
@ApiProduces("text/html")
|
|
||||||
async checkout(
|
|
||||||
@Query("bookingId") bookingId: string,
|
|
||||||
@Query("method") method: PaymentMethodTypeEnum,
|
|
||||||
@Query("platform") platform: PaymentPlatformDto = "web",
|
|
||||||
@Res() res: Response,
|
|
||||||
) {
|
|
||||||
if (!bookingId) {
|
|
||||||
return res
|
|
||||||
.status(HttpStatus.BAD_REQUEST)
|
|
||||||
.type("html")
|
|
||||||
.send(this.buildErrorHtml("Missing required query parameter: bookingId"));
|
|
||||||
}
|
|
||||||
if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) {
|
|
||||||
return res
|
|
||||||
.status(HttpStatus.BAD_REQUEST)
|
|
||||||
.type("html")
|
|
||||||
.send(this.buildErrorHtml("Missing or invalid query parameter: method"));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await this.paymentService.initiatePayment({ bookingId, method, platform });
|
|
||||||
const url =
|
|
||||||
result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined;
|
|
||||||
|
|
||||||
if (url) {
|
|
||||||
return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url));
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
.status(HttpStatus.OK)
|
|
||||||
.type("html")
|
|
||||||
.send(this.buildStatusHtml(result.status, result.intentId));
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
|
||||||
return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get("receipt/:orderId")
|
@Get("receipt/:orderId")
|
||||||
@Public()
|
@Public()
|
||||||
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
@ApiOperation({ summary: "Generate a payment receipt HTML page" })
|
||||||
@@ -153,76 +88,4 @@ export class PaymentController {
|
|||||||
const html = await this.paymentService.genReceiptHtml(orderId);
|
const html = await this.paymentService.genReceiptHtml(orderId);
|
||||||
return res.status(HttpStatus.OK).type("html").send(html);
|
return res.status(HttpStatus.OK).type("html").send(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildRedirectHtml(url: string): string {
|
|
||||||
const escaped = url.replace(/\"/g, """);
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta http-equiv="refresh" content="0;url=${escaped}">
|
|
||||||
<title>Redirecting to payment…</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
||||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
|
||||||
.spinner { width: 40px; height: 40px; border: 4px solid #e0e0e0; border-top-color: #1a73e8; border-radius: 50%; animation: spin .8s linear infinite; margin: 0 auto 20px; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
p { color: #555; margin: 0 0 16px; }
|
|
||||||
a { color: #1a73e8; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="spinner"></div>
|
|
||||||
<p>Redirecting to payment provider…</p>
|
|
||||||
<p><a href="${escaped}">Click here if you are not redirected</a></p>
|
|
||||||
</div>
|
|
||||||
<script>window.location.href = "${escaped}";</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildStatusHtml(status: string, intentId: string): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Payment status</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
||||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
|
||||||
.status { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 8px; }
|
|
||||||
small { color: #888; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="status">${status}</div>
|
|
||||||
<small>Intent: ${intentId}</small>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildErrorHtml(message: string): string {
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Payment error</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f5f5f5; }
|
|
||||||
.card { background: #fff; border-radius: 8px; padding: 40px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,.1); max-width: 400px; }
|
|
||||||
.error { color: #d32f2f; font-weight: 600; margin-bottom: 8px; }
|
|
||||||
p { color: #555; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="error">Payment could not be initiated</div>
|
|
||||||
<p>${message}</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
|
import { DynamicModule, forwardRef, Module } from "@nestjs/common";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||||
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
import { RabbitMQModule } from "@golevelup/nestjs-rabbitmq";
|
||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
|
|
||||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.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";
|
||||||
@@ -58,9 +56,7 @@ function rabbitMQImport(): DynamicModule[] {
|
|||||||
imports: [
|
imports: [
|
||||||
HttpModule.register({ timeout: 10_000 }),
|
HttpModule.register({ timeout: 10_000 }),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
DropdownSettingsModule,
|
forwardRef(() => BillingModule),
|
||||||
forwardRef(() => FirstMileModule),
|
|
||||||
forwardRef(() => TrainSchedulingModule),
|
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
PaymentEntity,
|
PaymentEntity,
|
||||||
PaymentWebhookEventEntity,
|
PaymentWebhookEventEntity,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { DataSource } from "typeorm";
|
|||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { PaymentClientService } from "./payment-client.service";
|
import { PaymentClientService } from "./payment-client.service";
|
||||||
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
@@ -28,13 +29,44 @@ import {
|
|||||||
ProviderMethod,
|
ProviderMethod,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import {
|
import {
|
||||||
InitiatePaymentDto,
|
|
||||||
InitiateResponseDto,
|
InitiateResponseDto,
|
||||||
IntentStatusDto,
|
IntentStatusDto,
|
||||||
|
PaymentPlatformDto,
|
||||||
RefundDto,
|
RefundDto,
|
||||||
} from "./payments.dto";
|
} from "./payments.dto";
|
||||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
|
||||||
import { FirstMileService } from "../first-mile/first-mile.service";
|
/** Everything the gateway needs to open an intent. Amount/currency are supplied by
|
||||||
|
* the caller (billing) — this service never derives them from a domain record. */
|
||||||
|
export interface InitiateIntentInput {
|
||||||
|
/** Opaque domain reference (booking id, …). */
|
||||||
|
referenceId: string;
|
||||||
|
/** Invoice source that owns the intent ('booking', …) — stored on the projection. */
|
||||||
|
source: string;
|
||||||
|
/** Gateway reference type the intent is opened with (caller's domain decides it). */
|
||||||
|
referenceType: PaymentReferenceType;
|
||||||
|
/** Human-readable order ref shown on provider pages. */
|
||||||
|
orderRef: string;
|
||||||
|
/** Authoritative amount in minor units, computed by the caller. */
|
||||||
|
amountMinor: number;
|
||||||
|
currency: string;
|
||||||
|
/** Stored on the intent projection for receipts/dashboards. */
|
||||||
|
reason?: string;
|
||||||
|
/** Provider/method selector. */
|
||||||
|
method: ProviderMethod | string;
|
||||||
|
platform?: PaymentPlatformDto;
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitiateIntentResult {
|
||||||
|
intentId: string;
|
||||||
|
response: InitiateResponseDto;
|
||||||
|
/** True when the provider settled the charge synchronously during initiate. */
|
||||||
|
immediateSuccess: boolean;
|
||||||
|
providerTxnId?: string;
|
||||||
|
paidAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
@@ -45,6 +77,23 @@ const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
|||||||
"refunded": ProviderPaymentStatus.CANCELLED,
|
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
||||||
|
TELEBIRR: "telebirr",
|
||||||
|
CBE_BIRR: "cbe-birr",
|
||||||
|
EBIRR: "ebirr",
|
||||||
|
WAAFI: "waafi",
|
||||||
|
CARD: "card",
|
||||||
|
DMONEY: "dmoney",
|
||||||
|
CAC_BANK: "cac-bank",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure payment-gateway adapter. Owns intents, provider calls and webhooks — and
|
||||||
|
* NOTHING domain-specific: it never loads a booking, computes an amount, or
|
||||||
|
* advances a domain record. On settlement it notifies billing directly
|
||||||
|
* ({@link BillingService.settleByPaymentId}); billing (and through it, the domain)
|
||||||
|
* reacts. The billing↔payment pair is a deliberate forwardRef cycle.
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentService {
|
export class PaymentService {
|
||||||
private readonly logger = new Logger(PaymentService.name);
|
private readonly logger = new Logger(PaymentService.name);
|
||||||
@@ -53,9 +102,8 @@ export class PaymentService {
|
|||||||
private readonly datasource: DataSource,
|
private readonly datasource: DataSource,
|
||||||
private readonly paymentRepo: PaymentRepository,
|
private readonly paymentRepo: PaymentRepository,
|
||||||
private readonly paymentClient: PaymentClientService,
|
private readonly paymentClient: PaymentClientService,
|
||||||
@Inject(forwardRef(() => BookingBatchService))
|
@Inject(forwardRef(() => BillingService))
|
||||||
private readonly bookingBatchService: BookingBatchService,
|
private readonly billing: BillingService,
|
||||||
private readonly firstMileService: FirstMileService,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getAll(filters: {
|
async getAll(filters: {
|
||||||
@@ -123,7 +171,6 @@ export class PaymentService {
|
|||||||
total += row.count;
|
total += row.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum of successfully collected amounts.
|
|
||||||
const paidAgg = await this.paymentRepo
|
const paidAgg = await this.paymentRepo
|
||||||
.createQueryBuilder("payment")
|
.createQueryBuilder("payment")
|
||||||
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
.select("COALESCE(SUM(payment.amount), 0)", "sum")
|
||||||
@@ -141,65 +188,74 @@ export class PaymentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async initiatePayment(dto: InitiatePaymentDto): Promise<InitiateResponseDto> {
|
/**
|
||||||
const booking = await this.datasource
|
* Open a gateway intent for a caller-supplied amount/reference and project it
|
||||||
.getRepository(Booking)
|
* locally. Returns the intent id (so billing can correlate the invoice) plus
|
||||||
.findOneBy({ id: dto.bookingId });
|
* the client action. When the provider settles synchronously, the intent is
|
||||||
if (!booking) throw new NotFoundException("Booking not found");
|
* marked paid WITHOUT emitting — the caller (billing) settles inline after it
|
||||||
|
* has stored the intent id, avoiding a settle-before-correlation race.
|
||||||
const amountMinor = Math.round(Number(booking.totalAmount));
|
*/
|
||||||
|
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||||
const snapshot = await this.paymentClient.initiate({
|
const snapshot = await this.paymentClient.initiate({
|
||||||
service: PaymentServiceEnum.FREIGHT,
|
service: PaymentServiceEnum.FREIGHT,
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: input.referenceType,
|
||||||
referenceId: booking.id,
|
referenceId: input.referenceId,
|
||||||
orderRef: booking.reference,
|
orderRef: input.orderRef,
|
||||||
amountMinor,
|
amountMinor: input.amountMinor,
|
||||||
currency: booking.paymentCurrency,
|
currency: input.currency,
|
||||||
provider: dto.method as unknown as ProviderMethod,
|
provider: input.method as ProviderMethod,
|
||||||
platform: dto.platform,
|
platform: input.platform,
|
||||||
payerAccount: dto.payerAccount,
|
payerAccount: input.payerAccount,
|
||||||
returnUrl:'https://edrfreight.triaplc.com/payment/success',
|
returnUrl: input.returnUrl ?? "https://edrfreight.triaplc.com/payment/success",
|
||||||
failureUrl: 'https://edrfreight.triaplc.com/payment/failure',
|
failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure",
|
||||||
});
|
});
|
||||||
|
|
||||||
const intent = await this.syncIntentProjection(booking.id, booking, snapshot);
|
const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED;
|
||||||
|
const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined;
|
||||||
|
|
||||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
const intent = await this.upsertIntent(input, snapshot);
|
||||||
await this.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
if (immediateSuccess) {
|
||||||
bookingId: booking.id,
|
// Settle the projection but DO NOT notify billing — billing settles
|
||||||
|
// inline once it has stored intentId on the invoice (see payInvoice),
|
||||||
|
// avoiding a settle-before-correlation race.
|
||||||
|
await this.markIntentSucceeded(intent.id, {
|
||||||
providerTxnId: snapshot.providerTxnId,
|
providerTxnId: snapshot.providerTxnId,
|
||||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
paidAt,
|
||||||
|
notify: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.formatIntentResponse(intent);
|
return {
|
||||||
|
intentId: intent.id,
|
||||||
|
// `intent` still reflects the projection status ("processing" on immediate
|
||||||
|
// success — settlement is applied by the caller, not shown synchronously).
|
||||||
|
response: this.formatIntentResponse(intent),
|
||||||
|
immediateSuccess,
|
||||||
|
providerTxnId: snapshot.providerTxnId,
|
||||||
|
paidAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncIntentProjection(
|
/** Create or update the local intent projection from a provider snapshot. */
|
||||||
bookingId: string,
|
private async upsertIntent(
|
||||||
booking: Booking,
|
input: InitiateIntentInput,
|
||||||
snapshot: PaymentIntentSnapshot,
|
snapshot: PaymentIntentSnapshot,
|
||||||
): Promise<PaymentEntity> {
|
): Promise<PaymentEntity> {
|
||||||
const existing = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
const existing = await this.paymentRepo.findOneBy({
|
||||||
|
refId: input.referenceId,
|
||||||
|
});
|
||||||
|
|
||||||
const PROVIDER_TO_METHOD: Record<string, PaymentEntity["method"]> = {
|
|
||||||
TELEBIRR: "telebirr",
|
|
||||||
CBE_BIRR: "cbe-birr",
|
|
||||||
EBIRR: "ebirr",
|
|
||||||
WAAFI: "waafi",
|
|
||||||
CARD: "card",
|
|
||||||
DMONEY: "dmoney",
|
|
||||||
CAC_BANK: "cac-bank",
|
|
||||||
};
|
|
||||||
const method: PaymentEntity["method"] =
|
const method: PaymentEntity["method"] =
|
||||||
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
PROVIDER_TO_METHOD[snapshot.provider ?? ""] ?? "telebirr";
|
||||||
const status = snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
const status =
|
||||||
? "processing"
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED
|
||||||
: this.toLocalStatus(snapshot.status);
|
? "processing"
|
||||||
|
: this.toLocalStatus(snapshot.status);
|
||||||
|
|
||||||
const clientAction = (snapshot.clientAction ?? undefined) as Record<string, unknown> | undefined;
|
const clientAction = (snapshot.clientAction ?? undefined) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
const data = {
|
const data = {
|
||||||
status,
|
status,
|
||||||
method,
|
method,
|
||||||
@@ -216,30 +272,37 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.paymentRepo.create({
|
return this.paymentRepo.create({
|
||||||
refId: bookingId,
|
refId: input.referenceId,
|
||||||
type: "booking",
|
type: input.source,
|
||||||
amount: booking.totalAmount,
|
referenceType: input.referenceType,
|
||||||
currency: booking.paymentCurrency,
|
amount: input.amountMinor,
|
||||||
reason: `Payment for booking ${booking.reference}`,
|
currency: input.currency as PaymentEntity["currency"],
|
||||||
|
reason: input.reason ?? `Payment for ${input.orderRef}`,
|
||||||
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
rawInitiation: snapshot as unknown as Record<string, unknown>,
|
||||||
clientAction: clientAction ?? {},
|
clientAction: clientAction ?? {},
|
||||||
...data,
|
...data,
|
||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getIntentByBookingId(bookingId: string): Promise<IntentStatusDto> {
|
/**
|
||||||
const local = await this.paymentRepo.findOneBy({ refId: bookingId, type: "booking" });
|
* Reconcile an intent's status with the gateway by reference. Read-only on the
|
||||||
|
* domain side: it syncs the local projection and, when the provider reports a
|
||||||
|
* newly-observed success, notifies billing to settle. `referenceId` is opaque
|
||||||
|
* (the booking id, but this service does not load it).
|
||||||
|
*/
|
||||||
|
async getIntentByBookingId(referenceId: string): Promise<IntentStatusDto> {
|
||||||
|
const local = await this.paymentRepo.findOneBy({ refId: referenceId });
|
||||||
|
|
||||||
let snapshot: PaymentIntentSnapshot | null = null;
|
let snapshot: PaymentIntentSnapshot | null = null;
|
||||||
try {
|
try {
|
||||||
snapshot = await this.paymentClient.getIntentByReference(
|
snapshot = await this.paymentClient.getIntentByReference(
|
||||||
PaymentReferenceType.SHIPMENT,
|
(local?.referenceType as PaymentReferenceType) ?? PaymentReferenceType.SHIPMENT,
|
||||||
bookingId,
|
referenceId,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`payment service lookup failed for booking ${bookingId}: ${message}; using local intent`,
|
`payment service lookup failed for reference ${referenceId}: ${message}; using local intent`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,80 +310,59 @@ export class PaymentService {
|
|||||||
if (!local) throw new NotFoundException("PaymentIntent not found");
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
return this.formatIntentStatus(local);
|
return this.formatIntentStatus(local);
|
||||||
}
|
}
|
||||||
|
if (!local) throw new NotFoundException("PaymentIntent not found");
|
||||||
|
|
||||||
const booking = await this.datasource
|
// Sync local projection with provider-reported status.
|
||||||
.getRepository(Booking)
|
const becameSuccess =
|
||||||
.findOneBy({ id: bookingId });
|
snapshot.status === ProviderPaymentStatus.SUCCEEDED && local.status !== "success";
|
||||||
|
|
||||||
if (!booking) throw new NotFoundException("Booking not found");
|
if (becameSuccess) {
|
||||||
|
await this.markIntentSucceeded(local.id, {
|
||||||
const intent = await this.syncIntentProjection(bookingId, booking, snapshot);
|
|
||||||
|
|
||||||
if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) {
|
|
||||||
await this.finalizePaymentSuccess({
|
|
||||||
intentId: intent.id,
|
|
||||||
bookingId: booking.id,
|
|
||||||
providerTxnId: snapshot.providerTxnId,
|
providerTxnId: snapshot.providerTxnId,
|
||||||
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
paidAt: snapshot.paidAt ? new Date(snapshot.paidAt) : undefined,
|
||||||
|
notify: true,
|
||||||
});
|
});
|
||||||
|
} else if (snapshot.status !== ProviderPaymentStatus.SUCCEEDED) {
|
||||||
|
await this.paymentRepo.update(
|
||||||
|
{ id: local.id },
|
||||||
|
{
|
||||||
|
status: this.toLocalStatus(snapshot.status),
|
||||||
|
failerCode: snapshot.failureCode ?? undefined,
|
||||||
|
failureMessage: snapshot.failureMessage ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await this.paymentRepo.findOneBy({ id: intent.id });
|
const refreshed = await this.paymentRepo.findOneBy({ id: local.id });
|
||||||
return this.formatIntentStatus(refreshed ?? intent);
|
return this.formatIntentStatus(refreshed ?? local);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refund(dto: RefundDto) {
|
/**
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
* Mark a gateway intent paid and (by default) notify billing to settle the
|
||||||
if (!intent || intent.status !== "success") {
|
* linked invoice. Idempotent — no-op when already success. Pass `notify: false`
|
||||||
throw new BadRequestException("No successful payment to refund");
|
* when the caller settles inline and will trigger settlement itself.
|
||||||
}
|
*/
|
||||||
|
async markIntentSucceeded(
|
||||||
await this.datasource.transaction(async (mg) => {
|
intentId: string,
|
||||||
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
opts: { providerTxnId?: string; paidAt?: Date; notify?: boolean } = {},
|
||||||
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
): Promise<{ alreadyFinalized: boolean }> {
|
||||||
});
|
const intent = await this.paymentRepo.findOneBy({ id: intentId });
|
||||||
|
|
||||||
return { refunded: true, bookingId: dto.bookingId };
|
|
||||||
}
|
|
||||||
|
|
||||||
async finalizePaymentSuccess(input: {
|
|
||||||
intentId: string;
|
|
||||||
bookingId: string;
|
|
||||||
providerTxnId?: string;
|
|
||||||
paidAt?: Date;
|
|
||||||
}): Promise<{ alreadyFinalized: boolean }> {
|
|
||||||
const intent = await this.paymentRepo.findOneBy({ id: input.intentId });
|
|
||||||
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
if (!intent) throw new NotFoundException("PaymentIntent not found");
|
||||||
if (intent.status === "success") return { alreadyFinalized: true };
|
if (intent.status === "success") return { alreadyFinalized: true };
|
||||||
|
|
||||||
const paidAt = input.paidAt ?? new Date();
|
const paidAt = opts.paidAt ?? new Date();
|
||||||
|
await this.paymentRepo.update(
|
||||||
|
{ id: intent.id },
|
||||||
|
{ status: "success", paidAt, transactionId: opts.providerTxnId ?? intent.transactionId },
|
||||||
|
);
|
||||||
|
|
||||||
// Every booking is a real shipment now (contracts are a separate aggregate),
|
if (opts.notify !== false) {
|
||||||
// so payment always settles the booking to PAID and enters allocation.
|
await this.billing.settleByPaymentId(intent.id, opts.providerTxnId, paidAt);
|
||||||
await this.datasource.transaction(async (mg) => {
|
|
||||||
await mg.update(
|
|
||||||
PaymentEntity,
|
|
||||||
{ id: intent.id },
|
|
||||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
|
||||||
);
|
|
||||||
await mg.update(
|
|
||||||
Booking,
|
|
||||||
{ id: input.bookingId },
|
|
||||||
{ paymentStatus: "PAID", status: "PAID" },
|
|
||||||
);
|
|
||||||
await this.firstMileService.acceptBooking(input.bookingId);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
`Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { alreadyFinalized: false };
|
return { alreadyFinalized: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async markPaymentFailed(input: {
|
async markPaymentFailed(input: {
|
||||||
intentId: string;
|
intentId: string;
|
||||||
@@ -335,6 +377,29 @@ export class PaymentService {
|
|||||||
{ id: intent.id },
|
{ id: intent.id },
|
||||||
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
{ status: "failed", failerCode: input.failureCode, failureMessage: input.failureMessage },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Invoice stays open for retry — nothing to settle. Logged only.
|
||||||
|
this.logger.warn(
|
||||||
|
`Payment ${intent.id} failed for ${intent.refId}` +
|
||||||
|
(input.failureMessage ? `: ${input.failureMessage}` : ""),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refund(dto: RefundDto) {
|
||||||
|
const intent = await this.paymentRepo.findOneBy({ refId: dto.bookingId, type: "booking" });
|
||||||
|
if (!intent || intent.status !== "success") {
|
||||||
|
throw new BadRequestException("No successful payment to refund");
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: refunding still mutates the booking directly — left intact pending
|
||||||
|
// the refund redesign. TODO: route refunds through billing.refundPayable +
|
||||||
|
// a `${source}.invoice.refunded` reaction, like settlement.
|
||||||
|
await this.datasource.transaction(async (mg) => {
|
||||||
|
await mg.update(PaymentEntity, { id: intent.id }, { status: "refunded", refundedAt: new Date() });
|
||||||
|
await mg.update(Booking, { id: dto.bookingId }, { paymentStatus: "FAILED", status: "CANCELLED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { refunded: true, bookingId: dto.bookingId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||||
@@ -363,7 +428,7 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findBookingById(id: string) {
|
findBookingById(id: string) {
|
||||||
return this.paymentRepo.findOneBy({ refId: id, type: "booking" });
|
return this.paymentRepo.findOneBy({ refId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||||
@@ -398,24 +463,37 @@ export class PaymentService {
|
|||||||
failureCode?: string;
|
failureCode?: string;
|
||||||
failureMessage?: string;
|
failureMessage?: string;
|
||||||
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
}): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> {
|
||||||
|
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||||
if (event.eventType === "payment.succeeded") {
|
if (event.eventType === "payment.succeeded") {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
const { alreadyFinalized } = await this.finalizePaymentSuccess({
|
console.log(`Processing payment succeeded event for intent: }`,intent);
|
||||||
intentId: intent.id,
|
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||||
bookingId: event.referenceId,
|
|
||||||
providerTxnId: event.providerTxnId,
|
providerTxnId: event.providerTxnId,
|
||||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||||
|
notify: true,
|
||||||
});
|
});
|
||||||
|
console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||||
|
|
||||||
|
// When the intent references a booking, flip the booking itself paid.
|
||||||
|
// refId holds the booking id (the domain reference the intent opened with).
|
||||||
|
if (intent.referenceType === PaymentReferenceType.BOOKING) {
|
||||||
|
await this.datasource.manager.update(
|
||||||
|
Booking,
|
||||||
|
{ id: intent.refId },
|
||||||
|
{ status: "PAID", paymentStatus: "PAID" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`);
|
||||||
return { processed: true, alreadyFinalized };
|
return { processed: true, alreadyFinalized };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.eventType === "payment.failed") {
|
if (event.eventType === "payment.failed") {
|
||||||
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" });
|
const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId });
|
||||||
if (!intent) {
|
if (!intent) {
|
||||||
return { processed: false, reason: `No local intent for booking ${event.referenceId}` };
|
return { processed: false, reason: `No local intent for reference ${event.referenceId}` };
|
||||||
}
|
}
|
||||||
await this.markPaymentFailed({
|
await this.markPaymentFailed({
|
||||||
intentId: intent.id,
|
intentId: intent.id,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
|
|||||||
|
|
||||||
/** How long a selected commercial customer has to pay before their slot expires. */
|
/** How long a selected commercial customer has to pay before their slot expires. */
|
||||||
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
|
export const PAYMENT_WINDOW_MS = 180 * 60 * 1000; // 5 minutes (test mode)
|
||||||
|
|
||||||
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
|
||||||
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
export const DEFAULT_WAGONS_PER_BOOKING = 1;
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export interface ImportTrainItemRow {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
allocatedWeightTons: number | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
@@ -59,6 +62,9 @@ export interface ExportTrainItemRow {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
allocatedWeightTons: number | null;
|
||||||
itemType: 'CONTAINER' | 'CARGO';
|
itemType: 'CONTAINER' | 'CARGO';
|
||||||
itemId: string | null;
|
itemId: string | null;
|
||||||
inventoryId: string | null;
|
inventoryId: string | null;
|
||||||
@@ -265,6 +271,9 @@ export class SchedulingReadFacade {
|
|||||||
b.reference AS "bookingReference",
|
b.reference AS "bookingReference",
|
||||||
b.company_id AS "customerId",
|
b.company_id AS "customerId",
|
||||||
company.name AS "customerName",
|
company.name AS "customerName",
|
||||||
|
w.wagon_number AS "wagonNumber",
|
||||||
|
tsw.sequence_no AS "sequenceNo",
|
||||||
|
wba.allocated_weight_tons AS "allocatedWeightTons",
|
||||||
(SELECT c.container_number FROM freight.containers c
|
(SELECT c.container_number FROM freight.containers c
|
||||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||||
@@ -279,11 +288,24 @@ export class SchedulingReadFacade {
|
|||||||
FROM freight.train_schedule_bookings tsb
|
FROM freight.train_schedule_bookings tsb
|
||||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
|
||||||
|
AND wba.deleted_at IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight.train_set_wagons tsw_match
|
||||||
|
WHERE tsw_match.id = wba.train_set_wagon_id
|
||||||
|
AND tsw_match.train_set_id = ts.train_set_id
|
||||||
|
AND tsw_match.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||||
|
AND tsw.train_set_id = ts.train_set_id
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||||
ORDER BY b.reference ASC NULLS LAST`,
|
ORDER BY tsw.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST`,
|
||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
@@ -401,6 +423,9 @@ export class SchedulingReadFacade {
|
|||||||
b.reference,
|
b.reference,
|
||||||
b.company_id,
|
b.company_id,
|
||||||
company.name AS customer_name,
|
company.name AS customer_name,
|
||||||
|
w.wagon_number,
|
||||||
|
tsw.sequence_no,
|
||||||
|
wba.allocated_weight_tons,
|
||||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
|
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS cargo_type,
|
||||||
b.cargo_total_weight_vgm AS booking_weight,
|
b.cargo_total_weight_vgm AS booking_weight,
|
||||||
oy.code AS origin,
|
oy.code AS origin,
|
||||||
@@ -412,6 +437,19 @@ export class SchedulingReadFacade {
|
|||||||
FROM freight.train_schedule_bookings tsb
|
FROM freight.train_schedule_bookings tsb
|
||||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.wagon_booking_allocations wba ON wba.booking_id = b.id
|
||||||
|
AND wba.deleted_at IS NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM freight.train_set_wagons tsw_match
|
||||||
|
WHERE tsw_match.id = wba.train_set_wagon_id
|
||||||
|
AND tsw_match.train_set_id = ts.train_set_id
|
||||||
|
AND tsw_match.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
LEFT JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||||
|
AND tsw.train_set_id = ts.train_set_id
|
||||||
|
AND tsw.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id AND w.deleted_at IS NULL
|
||||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||||
@@ -423,6 +461,9 @@ export class SchedulingReadFacade {
|
|||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
a.company_id AS "customerId",
|
a.company_id AS "customerId",
|
||||||
a.customer_name AS "customerName",
|
a.customer_name AS "customerName",
|
||||||
|
a.wagon_number AS "wagonNumber",
|
||||||
|
a.sequence_no AS "sequenceNo",
|
||||||
|
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||||
'CONTAINER' AS "itemType",
|
'CONTAINER' AS "itemType",
|
||||||
c.id AS "itemId",
|
c.id AS "itemId",
|
||||||
a.inventory_id AS "inventoryId",
|
a.inventory_id AS "inventoryId",
|
||||||
@@ -441,6 +482,9 @@ export class SchedulingReadFacade {
|
|||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
a.company_id AS "customerId",
|
a.company_id AS "customerId",
|
||||||
a.customer_name AS "customerName",
|
a.customer_name AS "customerName",
|
||||||
|
a.wagon_number AS "wagonNumber",
|
||||||
|
a.sequence_no AS "sequenceNo",
|
||||||
|
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||||
'CARGO' AS "itemType",
|
'CARGO' AS "itemType",
|
||||||
cg.id AS "itemId",
|
cg.id AS "itemId",
|
||||||
a.inventory_id AS "inventoryId",
|
a.inventory_id AS "inventoryId",
|
||||||
@@ -460,6 +504,9 @@ export class SchedulingReadFacade {
|
|||||||
a.reference AS "bookingReference",
|
a.reference AS "bookingReference",
|
||||||
a.company_id AS "customerId",
|
a.company_id AS "customerId",
|
||||||
a.customer_name AS "customerName",
|
a.customer_name AS "customerName",
|
||||||
|
a.wagon_number AS "wagonNumber",
|
||||||
|
a.sequence_no AS "sequenceNo",
|
||||||
|
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||||
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
CASE WHEN a.cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
||||||
a.inventory_id AS "itemId",
|
a.inventory_id AS "itemId",
|
||||||
a.inventory_id AS "inventoryId",
|
a.inventory_id AS "inventoryId",
|
||||||
@@ -474,7 +521,7 @@ export class SchedulingReadFacade {
|
|||||||
FROM assigned a
|
FROM assigned a
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
|
WHERE NOT EXISTS (SELECT 1 FROM freight.containers c WHERE c.booking_id = a.booking_id AND c.deleted_at IS NULL)
|
||||||
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
|
AND NOT EXISTS (SELECT 1 FROM freight.cargoes cg WHERE cg.booking_id = a.booking_id AND cg.deleted_at IS NULL)
|
||||||
ORDER BY "bookingReference" ASC NULLS LAST, "itemType" ASC`,
|
ORDER BY "sequenceNo" ASC NULLS LAST, "bookingReference" ASC NULLS LAST, "itemType" ASC`,
|
||||||
[scheduleId],
|
[scheduleId],
|
||||||
);
|
);
|
||||||
return rows;
|
return rows;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@@ -273,6 +273,25 @@ export class WarehouseInventoryController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/handover-document')
|
||||||
|
@ApiOperation({ summary: 'View import goods handover document PDF' })
|
||||||
|
async handoverDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
|
||||||
|
const { filename, buffer } = await this.inventoryService.handoverDocument(id);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('bookings/:bookingId/approve-delivery')
|
||||||
|
@ApiOperation({ summary: "Approve delivery using the current customer's saved signature" })
|
||||||
|
approveDeliveryForBooking(
|
||||||
|
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
|
) {
|
||||||
|
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/deliver')
|
@Post(':id/deliver')
|
||||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { InterchangeDocumentsService } from '../interchange-documents/interchang
|
|||||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||||
import { LastMileService } from '../last-mile/last-mile.service';
|
import { LastMileService } from '../last-mile/last-mile.service';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { SignaturesService } from '../signatures/signatures.service';
|
||||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||||
@@ -50,6 +51,8 @@ const normalizeWagonStatus = (status: string | null | undefined) =>
|
|||||||
const isLoadableWagonStatus = (status: string | null | undefined) =>
|
const isLoadableWagonStatus = (status: string | null | undefined) =>
|
||||||
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status));
|
||||||
|
|
||||||
|
const CUSTOMER_DELIVERY_APPROVAL_PREFIX = 'CUSTOMER_DELIVERY_APPROVAL:';
|
||||||
|
|
||||||
export interface InventoryInquiryResult {
|
export interface InventoryInquiryResult {
|
||||||
id: string;
|
id: string;
|
||||||
inventoryId: string | null;
|
inventoryId: string | null;
|
||||||
@@ -297,6 +300,9 @@ export interface ImportUnloadedRow {
|
|||||||
pickupOption: string;
|
pickupOption: string;
|
||||||
lastMileRequested: boolean;
|
lastMileRequested: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
|
releaseDate: string | null;
|
||||||
|
releaseOrderReference: string | null;
|
||||||
|
deliveredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -316,6 +322,7 @@ export class WarehouseInventoryService {
|
|||||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||||
private readonly lastMileService: LastMileService,
|
private readonly lastMileService: LastMileService,
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
|
private readonly signatures: SignaturesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1020,6 +1027,9 @@ export class WarehouseInventoryService {
|
|||||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||||
inv.status AS "currentStatus",
|
inv.status AS "currentStatus",
|
||||||
|
inv.release_date AS "releaseDate",
|
||||||
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
|
inv.delivered_at AS "deliveredAt",
|
||||||
oy.country AS "originCountry",
|
oy.country AS "originCountry",
|
||||||
dy.country AS "destinationCountry"
|
dy.country AS "destinationCountry"
|
||||||
FROM freight.warehouse_inventory inv
|
FROM freight.warehouse_inventory inv
|
||||||
@@ -1049,7 +1059,16 @@ export class WarehouseInventoryService {
|
|||||||
* states), with the columns the inspection screen needs. Read-only.
|
* states), with the columns the inspection screen needs. Read-only.
|
||||||
*/
|
*/
|
||||||
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
|
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
|
||||||
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
|
return this.importQueueByStatuses([
|
||||||
|
'UNLOADED',
|
||||||
|
'DESTINATION_INSPECTION',
|
||||||
|
'UNDER_INSPECTION',
|
||||||
|
'ARRIVED_AT_WAREHOUSE',
|
||||||
|
'STORED',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'DISPATCHED',
|
||||||
|
'DELIVERED',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2020,6 +2039,154 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||||
|
async approveDeliveryForBooking(
|
||||||
|
bookingId: string,
|
||||||
|
userId?: string,
|
||||||
|
): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> {
|
||||||
|
if (!userId) {
|
||||||
|
throw new BadRequestException('Authentication is required to approve delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = await this.signatures.getForUser(userId);
|
||||||
|
if (!signature?.signatureImageUrl) {
|
||||||
|
throw new BadRequestException('Please save your signature before approving delivery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> =
|
||||||
|
await this.dataSource.query(
|
||||||
|
`SELECT inv.id,
|
||||||
|
inv.warehouse_id AS "warehouseId",
|
||||||
|
inv.notes
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||||
|
WHERE inv.booking_id = $1
|
||||||
|
AND inv.deleted_at IS NULL
|
||||||
|
AND inv.inspection_status = 'PASSED'
|
||||||
|
ORDER BY inv.updated_at DESC NULLS LAST, inv.created_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const approvedAt = new Date();
|
||||||
|
const approval = {
|
||||||
|
approvedAt: approvedAt.toISOString(),
|
||||||
|
signerDisplayName: signature.signerDisplayName,
|
||||||
|
signatureImageUrl: signature.signatureImageUrl,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
const existingNotes = this.stripCustomerDeliveryApproval(item.notes);
|
||||||
|
const approvalNote = `${CUSTOMER_DELIVERY_APPROVAL_PREFIX}${JSON.stringify(approval)}`;
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||||||
|
notes: this.appendNote(existingNotes, approvalNote),
|
||||||
|
});
|
||||||
|
await this.activityLog.record(
|
||||||
|
{
|
||||||
|
activityType: 'INVENTORY_RELEASED',
|
||||||
|
inventoryId: item.id,
|
||||||
|
warehouseId: item.warehouseId,
|
||||||
|
description: `Customer approved delivery as ${signature.signerDisplayName}`,
|
||||||
|
performedBy: signature.signerDisplayName,
|
||||||
|
},
|
||||||
|
manager,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
inventoryId: item.id,
|
||||||
|
approvedAt: approval.approvedAt,
|
||||||
|
signerDisplayName: signature.signerDisplayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const [row] = await this.dataSource.query(
|
||||||
|
`SELECT inv.id,
|
||||||
|
inv.booking_id AS "bookingId",
|
||||||
|
inv.quantity,
|
||||||
|
inv.weight,
|
||||||
|
inv.status,
|
||||||
|
inv.notes,
|
||||||
|
inv.inspection_status AS "inspectionStatus",
|
||||||
|
inv.release_date AS "releaseDate",
|
||||||
|
inv.release_order_reference AS "releaseOrderReference",
|
||||||
|
COALESCE(inv.unloaded_at, inv.arrived_at, inv.created_at) AS "handoverDate",
|
||||||
|
b.reference AS "bookingReference",
|
||||||
|
b.status AS "bookingStatus",
|
||||||
|
b.freight_type AS "freightType",
|
||||||
|
b.trade_direction AS "tradeDirection",
|
||||||
|
company.name AS "customerName",
|
||||||
|
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||||
|
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription",
|
||||||
|
wh.name AS "warehouseName",
|
||||||
|
wh.code AS "warehouseCode",
|
||||||
|
yard.name AS "yardName",
|
||||||
|
yard.code AS "yardCode",
|
||||||
|
zone.name AS "zoneName",
|
||||||
|
zone.code AS "zoneCode",
|
||||||
|
ts.train_number AS "trainSchedule"
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
LEFT JOIN freight.bookings b ON b.id = inv.booking_id
|
||||||
|
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||||
|
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||||
|
LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id
|
||||||
|
LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id
|
||||||
|
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.booking_container booking_container ON (
|
||||||
|
booking_container.booking_id = b.id
|
||||||
|
AND booking_container.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||||
|
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||||
|
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||||
|
WHERE inv.id = $1 AND inv.deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||||
|
}
|
||||||
|
if (row.inspectionStatus !== 'PASSED') {
|
||||||
|
throw new BadRequestException('Handover document is available after inspection has passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingReference = row.bookingReference || row.bookingId || 'N/A';
|
||||||
|
const html = this.buildHandoverDocumentHtml({
|
||||||
|
reference: `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`,
|
||||||
|
handedOverAt: new Date(row.handoverDate ?? Date.now()),
|
||||||
|
bookingReference,
|
||||||
|
bookingStatus: row.bookingStatus ?? null,
|
||||||
|
customerName: row.customerName ?? null,
|
||||||
|
freightType: row.freightType ?? null,
|
||||||
|
tradeDirection: row.tradeDirection ?? null,
|
||||||
|
containerNumber: row.containerNumber ?? null,
|
||||||
|
cargoDescription: row.cargoDescription ?? null,
|
||||||
|
quantity: Number(row.quantity ?? 0),
|
||||||
|
weight: Number(row.weight ?? 0),
|
||||||
|
warehouse: [row.warehouseName, row.warehouseCode].filter(Boolean).join(' / ') || null,
|
||||||
|
yard: [row.yardName, row.yardCode].filter(Boolean).join(' / ') || null,
|
||||||
|
zone: [row.zoneName, row.zoneCode].filter(Boolean).join(' / ') || null,
|
||||||
|
inventoryStatus: row.status ?? null,
|
||||||
|
inspectionStatus: row.inspectionStatus ?? null,
|
||||||
|
releaseOrderReference: row.releaseOrderReference ?? null,
|
||||||
|
releaseDate: row.releaseDate ? new Date(row.releaseDate) : null,
|
||||||
|
trainSchedule: row.trainSchedule ?? null,
|
||||||
|
customerApproval: this.extractCustomerDeliveryApproval(row.notes),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: `handover-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
|
buffer: await this.releaseDocuments.htmlToPdfBuffer(html),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
|
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
|
||||||
const item = await this.findById(id);
|
const item = await this.findById(id);
|
||||||
this.assertTransition(item.status, 'DELIVERED');
|
this.assertTransition(item.status, 'DELIVERED');
|
||||||
@@ -2619,6 +2786,183 @@ export class WarehouseInventoryService {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildHandoverDocumentHtml(data: {
|
||||||
|
reference: string;
|
||||||
|
handedOverAt: Date;
|
||||||
|
bookingReference: string;
|
||||||
|
bookingStatus: string | null;
|
||||||
|
customerName: string | null;
|
||||||
|
freightType: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
containerNumber: string | null;
|
||||||
|
cargoDescription: string | null;
|
||||||
|
quantity: number;
|
||||||
|
weight: number;
|
||||||
|
warehouse: string | null;
|
||||||
|
yard: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
inventoryStatus: string | null;
|
||||||
|
inspectionStatus: string | null;
|
||||||
|
releaseOrderReference: string | null;
|
||||||
|
releaseDate: Date | null;
|
||||||
|
trainSchedule: string | null;
|
||||||
|
customerApproval: {
|
||||||
|
approvedAt: string;
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl: string;
|
||||||
|
} | null;
|
||||||
|
}): string {
|
||||||
|
const esc = (value: unknown) =>
|
||||||
|
String(value ?? '-')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
const fmt = (date: Date | string | null) => {
|
||||||
|
if (!date) return '-';
|
||||||
|
const parsed = date instanceof Date ? date : new Date(date);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '-';
|
||||||
|
return parsed.toLocaleString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const rows = [
|
||||||
|
['Booking Reference', data.bookingReference],
|
||||||
|
['Customer / Consignee', data.customerName],
|
||||||
|
['Booking Status', data.bookingStatus],
|
||||||
|
['Freight Type', data.freightType],
|
||||||
|
['Trade Direction', data.tradeDirection],
|
||||||
|
['Train Schedule', data.trainSchedule],
|
||||||
|
['Container Number', data.containerNumber],
|
||||||
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
|
['Quantity', data.quantity],
|
||||||
|
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
||||||
|
['Warehouse', data.warehouse],
|
||||||
|
['Yard', data.yard],
|
||||||
|
['Zone', data.zone],
|
||||||
|
['Inventory Status', data.inventoryStatus],
|
||||||
|
['Inspection Status', data.inspectionStatus],
|
||||||
|
['Release Order', data.releaseOrderReference],
|
||||||
|
['Release Date', fmt(data.releaseDate)],
|
||||||
|
];
|
||||||
|
const approval = data.customerApproval;
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Import Goods Handover Document</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
@page { size: A4; margin: 12mm 15mm 14mm; }
|
||||||
|
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 0; background: #fff; }
|
||||||
|
.top { display: grid; grid-template-columns: 1fr 190px; gap: 26px; border-top: 5px solid #2a2a2a; padding-top: 18px; }
|
||||||
|
.brand { font-size: 12px; color: #064c27; text-transform: uppercase; letter-spacing: .13em; font-weight: 800; }
|
||||||
|
h1 { margin: 8px 0 0; max-width: 380px; font-size: 29px; line-height: .98; text-transform: uppercase; letter-spacing: .02em; }
|
||||||
|
.subtitle { margin-top: 12px; font-size: 11px; color: #3d516a; text-transform: uppercase; letter-spacing: .14em; }
|
||||||
|
.ref { text-align: right; font-size: 11px; color: #334155; padding-top: 8px; }
|
||||||
|
.ref strong { display: block; color: #061323; font-size: 18px; margin: 5px 0 8px; letter-spacing: .02em; }
|
||||||
|
.rule { height: 3px; background: #064c27; margin: 16px 0 22px; }
|
||||||
|
.notice { width: 78%; margin: 0 0 18px; padding: 13px 18px; background: #f3fff6; border: 1px solid #61d98b; border-left: 5px solid #16743d; font-size: 13px; line-height: 1.45; }
|
||||||
|
.section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .12em; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { width: 31%; text-align: left; color: #0f2744; background: #f8fafc; font-weight: 800; }
|
||||||
|
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12.2px; vertical-align: top; }
|
||||||
|
.clause { margin-top: 14px; border: 1px solid #b9c7d8; padding: 12px 15px; font-size: 12.2px; line-height: 1.45; }
|
||||||
|
.signatures { display: grid; grid-template-columns: 1fr 96px 1.45fr; gap: 22px; align-items: start; margin-top: 42px; }
|
||||||
|
.line { border-top: 1.4px solid #061323; padding-top: 7px; font-size: 10.8px; color: #24384f; min-height: 72px; }
|
||||||
|
.signature-img { display: block; max-width: 210px; max-height: 58px; margin: 2px 0 6px; object-fit: contain; }
|
||||||
|
.signature-meta { font-size: 11px; color: #061323; }
|
||||||
|
.seal { position: relative; width: 96px; height: 96px; margin: -28px auto 0; border: 3px double #17633a; border-radius: 999px; color: #17633a; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 14px; line-height: 1.05; transform: rotate(-17deg); text-transform: uppercase; }
|
||||||
|
.seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; }
|
||||||
|
.seal span { position: relative; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="top">
|
||||||
|
<div>
|
||||||
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
|
<h1>Import Goods Handover Document</h1>
|
||||||
|
<div class="subtitle">EDR to customer warehouse handover</div>
|
||||||
|
</div>
|
||||||
|
<div class="ref">
|
||||||
|
Document No.
|
||||||
|
<strong>${esc(data.reference)}</strong>
|
||||||
|
Handover: ${esc(fmt(data.handedOverAt))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="rule"></div>
|
||||||
|
<div class="notice">
|
||||||
|
This document confirms EDR handed over the listed import goods to the customer after warehouse inspection passed.
|
||||||
|
</div>
|
||||||
|
<div class="section-title">Handover Particulars</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
${rows.map(([label, value]) => `<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-title">Goods List</div>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><th>1. Goods</th><td>${esc(data.cargoDescription || data.containerNumber || data.bookingReference)}</td></tr>
|
||||||
|
<tr><th>Container</th><td>${esc(data.containerNumber)}</td></tr>
|
||||||
|
<tr><th>Weight</th><td>${esc(`${data.weight.toLocaleString()} kg`)}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="section-title">Handover Clause</div>
|
||||||
|
<div class="clause">
|
||||||
|
The customer acknowledges receipt of the goods listed above. Warehouse staff shall verify identity, booking reference,
|
||||||
|
inspection status, and release records before final physical handover.
|
||||||
|
</div>
|
||||||
|
<div class="signatures">
|
||||||
|
<div class="line">Officer in charge name / signature / date</div>
|
||||||
|
<div class="seal"><span>EDR<br />Warehouse<br />Handover</span></div>
|
||||||
|
<div class="line">
|
||||||
|
${approval?.signatureImageUrl ? `<img class="signature-img" src="${esc(approval.signatureImageUrl)}" />` : ''}
|
||||||
|
<div class="signature-meta">${approval ? esc(approval.signerDisplayName) : 'Customer or driver name / signature / date'}</div>
|
||||||
|
<div class="signature-meta">${approval ? `Approved: ${esc(fmt(approval.approvedAt))}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractCustomerDeliveryApproval(notes?: string | null): {
|
||||||
|
approvedAt: string;
|
||||||
|
signerDisplayName: string;
|
||||||
|
signatureImageUrl: string;
|
||||||
|
} | null {
|
||||||
|
if (!notes) return null;
|
||||||
|
const line = notes
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.find((entry) => entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||||||
|
if (!line) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line.slice(CUSTOMER_DELIVERY_APPROVAL_PREFIX.length));
|
||||||
|
if (!parsed?.approvedAt || !parsed?.signerDisplayName || !parsed?.signatureImageUrl) return null;
|
||||||
|
return {
|
||||||
|
approvedAt: String(parsed.approvedAt),
|
||||||
|
signerDisplayName: String(parsed.signerDisplayName),
|
||||||
|
signatureImageUrl: String(parsed.signatureImageUrl),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stripCustomerDeliveryApproval(notes?: string | null): string | null {
|
||||||
|
if (!notes?.trim()) return null;
|
||||||
|
const lines = notes
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((entry) => !entry.startsWith(CUSTOMER_DELIVERY_APPROVAL_PREFIX));
|
||||||
|
return lines.join('\n').trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||||||
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||||||
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { FilesModule } from '../files/files.module';
|
|||||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { SignaturesModule } from '../signatures/signatures.module';
|
||||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||||
@@ -73,6 +74,7 @@ import { WarehousesService } from './warehouses.service';
|
|||||||
InterchangeDocumentsModule,
|
InterchangeDocumentsModule,
|
||||||
forwardRef(() => LastMileModule),
|
forwardRef(() => LastMileModule),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
SignaturesModule,
|
||||||
ExchangeModule.forRootAsync({
|
ExchangeModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
process.env.TYPEORM_LOGGING = 'false';
|
||||||
|
|
||||||
|
import { AppModule } from '../app.module';
|
||||||
|
import { deriveTradeDirection } from '../common/derive-trade-direction.util';
|
||||||
|
import { WarehouseInventoryService } from '../modules/warehouses/warehouse-inventory.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ['error', 'warn'],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataSource = app.get(DataSource);
|
||||||
|
const inventory = app.get(WarehouseInventoryService);
|
||||||
|
|
||||||
|
const schedules: {
|
||||||
|
id: string;
|
||||||
|
trainNumber: string | null;
|
||||||
|
originCountry: string | null;
|
||||||
|
destinationCountry: string | null;
|
||||||
|
}[] = await dataSource.query(
|
||||||
|
`SELECT ts.id,
|
||||||
|
ts.train_number AS "trainNumber",
|
||||||
|
oy.country AS "originCountry",
|
||||||
|
dy.country AS "destinationCountry"
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
|
WHERE ts.status = 'ARRIVED'
|
||||||
|
AND ts.deleted_at IS NULL
|
||||||
|
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST,
|
||||||
|
ts.created_at DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const importSchedules = schedules.filter(
|
||||||
|
(schedule) =>
|
||||||
|
deriveTradeDirection(
|
||||||
|
{ country: schedule.originCountry },
|
||||||
|
{ country: schedule.destinationCountry },
|
||||||
|
) === 'IMPORT',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (importSchedules.length === 0) {
|
||||||
|
console.log('No ARRIVED import trains found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const schedule of importSchedules) {
|
||||||
|
const result = await inventory.autoUnloadArrivedBookings(
|
||||||
|
schedule.id,
|
||||||
|
'Demo Auto Unload',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`${schedule.trainNumber ?? schedule.id}: ${result.unloadedCount} unloaded, ${result.skippedCount} skipped, ${result.failedCount} failed`,
|
||||||
|
);
|
||||||
|
for (const item of result.results) {
|
||||||
|
console.log(` - ${item.bookingId}: ${item.status}${item.reason ? ` (${item.reason})` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueRows = await inventory.importUnloadedQueue();
|
||||||
|
console.log(`Import Unloaded Queue rows now visible: ${queueRows.length}`);
|
||||||
|
const byStatus = queueRows.reduce<Record<string, number>>((acc, row) => {
|
||||||
|
acc[row.currentStatus] = (acc[row.currentStatus] ?? 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
for (const [status, count] of Object.entries(byStatus)) {
|
||||||
|
console.log(` ${status}: ${count}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
28
apps/edr-freight-api/src/scripts/seed-gov-companies.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import "reflect-metadata";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
|
config({ path: resolve(__dirname, "../../.env") });
|
||||||
|
|
||||||
|
import { NestFactory } from "@nestjs/core";
|
||||||
|
import { AppModule } from "../app.module";
|
||||||
|
import { GovCompaniesSeeder } from "../seed/gov-companies.seeder";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||||
|
logger: ["error", "warn", "log"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seeder = app.get(GovCompaniesSeeder);
|
||||||
|
await seeder.run();
|
||||||
|
console.log("Government companies seeded.");
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Government companies seed failed:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import { WagonStatus } from '@edr/types';
|
||||||
|
|
||||||
config({ path: resolve(__dirname, '../../.env') });
|
config({ path: resolve(__dirname, '../../.env') });
|
||||||
|
|
||||||
import { AppDataSource } from '../data-source';
|
import { AppDataSource } from '../data-source';
|
||||||
|
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||||
|
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||||
|
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||||
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
|
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||||
|
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
|
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||||
|
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||||
|
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||||
|
|
||||||
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
||||||
|
const BOOKING_REFS = ['NEGAD-INDODE-BKG-001', 'NEGAD-INDODE-BKG-002', 'NEGAD-INDODE-BKG-003'] as const;
|
||||||
|
|
||||||
function addHours(date: Date, hours: number): Date {
|
function addHours(date: Date, hours: number): Date {
|
||||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||||
@@ -25,18 +39,34 @@ async function main() {
|
|||||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||||
const trainSetRepo = manager.getRepository(TrainSet);
|
const trainSetRepo = manager.getRepository(TrainSet);
|
||||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||||
|
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||||
|
const wagonRepo = manager.getRepository(Wagon);
|
||||||
|
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
|
||||||
|
const serviceTypeRepo = manager.getRepository(ServiceType);
|
||||||
|
const containerTypeRepo = manager.getRepository(ContainerType);
|
||||||
|
const companyRepo = manager.getRepository(Company);
|
||||||
|
const bookingRepo = manager.getRepository(Booking);
|
||||||
|
const bookingContainerRepo = manager.getRepository(BookingContainer);
|
||||||
|
const scheduleBookingRepo = manager.getRepository(TrainScheduleBooking);
|
||||||
|
const allocationRepo = manager.getRepository(WagonBookingAllocation);
|
||||||
|
const containerItemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||||
|
const importOperationRepo = manager.getRepository(ImportDjiboutiOperation);
|
||||||
|
|
||||||
const negad =
|
const negad =
|
||||||
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
||||||
(await yardRepo.save(
|
(await yardRepo.save(
|
||||||
yardRepo.create({
|
yardRepo.create({
|
||||||
code: 'NEGAD',
|
code: 'NEGAD',
|
||||||
label: 'Negad',
|
label: 'Negad / Nagad',
|
||||||
country: 'Djibouti',
|
country: 'Djibouti',
|
||||||
isActive: true,
|
isActive: true,
|
||||||
displayOrder: 5,
|
displayOrder: 5,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
if (negad.label !== 'Negad / Nagad') {
|
||||||
|
negad.label = 'Negad / Nagad';
|
||||||
|
await yardRepo.save(negad);
|
||||||
|
}
|
||||||
|
|
||||||
const indode =
|
const indode =
|
||||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||||
@@ -64,6 +94,78 @@ async function main() {
|
|||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
const wagonType =
|
||||||
|
(await wagonTypeRepo.findOne({ where: { code: 'NEGAD-FLAT' } })) ??
|
||||||
|
(await wagonTypeRepo.save(
|
||||||
|
wagonTypeRepo.create({
|
||||||
|
code: 'NEGAD-FLAT',
|
||||||
|
name: 'Negad Demo Flat Wagon',
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
maxWagonsPerTrain: 53,
|
||||||
|
supportedLoadTypes: ['CONTAINER'],
|
||||||
|
isActive: true,
|
||||||
|
equatedLengthM: 14,
|
||||||
|
tareWeightTons: 20,
|
||||||
|
supportsContainer: true,
|
||||||
|
maxContainerGrossT: 70,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
const containerType =
|
||||||
|
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
|
||||||
|
(await containerTypeRepo.save(
|
||||||
|
containerTypeRepo.create({
|
||||||
|
code: '40FT',
|
||||||
|
label: '40FT',
|
||||||
|
sizeFt: 40,
|
||||||
|
wagonsPerUnit: 1,
|
||||||
|
isReefer: false,
|
||||||
|
isOpenTop: false,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
const serviceType =
|
||||||
|
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||||
|
(await serviceTypeRepo.save(
|
||||||
|
serviceTypeRepo.create({
|
||||||
|
code: 'RAIL_CONTAINER',
|
||||||
|
serviceName: 'Rail Container Service',
|
||||||
|
description: 'Rail container service for demo marshalling',
|
||||||
|
canBeBookedAlone: true,
|
||||||
|
includesFirstMile: false,
|
||||||
|
includesLastMile: false,
|
||||||
|
includesCustoms: false,
|
||||||
|
priorityBonusPoints: 0,
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
const company =
|
||||||
|
(await companyRepo.findOne({ where: { tin: 'NEGADIND01' } })) ??
|
||||||
|
(await companyRepo.save(
|
||||||
|
companyRepo.create({
|
||||||
|
name: 'Negad Indode Marshalling Demo Customer',
|
||||||
|
type: CompanyType.Customer,
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
tin: 'NEGADIND01',
|
||||||
|
vatNumber: 'NEGADIND01',
|
||||||
|
fanNumber: 'NEGADINDODE00001',
|
||||||
|
country: 'Ethiopia',
|
||||||
|
address: 'Indode Dry Port',
|
||||||
|
phone: '251900000202',
|
||||||
|
email: 'negad-indode-demo@edr.local',
|
||||||
|
contactPersonName: 'Marshalling Demo',
|
||||||
|
contactPersonPhone: '251900000202',
|
||||||
|
generalManagerName: 'Demo Manager',
|
||||||
|
generalManagerEmail: 'negad-indode-demo@edr.local',
|
||||||
|
generalManagerPhone: '251900000202',
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const departure = addHours(now, -12);
|
const departure = addHours(now, -12);
|
||||||
const arrival = now;
|
const arrival = now;
|
||||||
@@ -75,7 +177,7 @@ async function main() {
|
|||||||
locomotiveId: locomotive.id,
|
locomotiveId: locomotive.id,
|
||||||
totalWeightTons: 960,
|
totalWeightTons: 960,
|
||||||
totalLengthMeters: 420,
|
totalLengthMeters: 420,
|
||||||
wagonCount: 18,
|
wagonCount: BOOKING_REFS.length,
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -111,9 +213,169 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saved = await scheduleRepo.save(schedule);
|
const saved = await scheduleRepo.save(schedule);
|
||||||
|
await trainSetRepo.update(saved.trainSetId, {
|
||||||
|
totalWeightTons: BOOKING_REFS.length * 28,
|
||||||
|
totalLengthMeters: BOOKING_REFS.length * 14,
|
||||||
|
wagonCount: BOOKING_REFS.length,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingSlots = await trainSetWagonRepo.find({ where: { trainSetId: saved.trainSetId } });
|
||||||
|
const existingAllocations = existingSlots.length
|
||||||
|
? await allocationRepo.find({
|
||||||
|
where: existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
if (existingAllocations.length) {
|
||||||
|
await containerItemRepo.delete(
|
||||||
|
existingAllocations.map((allocation) => ({ wagonBookingAllocationId: allocation.id })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existingSlots.length) {
|
||||||
|
await allocationRepo.delete(existingSlots.map((slot) => ({ trainSetWagonId: slot.id })));
|
||||||
|
await wagonRepo.update(
|
||||||
|
existingSlots.map((slot) => ({ trainSetWagonId: slot.id })),
|
||||||
|
{
|
||||||
|
trainSetWagonId: null,
|
||||||
|
currentTrainScheduleId: null,
|
||||||
|
sequenceNumber: null,
|
||||||
|
status: WagonStatus.Available,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await trainSetWagonRepo.delete({ trainSetId: saved.trainSetId });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||||
|
const sequenceNo = index + 1;
|
||||||
|
const containerNumber = `NEGADIND${String(sequenceNo).padStart(4, '0')}`;
|
||||||
|
const weightTons = 26 + sequenceNo;
|
||||||
|
|
||||||
|
let booking = await bookingRepo.findOne({ where: { reference } });
|
||||||
|
if (!booking) {
|
||||||
|
booking = bookingRepo.create({ reference });
|
||||||
|
}
|
||||||
|
Object.assign(booking, {
|
||||||
|
companyId: company.id,
|
||||||
|
originYardId: negad.id,
|
||||||
|
destinationYardId: indode.id,
|
||||||
|
serviceTypeId: serviceType.id,
|
||||||
|
status: 'IN_TRANSIT',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
scheduledDate: departure,
|
||||||
|
estimatedShipmentDate: departure,
|
||||||
|
contractType: 'SPOT',
|
||||||
|
equipmentReturn: 'TERMINAL',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
totalAmount: 0,
|
||||||
|
isGovernment: false,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
cargoTypeId: null,
|
||||||
|
cargoFreeText: `Negad to Indode demo container ${sequenceNo}`,
|
||||||
|
cargoTotalWeightVgm: weightTons,
|
||||||
|
priorityScore: 75 - index,
|
||||||
|
trainScheduleId: saved.id,
|
||||||
|
schedulingStatus: 'SCHEDULED',
|
||||||
|
scheduledAt: now,
|
||||||
|
wagonsRequired: 1,
|
||||||
|
});
|
||||||
|
booking = await bookingRepo.save(booking);
|
||||||
|
|
||||||
|
await bookingContainerRepo.delete({ bookingId: booking.id });
|
||||||
|
const bookingContainer = await bookingContainerRepo.save(
|
||||||
|
bookingContainerRepo.create({
|
||||||
|
bookingId: booking.id,
|
||||||
|
containerTypeId: containerType.id,
|
||||||
|
containerNumber,
|
||||||
|
quantity: 1,
|
||||||
|
vgmPerUnitTons: weightTons,
|
||||||
|
totalVgmTons: weightTons,
|
||||||
|
wagonsRequired: 1,
|
||||||
|
weightLimitRuleId: null,
|
||||||
|
isOverweight: false,
|
||||||
|
overweightExcessTons: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await scheduleBookingRepo.upsert(
|
||||||
|
{ trainScheduleId: saved.id, bookingId: booking.id },
|
||||||
|
{ conflictPaths: { trainScheduleId: true, bookingId: true } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const wagon = await wagonRepo.save(
|
||||||
|
wagonRepo.create({
|
||||||
|
wagonNumber: `NEGAD-INDODE-WGN-${String(sequenceNo).padStart(2, '0')}`,
|
||||||
|
wagonTypeId: wagonType.id,
|
||||||
|
trainId: null,
|
||||||
|
sequenceNumber: sequenceNo,
|
||||||
|
tareWeight: 20,
|
||||||
|
maxPayloadWeight: 70,
|
||||||
|
status: WagonStatus.Assigned,
|
||||||
|
currentYardId: indode.id,
|
||||||
|
notes: 'Demo wagon for Negad to Indode marshalling',
|
||||||
|
trainSetWagonId: null,
|
||||||
|
currentTrainScheduleId: saved.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const trainSetWagon = await trainSetWagonRepo.save(
|
||||||
|
trainSetWagonRepo.create({
|
||||||
|
trainSetId: saved.trainSetId,
|
||||||
|
wagonTypeId: wagonType.id,
|
||||||
|
physicalWagonId: wagon.id,
|
||||||
|
sequenceNo,
|
||||||
|
capacityTons: 70,
|
||||||
|
lengthMeters: 14,
|
||||||
|
assignedWeightTons: weightTons,
|
||||||
|
status: 'LOADED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
|
||||||
|
|
||||||
|
const allocation = await allocationRepo.save(
|
||||||
|
allocationRepo.create({
|
||||||
|
trainSetWagonId: trainSetWagon.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
allocatedWeightTons: weightTons,
|
||||||
|
loadType: 'CONTAINER',
|
||||||
|
status: 'LOADED',
|
||||||
|
confirmedAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await containerItemRepo.save(
|
||||||
|
containerItemRepo.create({
|
||||||
|
wagonBookingAllocationId: allocation.id,
|
||||||
|
bookingContainerId: bookingContainer.id,
|
||||||
|
containerId: null,
|
||||||
|
containerNumber,
|
||||||
|
containerTypeId: containerType.id,
|
||||||
|
positionOnWagon: 1,
|
||||||
|
sealNumber: `SEAL-${containerNumber}`,
|
||||||
|
grossWeightTons: weightTons,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await importOperationRepo.upsert(
|
||||||
|
{
|
||||||
|
trainScheduleId: saved.id,
|
||||||
|
documents: {},
|
||||||
|
gatepassGrantedAt: departure,
|
||||||
|
readyForLoadingAt: departure,
|
||||||
|
loadedOnTrainAt: departure,
|
||||||
|
departedFromDjiboutiAt: departure,
|
||||||
|
loadListGeneratedAt: now,
|
||||||
|
performedBy: 'Seed Demo',
|
||||||
|
notes: 'Seeded marshalling data for Negad to Indode arrived train',
|
||||||
|
},
|
||||||
|
{ conflictPaths: { trainScheduleId: true } },
|
||||||
|
);
|
||||||
|
|
||||||
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
||||||
console.log(`Schedule ID: ${saved.id}`);
|
console.log(`Schedule ID: ${saved.id}`);
|
||||||
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
||||||
|
console.log(`Marshalling data: ${BOOKING_REFS.length} bookings, wagons and allocations`);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await dataSource.destroy();
|
await dataSource.destroy();
|
||||||
|
|||||||
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
136
apps/edr-freight-api/src/seed/data/gov-companies.data.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
CompanyKind,
|
||||||
|
CompanyStatus,
|
||||||
|
CompanyType,
|
||||||
|
} from "../../modules/companies/entities/company.entity";
|
||||||
|
import {
|
||||||
|
ProfileStatus,
|
||||||
|
ProfileType,
|
||||||
|
} from "../../modules/companies/entities/company-profile.entity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical list of seeded Ethiopian government entities. Government bookings
|
||||||
|
* are billed to one of these (with an explicit importer/exporter profile)
|
||||||
|
* instead of carrying a null company + free-text institution.
|
||||||
|
*
|
||||||
|
* IDs are fixed so the seeder is idempotent and the matching migration
|
||||||
|
* (1821000000003-AddCompanyKindAndGovBookingLinks) can backfill legacy rows to
|
||||||
|
* the same companies. The migration mirrors these rows in raw SQL — keep both
|
||||||
|
* in sync when adding new entities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const GOV_COMPANY_TYPE = CompanyType.Customer;
|
||||||
|
export const GOV_COMPANY_KIND = CompanyKind.Government;
|
||||||
|
export const GOV_COMPANY_STATUS = CompanyStatus.Active;
|
||||||
|
export const GOV_PROFILE_STATUS = ProfileStatus.Active;
|
||||||
|
|
||||||
|
export interface GovProfileSeed {
|
||||||
|
id: string;
|
||||||
|
type: ProfileType;
|
||||||
|
reference: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GovCompanySeed {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tin: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
profiles: GovProfileSeed[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const importExport = (
|
||||||
|
index: number,
|
||||||
|
importerId: string,
|
||||||
|
exporterId: string,
|
||||||
|
): GovProfileSeed[] => [
|
||||||
|
{
|
||||||
|
id: importerId,
|
||||||
|
type: ProfileType.importer,
|
||||||
|
reference: `IM-9000${index}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: exporterId,
|
||||||
|
type: ProfileType.exporter,
|
||||||
|
reference: `EX-9000${index}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const GOV_COMPANIES: GovCompanySeed[] = [
|
||||||
|
{
|
||||||
|
id: "0a1b0001-0000-4000-8000-000000000001",
|
||||||
|
name: "Federal Government of Ethiopia",
|
||||||
|
tin: "0000000001",
|
||||||
|
email: "procurement@gov.et",
|
||||||
|
phone: "+251111000001",
|
||||||
|
profiles: importExport(
|
||||||
|
1,
|
||||||
|
"0b1c0001-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0001-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0002-0000-4000-8000-000000000002",
|
||||||
|
name: "Ministry of National Defense",
|
||||||
|
tin: "0000000002",
|
||||||
|
email: "logistics@mod.gov.et",
|
||||||
|
phone: "+251111000002",
|
||||||
|
profiles: importExport(
|
||||||
|
2,
|
||||||
|
"0b1c0002-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0002-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0003-0000-4000-8000-000000000003",
|
||||||
|
name: "Ethiopian Roads Administration",
|
||||||
|
tin: "0000000003",
|
||||||
|
email: "supply@era.gov.et",
|
||||||
|
phone: "+251111000003",
|
||||||
|
profiles: importExport(
|
||||||
|
3,
|
||||||
|
"0b1c0003-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0003-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0004-0000-4000-8000-000000000004",
|
||||||
|
name: "Ministry of Agriculture",
|
||||||
|
tin: "0000000004",
|
||||||
|
email: "imports@moa.gov.et",
|
||||||
|
phone: "+251111000004",
|
||||||
|
profiles: importExport(
|
||||||
|
4,
|
||||||
|
"0b1c0004-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0004-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0005-0000-4000-8000-000000000005",
|
||||||
|
name: "Ministry of Trade and Regional Integration",
|
||||||
|
tin: "0000000005",
|
||||||
|
email: "trade@motri.gov.et",
|
||||||
|
phone: "+251111000005",
|
||||||
|
profiles: importExport(
|
||||||
|
5,
|
||||||
|
"0b1c0005-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0005-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "0a1b0006-0000-4000-8000-000000000006",
|
||||||
|
name: "Ethiopian Disaster Risk Management Commission",
|
||||||
|
tin: "0000000006",
|
||||||
|
email: "relief@edrmc.gov.et",
|
||||||
|
phone: "+251111000006",
|
||||||
|
profiles: importExport(
|
||||||
|
6,
|
||||||
|
"0b1c0006-0000-4000-8000-000000000001",
|
||||||
|
"0b1c0006-0000-4000-8000-000000000002",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Fallback entity used to backfill legacy government / null-company bookings. */
|
||||||
|
export const DEFAULT_GOV_COMPANY = GOV_COMPANIES[0];
|
||||||
|
export const DEFAULT_GOV_IMPORTER_PROFILE = GOV_COMPANIES[0].profiles[0];
|
||||||
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
72
apps/edr-freight-api/src/seed/gov-companies.seeder.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { Company } from "../modules/companies/entities/company.entity";
|
||||||
|
import { CompanyProfile } from "../modules/companies/entities/company-profile.entity";
|
||||||
|
import {
|
||||||
|
GOV_COMPANIES,
|
||||||
|
GOV_COMPANY_KIND,
|
||||||
|
GOV_COMPANY_STATUS,
|
||||||
|
GOV_COMPANY_TYPE,
|
||||||
|
GOV_PROFILE_STATUS,
|
||||||
|
} from "./data/gov-companies.data";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotently seeds the Ethiopian government entities (with importer + exporter
|
||||||
|
* profiles) that government bookings bill to. Safe to re-run — rows are keyed by
|
||||||
|
* the fixed IDs in {@link GOV_COMPANIES}; existing rows are left untouched.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GovCompaniesSeeder {
|
||||||
|
private readonly logger = new Logger(GovCompaniesSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const companyRepo = manager.getRepository(Company);
|
||||||
|
const profileRepo = manager.getRepository(CompanyProfile);
|
||||||
|
|
||||||
|
for (const gov of GOV_COMPANIES) {
|
||||||
|
const existing = await companyRepo.findOne({ where: { id: gov.id } });
|
||||||
|
if (!existing) {
|
||||||
|
await companyRepo.save(
|
||||||
|
companyRepo.create({
|
||||||
|
id: gov.id,
|
||||||
|
name: gov.name,
|
||||||
|
type: GOV_COMPANY_TYPE,
|
||||||
|
kind: GOV_COMPANY_KIND,
|
||||||
|
status: GOV_COMPANY_STATUS,
|
||||||
|
tin: gov.tin,
|
||||||
|
country: "Ethiopia",
|
||||||
|
email: gov.email,
|
||||||
|
phone: gov.phone,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(`Created government company: ${gov.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of gov.profiles) {
|
||||||
|
const existingProfile = await profileRepo.findOne({
|
||||||
|
where: { id: profile.id },
|
||||||
|
});
|
||||||
|
if (existingProfile) continue;
|
||||||
|
await profileRepo.save(
|
||||||
|
profileRepo.create({
|
||||||
|
id: profile.id,
|
||||||
|
companyId: gov.id,
|
||||||
|
type: profile.type,
|
||||||
|
reference: profile.reference,
|
||||||
|
status: GOV_PROFILE_STATUS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Created ${profile.type} profile ${profile.reference} for ${gov.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Government companies seeded.");
|
||||||
|
}
|
||||||
|
}
|
||||||
474
apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts
Normal file
474
apps/edr-freight-api/src/seed/marshalling-demo-trains.seeder.ts
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { WagonStatus } from '@edr/types';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||||
|
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||||
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
|
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||||
|
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||||
|
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
|
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||||
|
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||||
|
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||||
|
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||||
|
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||||
|
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||||
|
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||||
|
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||||
|
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||||
|
|
||||||
|
type DemoDirection = 'IMPORT' | 'EXPORT';
|
||||||
|
|
||||||
|
interface DemoTrain {
|
||||||
|
trainNumber: string;
|
||||||
|
direction: DemoDirection;
|
||||||
|
status: 'SCHEDULED' | 'DISPATCHED' | 'ARRIVED';
|
||||||
|
bookingPrefix: string;
|
||||||
|
departureOffsetHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMO_TRAINS: DemoTrain[] = [
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-IMP-01',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
bookingPrefix: 'MSH-IMP-01',
|
||||||
|
departureOffsetHours: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-IMP-02',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
status: 'DISPATCHED',
|
||||||
|
bookingPrefix: 'MSH-IMP-02',
|
||||||
|
departureOffsetHours: -3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-IMP-03',
|
||||||
|
direction: 'IMPORT',
|
||||||
|
status: 'ARRIVED',
|
||||||
|
bookingPrefix: 'MSH-IMP-03',
|
||||||
|
departureOffsetHours: -14,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-EXP-01',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
bookingPrefix: 'MSH-EXP-01',
|
||||||
|
departureOffsetHours: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-EXP-02',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
status: 'DISPATCHED',
|
||||||
|
bookingPrefix: 'MSH-EXP-02',
|
||||||
|
departureOffsetHours: -2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trainNumber: 'MSH-DEMO-EXP-03',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
status: 'ARRIVED',
|
||||||
|
bookingPrefix: 'MSH-EXP-03',
|
||||||
|
departureOffsetHours: -12,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarshallingDemoTrainsSeeder {
|
||||||
|
private readonly logger = new Logger(MarshallingDemoTrainsSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const yardRepo = this.dataSource.getRepository(Yard);
|
||||||
|
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||||
|
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
|
||||||
|
const wagonTypeRepo = this.dataSource.getRepository(WagonType);
|
||||||
|
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||||
|
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||||
|
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||||
|
|
||||||
|
const djiboutiYard =
|
||||||
|
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||||
|
const ethiopiaYard =
|
||||||
|
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||||
|
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||||
|
const serviceType =
|
||||||
|
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||||
|
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||||
|
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||||
|
const wagonType =
|
||||||
|
(await wagonTypeRepo.findOne({ where: { code: 'NW5' } })) ??
|
||||||
|
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
|
||||||
|
(await wagonTypeRepo.findOne({ where: { isActive: true } }));
|
||||||
|
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||||
|
const warehouseYard = warehouse
|
||||||
|
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
|
||||||
|
: null;
|
||||||
|
const warehouseZone = warehouseYard
|
||||||
|
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
!djiboutiYard ? 'Djibouti yard' : '',
|
||||||
|
!ethiopiaYard ? 'Ethiopia yard' : '',
|
||||||
|
!serviceType ? 'service type' : '',
|
||||||
|
!wagonType ? 'wagon type' : '',
|
||||||
|
!warehouse ? 'INDODE_OPEN warehouse' : '',
|
||||||
|
!warehouseYard ? 'warehouse yard' : '',
|
||||||
|
!warehouseZone ? 'warehouse zone' : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
if (missing.length) {
|
||||||
|
this.logger.warn(`Cannot seed marshalling demo trains, missing: ${missing.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seeded = 0;
|
||||||
|
for (const demo of DEMO_TRAINS) {
|
||||||
|
const created = await this.seedTrain(demo, {
|
||||||
|
djiboutiYard: djiboutiYard!,
|
||||||
|
ethiopiaYard: ethiopiaYard!,
|
||||||
|
serviceType: serviceType!,
|
||||||
|
cargoType,
|
||||||
|
wagonType: wagonType!,
|
||||||
|
warehouse: warehouse!,
|
||||||
|
warehouseYard: warehouseYard!,
|
||||||
|
warehouseZone: warehouseZone!,
|
||||||
|
});
|
||||||
|
if (created) seeded += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Marshalling demo trains ready: ${seeded} new train(s) seeded, 6 total expected`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`MarshallingDemoTrainsSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedTrain(
|
||||||
|
demo: DemoTrain,
|
||||||
|
refs: {
|
||||||
|
djiboutiYard: Yard;
|
||||||
|
ethiopiaYard: Yard;
|
||||||
|
serviceType: ServiceType;
|
||||||
|
cargoType: CargoType | null;
|
||||||
|
wagonType: WagonType;
|
||||||
|
warehouse: Warehouse;
|
||||||
|
warehouseYard: WarehouseYard;
|
||||||
|
warehouseZone: WarehouseZone;
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||||
|
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||||
|
const trainSetWagonRepo = this.dataSource.getRepository(TrainSetWagon);
|
||||||
|
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||||
|
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||||
|
const allocationRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||||
|
const containerItemRepo = this.dataSource.getRepository(WagonAllocationContainerItem);
|
||||||
|
|
||||||
|
const existing = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||||
|
if (existing) {
|
||||||
|
await this.backfillDispatchQueueInventory(demo, refs);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const departure = this.addHours(now, demo.departureOffsetHours);
|
||||||
|
const arrival = this.addHours(departure, demo.direction === 'IMPORT' ? 12 : 10);
|
||||||
|
const isDispatched = demo.status === 'DISPATCHED';
|
||||||
|
const isArrived = demo.status === 'ARRIVED';
|
||||||
|
const hasDeparted = isDispatched || isArrived;
|
||||||
|
const originYard = demo.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||||
|
const destinationYard = demo.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||||
|
|
||||||
|
const locomotive = await this.ensureLocomotive(originYard.id);
|
||||||
|
const bookingWeights = [22.4, 24.8, 18.6, 20.2];
|
||||||
|
const totalWeight = bookingWeights.reduce((sum, weight) => sum + weight, 0);
|
||||||
|
const wagonCapacity = Number(refs.wagonType.capacityTons) || 70;
|
||||||
|
const wagonLength = Number(refs.wagonType.lengthMeters) || 14;
|
||||||
|
const tareWeight = Number(refs.wagonType.tareWeightTons) || 14;
|
||||||
|
|
||||||
|
const trainSet = await trainSetRepo.save(
|
||||||
|
trainSetRepo.create({
|
||||||
|
locomotiveId: locomotive.id,
|
||||||
|
totalWeightTons: totalWeight,
|
||||||
|
totalLengthMeters: wagonLength * bookingWeights.length,
|
||||||
|
wagonCount: bookingWeights.length,
|
||||||
|
status: isArrived ? 'COMPLETED' : isDispatched ? 'DISPATCHED' : 'ASSIGNED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = await scheduleRepo.save(
|
||||||
|
scheduleRepo.create({
|
||||||
|
trainSetId: trainSet.id,
|
||||||
|
originStationId: originYard.id,
|
||||||
|
destinationStationId: destinationYard.id,
|
||||||
|
scheduledDepartureDate: departure,
|
||||||
|
scheduledArrivalDate: arrival,
|
||||||
|
actualDepartureAt: hasDeparted ? departure : null,
|
||||||
|
actualArrivalAt: isArrived ? arrival : null,
|
||||||
|
status: demo.status as TrainSchedule['status'],
|
||||||
|
trainNumber: demo.trainNumber,
|
||||||
|
direction: demo.direction,
|
||||||
|
maxWagons: 53,
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [index, weightTons] of bookingWeights.entries()) {
|
||||||
|
const sequence = index + 1;
|
||||||
|
const bookingReference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
|
||||||
|
const containerNumber = `${demo.direction === 'IMPORT' ? 'IMDU' : 'EXPU'}${demo.trainNumber.slice(-2)}${String(sequence).padStart(3, '0')}`;
|
||||||
|
|
||||||
|
const booking = await bookingRepo.save(
|
||||||
|
bookingRepo.create({
|
||||||
|
reference: bookingReference,
|
||||||
|
originYardId: originYard.id,
|
||||||
|
destinationYardId: destinationYard.id,
|
||||||
|
serviceTypeId: refs.serviceType.id,
|
||||||
|
status: hasDeparted ? 'IN_TRANSIT' : 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
scheduledDate: departure,
|
||||||
|
contractType: 'SPOT',
|
||||||
|
equipmentReturn: 'TERMINAL',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
totalAmount: 0,
|
||||||
|
isGovernment: false,
|
||||||
|
tradeDirection: demo.direction,
|
||||||
|
freightType: sequence % 2 === 0 ? 'BULK' : 'CONTAINER',
|
||||||
|
cargoTypeId: refs.cargoType?.id ?? null,
|
||||||
|
cargoFreeText: refs.cargoType ? null : `${demo.direction} marshalling demo goods ${sequence}`,
|
||||||
|
cargoTotalWeightVgm: weightTons * 1000,
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
schedulingStatus: isArrived ? 'ARRIVED' : isDispatched ? 'DISPATCHED' : 'SCHEDULED',
|
||||||
|
scheduledAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await this.ensureDispatchQueueInventory({
|
||||||
|
booking,
|
||||||
|
demo,
|
||||||
|
refs,
|
||||||
|
weightKg: weightTons * 1000,
|
||||||
|
now,
|
||||||
|
});
|
||||||
|
|
||||||
|
const physicalWagon = await this.ensureWagon({
|
||||||
|
wagonNumber: `${demo.trainNumber}-W${String(sequence).padStart(2, '0')}`,
|
||||||
|
wagonTypeId: refs.wagonType.id,
|
||||||
|
yardId: originYard.id,
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
tareWeight,
|
||||||
|
capacityTons: wagonCapacity,
|
||||||
|
dispatched: hasDeparted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const trainSetWagon = await trainSetWagonRepo.save(
|
||||||
|
trainSetWagonRepo.create({
|
||||||
|
trainSetId: trainSet.id,
|
||||||
|
wagonTypeId: refs.wagonType.id,
|
||||||
|
physicalWagonId: physicalWagon.id,
|
||||||
|
sequenceNo: sequence,
|
||||||
|
capacityTons: wagonCapacity,
|
||||||
|
lengthMeters: wagonLength,
|
||||||
|
assignedWeightTons: weightTons,
|
||||||
|
status: hasDeparted ? 'DEPARTED' : 'LOADED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.getRepository(Wagon).update(physicalWagon.id, {
|
||||||
|
trainSetWagonId: trainSetWagon.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allocation = await allocationRepo.save(
|
||||||
|
allocationRepo.create({
|
||||||
|
trainSetWagonId: trainSetWagon.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
allocatedWeightTons: weightTons,
|
||||||
|
loadType: booking.freightType === 'CONTAINER' ? 'CONTAINER' : 'BULK',
|
||||||
|
status: hasDeparted ? 'DEPARTED' : 'LOADED',
|
||||||
|
confirmedAt: now,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await containerItemRepo.save(
|
||||||
|
containerItemRepo.create({
|
||||||
|
wagonBookingAllocationId: allocation.id,
|
||||||
|
containerNumber,
|
||||||
|
positionOnWagon: 1,
|
||||||
|
sealNumber: `SEAL-${demo.trainNumber.slice(-2)}-${sequence}`,
|
||||||
|
chassisNumber: `CHS-${demo.trainNumber.slice(-2)}-${sequence}`,
|
||||||
|
grossWeightTons: weightTons,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await scheduleBookingRepo.save(
|
||||||
|
scheduleBookingRepo.create({
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (demo.direction === 'IMPORT') {
|
||||||
|
await this.seedImportOperation(schedule.id, demo.trainNumber, now, departure, hasDeparted);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async backfillDispatchQueueInventory(
|
||||||
|
demo: DemoTrain,
|
||||||
|
refs: {
|
||||||
|
warehouse: Warehouse;
|
||||||
|
warehouseYard: WarehouseYard;
|
||||||
|
warehouseZone: WarehouseZone;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||||
|
for (let sequence = 1; sequence <= 4; sequence++) {
|
||||||
|
const reference = `${demo.bookingPrefix}-${String(sequence).padStart(3, '0')}`;
|
||||||
|
const booking = await bookingRepo.findOne({ where: { reference } });
|
||||||
|
if (!booking) continue;
|
||||||
|
await this.ensureDispatchQueueInventory({
|
||||||
|
booking,
|
||||||
|
demo,
|
||||||
|
refs,
|
||||||
|
weightKg: Number(booking.cargoTotalWeightVgm) || 0,
|
||||||
|
now: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDispatchQueueInventory(input: {
|
||||||
|
booking: Booking;
|
||||||
|
demo: DemoTrain;
|
||||||
|
refs: {
|
||||||
|
warehouse: Warehouse;
|
||||||
|
warehouseYard: WarehouseYard;
|
||||||
|
warehouseZone: WarehouseZone;
|
||||||
|
};
|
||||||
|
weightKg: number;
|
||||||
|
now: Date;
|
||||||
|
}): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(WarehouseInventory);
|
||||||
|
const existing = await repo.findOne({ where: { bookingId: input.booking.id } });
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const exportDispatch = input.demo.direction === 'EXPORT';
|
||||||
|
const arrivedAt = this.addHours(input.now, -8);
|
||||||
|
const inspectedAt = this.addHours(input.now, -6);
|
||||||
|
const readyAt = this.addHours(input.now, -4);
|
||||||
|
const loadedAt = this.addHours(input.now, -2);
|
||||||
|
|
||||||
|
await repo.save(
|
||||||
|
repo.create({
|
||||||
|
warehouseId: input.refs.warehouse.id,
|
||||||
|
yardId: input.refs.warehouseYard.id,
|
||||||
|
zoneId: input.refs.warehouseZone.id,
|
||||||
|
bookingId: input.booking.id,
|
||||||
|
quantity: 1,
|
||||||
|
weight: input.weightKg,
|
||||||
|
status: exportDispatch ? 'LOADED' : 'READY_FOR_PICKUP',
|
||||||
|
inspectionStatus: 'PASSED',
|
||||||
|
arrivedAt,
|
||||||
|
unloadedAt: exportDispatch ? null : arrivedAt,
|
||||||
|
inspectedAt,
|
||||||
|
readyForLoadingAt: exportDispatch ? readyAt : null,
|
||||||
|
loadedAt: exportDispatch ? loadedAt : null,
|
||||||
|
readyForPickupAt: exportDispatch ? null : readyAt,
|
||||||
|
notes: `[MSH-DEMO] ${input.demo.trainNumber} dispatch queue test item`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureLocomotive(currentYardId: string): Promise<Locomotive> {
|
||||||
|
const repo = this.dataSource.getRepository(Locomotive);
|
||||||
|
const existing = await repo.findOne({ where: { code: 'MSH-DEMO-LOCO' } });
|
||||||
|
if (existing) return existing;
|
||||||
|
return repo.save(
|
||||||
|
repo.create({
|
||||||
|
code: 'MSH-DEMO-LOCO',
|
||||||
|
name: 'Marshalling Demo Locomotive',
|
||||||
|
locomotiveType: 'DIESEL',
|
||||||
|
maxPullWeightTons: 4200,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
|
status: 'AVAILABLE',
|
||||||
|
currentYardId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureWagon(input: {
|
||||||
|
wagonNumber: string;
|
||||||
|
wagonTypeId: string;
|
||||||
|
yardId: string;
|
||||||
|
trainScheduleId: string;
|
||||||
|
tareWeight: number;
|
||||||
|
capacityTons: number;
|
||||||
|
dispatched: boolean;
|
||||||
|
}): Promise<Wagon> {
|
||||||
|
const repo = this.dataSource.getRepository(Wagon);
|
||||||
|
const existing = await repo.findOne({ where: { wagonNumber: input.wagonNumber } });
|
||||||
|
if (existing) return existing;
|
||||||
|
return repo.save(
|
||||||
|
repo.create({
|
||||||
|
wagonNumber: input.wagonNumber,
|
||||||
|
wagonTypeId: input.wagonTypeId,
|
||||||
|
currentYardId: input.yardId,
|
||||||
|
currentTrainScheduleId: input.trainScheduleId,
|
||||||
|
tareWeight: input.tareWeight,
|
||||||
|
maxPayloadWeight: input.capacityTons,
|
||||||
|
status: WagonStatus.Assigned,
|
||||||
|
notes: 'Marshalling demo seed wagon',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async seedImportOperation(
|
||||||
|
trainScheduleId: string,
|
||||||
|
trainNumber: string,
|
||||||
|
now: Date,
|
||||||
|
departure: Date,
|
||||||
|
dispatched: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||||
|
await repo.save(
|
||||||
|
repo.create({
|
||||||
|
trainScheduleId,
|
||||||
|
documents: {
|
||||||
|
DELIVERY_ORDER: this.documentRecord(trainNumber, 'DELIVERY_ORDER', now),
|
||||||
|
PORT_INVOICE: this.documentRecord(trainNumber, 'PORT_INVOICE', now),
|
||||||
|
DJIBOUTI_T1: this.documentRecord(trainNumber, 'DJIBOUTI_T1', now),
|
||||||
|
ETHIOPIA_T1: this.documentRecord(trainNumber, 'ETHIOPIA_T1', now),
|
||||||
|
RAILWAY_BILL: this.documentRecord(trainNumber, 'RAILWAY_BILL', now),
|
||||||
|
},
|
||||||
|
gatepassGrantedAt: now,
|
||||||
|
readyForLoadingAt: now,
|
||||||
|
loadedOnTrainAt: now,
|
||||||
|
departedFromDjiboutiAt: dispatched ? departure : null,
|
||||||
|
performedBy: 'Marshalling Demo Seeder',
|
||||||
|
notes: '[MSH-DEMO] Import train ready for marshalling document and dispatch workflow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private documentRecord(trainNumber: string, type: string, now: Date) {
|
||||||
|
return {
|
||||||
|
reference: `${type}-${trainNumber}`,
|
||||||
|
uploadedAt: now.toISOString(),
|
||||||
|
uploadedBy: 'Marshalling Demo Seeder',
|
||||||
|
notes: 'Seeded document for import marshalling workflow',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private addHours(date: Date, hours: number): Date {
|
||||||
|
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,8 @@ import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
|||||||
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage";
|
||||||
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage";
|
||||||
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage";
|
||||||
|
import ExportWarehouseFlowPage from "./pages/warehouses/ExportWarehouseFlowPage";
|
||||||
|
import ImportWarehouseFlowPage from "./pages/warehouses/ImportWarehouseFlowPage";
|
||||||
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
import InterchangeDocumentsPage from "./pages/warehouses/InterchangeDocumentsPage";
|
||||||
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
|
||||||
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
|
||||||
@@ -210,6 +212,85 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Port & Terminal",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Import Operations",
|
||||||
|
href: "/dashboard/import-warehouse",
|
||||||
|
icon: <PackageOpen />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Import Overview",
|
||||||
|
href: "/dashboard/import-warehouse",
|
||||||
|
icon: <PackageOpen />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Arrival Queue",
|
||||||
|
href: "/dashboard/arrival-queue",
|
||||||
|
icon: <PackageOpen />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dispatch Queue",
|
||||||
|
href: "/dashboard/dispatch-queue",
|
||||||
|
icon: <Send />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Terminal Inventory",
|
||||||
|
href: "/dashboard/warehouse-inventory",
|
||||||
|
icon: <Package />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Inventory Inquiry",
|
||||||
|
href: "/dashboard/inventory-inquiry",
|
||||||
|
icon: <Boxes />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Export Operations",
|
||||||
|
href: "/dashboard/export-warehouse",
|
||||||
|
icon: <Truck />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Export Overview",
|
||||||
|
href: "/dashboard/export-warehouse",
|
||||||
|
icon: <Truck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loading Queue",
|
||||||
|
href: "/dashboard/loading-queue",
|
||||||
|
icon: <Truck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Loaded Inventory",
|
||||||
|
href: "/dashboard/loaded-inventory",
|
||||||
|
icon: <PackageCheck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Dispatch Queue",
|
||||||
|
href: "/dashboard/dispatch-queue",
|
||||||
|
icon: <Send />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Djibouti Unloading",
|
||||||
|
href: "/dashboard/export-djibouti-unloading",
|
||||||
|
icon: <PackageOpen />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Interchange Documents",
|
||||||
|
href: "/dashboard/interchange-documents",
|
||||||
|
icon: <FileText />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Terminal Inventory",
|
||||||
|
href: "/dashboard/warehouse-inventory",
|
||||||
|
icon: <Package />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Warehouse Management",
|
title: "Warehouse Management",
|
||||||
items: [
|
items: [
|
||||||
@@ -223,46 +304,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/warehouses",
|
href: "/dashboard/warehouses",
|
||||||
icon: <Container />,
|
icon: <Container />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Inventory",
|
|
||||||
href: "/dashboard/warehouse-inventory",
|
|
||||||
icon: <Package />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Arrival Queue",
|
|
||||||
href: "/dashboard/arrival-queue",
|
|
||||||
icon: <PackageOpen />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Loading Queue",
|
|
||||||
href: "/dashboard/loading-queue",
|
|
||||||
icon: <Truck />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Loaded Inventory",
|
|
||||||
href: "/dashboard/loaded-inventory",
|
|
||||||
icon: <PackageCheck />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Dispatch Queue",
|
|
||||||
href: "/dashboard/dispatch-queue",
|
|
||||||
icon: <Send />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Djibouti Unloading",
|
|
||||||
href: "/dashboard/export-djibouti-unloading",
|
|
||||||
icon: <PackageOpen />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Interchange Documents",
|
|
||||||
href: "/dashboard/interchange-documents",
|
|
||||||
icon: <FileText />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Inventory Inquiry",
|
|
||||||
href: "/dashboard/inventory-inquiry",
|
|
||||||
icon: <Boxes />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Allocation & Fees",
|
label: "Allocation & Fees",
|
||||||
href: "/dashboard/warehouse-rules",
|
href: "/dashboard/warehouse-rules",
|
||||||
@@ -487,6 +528,8 @@ const App = () => {
|
|||||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||||
|
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
|
||||||
|
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
|
||||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openHandoverDocument = async (item: WarehouseInventoryItem) => {
|
||||||
|
setBusyId(item.id);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadHandoverDocument(item.id);
|
||||||
|
const filename = `handover-${item.booking?.reference ?? item.bookingId ?? item.id}.pdf`;
|
||||||
|
const opened = openPdfBlob(response.data, filename, pdfWindow);
|
||||||
|
toast({ title: opened ? 'Handover document opened' : 'Handover document downloaded' });
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: 'Handover document failed',
|
||||||
|
description: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
const acceptLastMile = async (item: WarehouseInventoryItem) => {
|
||||||
const reference = item.booking?.reference;
|
const reference = item.booking?.reference;
|
||||||
if (!reference) {
|
if (!reference) {
|
||||||
@@ -227,6 +247,7 @@ export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWo
|
|||||||
onInspect={setInspectItem}
|
onInspect={setInspectItem}
|
||||||
onFeePreview={setFeeItem}
|
onFeePreview={setFeeItem}
|
||||||
onReleaseDocument={downloadReleaseDocument}
|
onReleaseDocument={downloadReleaseDocument}
|
||||||
|
onHandoverDocument={openHandoverDocument}
|
||||||
onLastMile={onLastMile ? acceptLastMile : undefined}
|
onLastMile={onLastMile ? acceptLastMile : undefined}
|
||||||
selectedIds={selected}
|
selectedIds={selected}
|
||||||
onToggleSelect={toggleSelect}
|
onToggleSelect={toggleSelect}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
ActionIcon,
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
@@ -15,28 +17,58 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react';
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ClipboardCheck,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
History,
|
||||||
|
Info,
|
||||||
|
PackageCheck,
|
||||||
|
PackageOpen,
|
||||||
|
PackageSearch,
|
||||||
|
Send,
|
||||||
|
Search,
|
||||||
|
Train,
|
||||||
|
Truck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||||
import { firstMileService } from '@/services/first-mile.service';
|
import { firstMileService } from '@/services/first-mile.service';
|
||||||
|
import { warehouseService } from '@/services/warehouse.service';
|
||||||
import type {
|
import type {
|
||||||
EligibleBooking,
|
EligibleBooking,
|
||||||
|
InventoryInquiryFilter,
|
||||||
|
InventoryInquiryResult,
|
||||||
ImportTrain,
|
ImportTrain,
|
||||||
ImportTrainItem,
|
ImportTrainItem,
|
||||||
ImportUnloadedItem,
|
ImportUnloadedItem,
|
||||||
ReadyToLoadRow,
|
ReadyToLoadRow,
|
||||||
ReceiveInventoryPayload,
|
ReceiveInventoryPayload,
|
||||||
TruckEntrancePayload,
|
TruckEntrancePayload,
|
||||||
|
WarehouseInventoryItem,
|
||||||
} from '@/types/warehouse';
|
} from '@/types/warehouse';
|
||||||
import { BookingSelect } from './BookingSelect';
|
import { BookingSelect } from './BookingSelect';
|
||||||
|
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||||
|
import { FeePreviewModal } from './FeePreviewModal';
|
||||||
import { InspectionReportModal } from './InspectionReportModal';
|
import { InspectionReportModal } from './InspectionReportModal';
|
||||||
|
import { InventoryDetailModal } from './InventoryDetailModal';
|
||||||
|
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||||
import { InventoryWorkbench } from './InventoryWorkbench';
|
import { InventoryWorkbench } from './InventoryWorkbench';
|
||||||
import { extractErrorMessage, formatDate, formatNumber } from './options';
|
import { InventoryInquiryDetailModal } from './InventoryInquiryDetailModal';
|
||||||
|
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||||
|
import { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
||||||
|
import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options';
|
||||||
|
import { openPdfBlob } from './pdf';
|
||||||
|
import '@/components/overview/overview.css';
|
||||||
|
|
||||||
interface ReceiveInventoryModalProps {
|
interface ReceiveInventoryModalProps {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
@@ -608,6 +640,7 @@ function EligibleTab({
|
|||||||
const [statusTab, setStatusTab] = useState('ALL');
|
const [statusTab, setStatusTab] = useState('ALL');
|
||||||
const [truckOpen, setTruckOpen] = useState(false);
|
const [truckOpen, setTruckOpen] = useState(false);
|
||||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||||
|
const [receivedAt, setReceivedAt] = useState<string | null>(null);
|
||||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||||
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||||
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||||
@@ -717,6 +750,7 @@ function EligibleTab({
|
|||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
setTruckOpen(false);
|
setTruckOpen(false);
|
||||||
setPendingReceiveIds([]);
|
setPendingReceiveIds([]);
|
||||||
|
setReceivedAt(null);
|
||||||
setLockedTruckFields({});
|
setLockedTruckFields({});
|
||||||
setPackagingFreightType('MIXED');
|
setPackagingFreightType('MIXED');
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
@@ -749,6 +783,7 @@ function EligibleTab({
|
|||||||
}
|
}
|
||||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||||
setPendingReceiveIds(filteredIds);
|
setPendingReceiveIds(filteredIds);
|
||||||
|
setReceivedAt(new Date().toISOString());
|
||||||
setTruckForm(form);
|
setTruckForm(form);
|
||||||
setLockedTruckFields(lockedFields);
|
setLockedTruckFields(lockedFields);
|
||||||
setPackagingFreightType(nextPackagingFreightType);
|
setPackagingFreightType(nextPackagingFreightType);
|
||||||
@@ -807,15 +842,18 @@ function EligibleTab({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
variant="default"
|
color={direction === 'EXPORT' ? 'edr-green' : undefined}
|
||||||
|
variant={direction === 'EXPORT' ? 'filled' : 'default'}
|
||||||
|
leftSection={direction === 'EXPORT' ? <Truck size={14} /> : undefined}
|
||||||
disabled={!locationReady || selectableRows.length === 0}
|
disabled={!locationReady || selectableRows.length === 0}
|
||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive(selectableRows.map((r) => r.id))}
|
onClick={() => openTruckReceive(selected.size > 0 ? [...selected] : selectableRows.map((r) => r.id))}
|
||||||
>
|
>
|
||||||
Receive All Eligible
|
{direction === 'EXPORT' ? 'Receive to Warehouse' : 'Receive All to Warehouse'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
|
variant="default"
|
||||||
disabled={!locationReady || selected.size === 0}
|
disabled={!locationReady || selected.size === 0}
|
||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive([...selected])}
|
onClick={() => openTruckReceive([...selected])}
|
||||||
@@ -859,7 +897,7 @@ function EligibleTab({
|
|||||||
<Table.Th>Origin</Table.Th>
|
<Table.Th>Origin</Table.Th>
|
||||||
<Table.Th>Destination</Table.Th>
|
<Table.Th>Destination</Table.Th>
|
||||||
<Table.Th>Route</Table.Th>
|
<Table.Th>Route</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container / Cargo Items</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
<Table.Th>Weight</Table.Th>
|
<Table.Th>Weight</Table.Th>
|
||||||
<Table.Th>Payment</Table.Th>
|
<Table.Th>Payment</Table.Th>
|
||||||
@@ -899,7 +937,17 @@ function EligibleTab({
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>—</Table.Td>
|
<Table.Td>
|
||||||
|
<Stack gap={2}>
|
||||||
|
<Text size="sm">{r.containerNumber ?? r.cargoDescription ?? r.cargo ?? '—'}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{[
|
||||||
|
r.containerQuantity != null ? `${r.containerQuantity} unit(s)` : null,
|
||||||
|
r.containerPackagingType,
|
||||||
|
].filter(Boolean).join(' / ') || 'Item details from booking'}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{r.cargo ?? '—'}</Table.Td>
|
<Table.Td>{r.cargo ?? '—'}</Table.Td>
|
||||||
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -952,7 +1000,7 @@ function EligibleTab({
|
|||||||
loading={bulkReceive.isPending}
|
loading={bulkReceive.isPending}
|
||||||
onClick={() => openTruckReceive([r.id])}
|
onClick={() => openTruckReceive([r.id])}
|
||||||
>
|
>
|
||||||
{canReceive ? 'Receive' : 'Await First Mile'}
|
{canReceive ? 'Receive to Warehouse' : 'Await First Mile'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -967,7 +1015,7 @@ function EligibleTab({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={truckOpen}
|
opened={truckOpen}
|
||||||
onClose={() => setTruckOpen(false)}
|
onClose={() => setTruckOpen(false)}
|
||||||
title="Export Truck Arrival / First Mile Receive Form"
|
title="Receive to Warehouse"
|
||||||
centered
|
centered
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -979,6 +1027,52 @@ function EligibleTab({
|
|||||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
<Table.ScrollContainer minWidth={900}>
|
||||||
|
<Table withTableBorder highlightOnHover verticalSpacing="xs">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Booking</Table.Th>
|
||||||
|
<Table.Th>Customer</Table.Th>
|
||||||
|
<Table.Th>TIN / Phone</Table.Th>
|
||||||
|
<Table.Th>Container / Cargo</Table.Th>
|
||||||
|
<Table.Th>Qty / Package</Table.Th>
|
||||||
|
<Table.Th>Weight</Table.Th>
|
||||||
|
<Table.Th>Received at</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{pendingReceiveRows.map((booking) => (
|
||||||
|
<Table.Tr key={booking.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" fw={600}>{booking.reference}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{booking.id.slice(0, 8)}...</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{booking.customer ?? '-'}</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs">{booking.customerTin ?? '-'}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{booking.customerPhone ?? '-'}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="xs">{booking.containerNumber ?? booking.cargoDescription ?? booking.cargo ?? '-'}</Text>
|
||||||
|
<Text size="xs" c="dimmed">{booking.freightType ?? '-'}</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{[
|
||||||
|
booking.containerQuantity != null ? `${booking.containerQuantity} unit(s)` : null,
|
||||||
|
booking.containerPackagingType,
|
||||||
|
].filter(Boolean).join(' / ') || '-'}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>{formatNumber(Number(booking.weight))}</Table.Td>
|
||||||
|
<Table.Td>{formatDate(receivedAt)}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
<TruckEntranceFields
|
<TruckEntranceFields
|
||||||
value={truckForm}
|
value={truckForm}
|
||||||
onChange={setTruckForm}
|
onChange={setTruckForm}
|
||||||
@@ -1084,7 +1178,7 @@ function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged
|
|||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
<Table.Th>Customer Name</Table.Th>
|
<Table.Th>Customer Name</Table.Th>
|
||||||
<Table.Th>Container #</Table.Th>
|
<Table.Th>Container / Cargo Items</Table.Th>
|
||||||
<Table.Th>Cargo Type</Table.Th>
|
<Table.Th>Cargo Type</Table.Th>
|
||||||
<Table.Th>Weight</Table.Th>
|
<Table.Th>Weight</Table.Th>
|
||||||
<Table.Th>Route</Table.Th>
|
<Table.Th>Route</Table.Th>
|
||||||
@@ -1466,11 +1560,11 @@ function LoadedExportTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
||||||
function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||||
const { data: items = [], isLoading } = useQuery(
|
const { data: items = [], isLoading } = useQuery(
|
||||||
api.warehouses.importTrainItems.queryOptions({
|
api.warehouses.importTrainItems.queryOptions({
|
||||||
input: { scheduleId },
|
input: { scheduleId: train.scheduleId },
|
||||||
enabled: Boolean(scheduleId),
|
enabled: Boolean(train.scheduleId),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1493,6 +1587,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
|||||||
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
<Table withTableBorder verticalSpacing="xs" fz="xs">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th>Wagon</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Booking Ref</Table.Th>
|
<Table.Th>Booking Ref</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
@@ -1509,7 +1604,12 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) {
|
|||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{items.map((it: ImportTrainItem) => (
|
{items.map((it: ImportTrainItem) => (
|
||||||
<Table.Tr key={it.bookingId}>
|
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{it.sequenceNo ? `#${it.sequenceNo}` : '-'} {it.wagonNumber ?? ''}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
<Text size="xs" c="dimmed">{it.bookingId.slice(0, 8)}…</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -1656,8 +1756,8 @@ function ImportArriveQueueTab({
|
|||||||
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
<Table.Td ta="center">{t.totalCargoes}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Badge color={fullyUnloaded ? 'green' : 'indigo'} variant="light" size="sm">
|
<Badge color="indigo" variant="light" size="sm">
|
||||||
{fullyUnloaded ? 'UNLOADED' : t.status}
|
{t.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
{Math.max(unloadedBookings, 0)}/{t.totalBookings} unloaded
|
||||||
@@ -1690,7 +1790,7 @@ function ImportArriveQueueTab({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
||||||
<ImportTrainDetailTable scheduleId={t.scheduleId} />
|
<ImportTrainDetailTable train={t} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
@@ -1712,14 +1812,24 @@ function ImportArriveQueueTab({
|
|||||||
*/
|
*/
|
||||||
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const qc = useQueryClient();
|
||||||
const { data: rows = [], isLoading } = useQuery(
|
const { data: rows = [], isLoading } = useQuery(
|
||||||
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
|
api.warehouses.importUnloadedQueue.queryOptions({ enabled }),
|
||||||
);
|
);
|
||||||
const inspectMutation = useMutation(
|
const inspectMutation = useMutation(
|
||||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||||
);
|
);
|
||||||
|
const storeMutation = useMutation(api.warehouses.store.mutationOptions());
|
||||||
|
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
|
||||||
|
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [viewItem, setViewItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||||
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||||
const someSelected = selected.size > 0 && !allSelected;
|
const someSelected = selected.size > 0 && !allSelected;
|
||||||
@@ -1744,11 +1854,62 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||||
});
|
});
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||||
|
({
|
||||||
|
id: row.id,
|
||||||
|
bookingId: row.bookingId,
|
||||||
|
quantity: 1,
|
||||||
|
weight: Number(row.weight) || 0,
|
||||||
|
status: row.currentStatus,
|
||||||
|
arrivedAt: row.arrivalTime,
|
||||||
|
unloadedAt: row.arrivalTime,
|
||||||
|
inspectionStatus: row.inspectionStatus,
|
||||||
|
releaseDate: row.releaseDate,
|
||||||
|
releaseOrderReference: row.releaseOrderReference,
|
||||||
|
deliveredAt: row.deliveredAt,
|
||||||
|
booking: row.bookingId
|
||||||
|
? {
|
||||||
|
id: row.bookingId,
|
||||||
|
reference: row.bookingReference ?? row.bookingId,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}) as unknown as WarehouseInventoryItem;
|
||||||
|
|
||||||
|
const runRowAction = async (row: ImportUnloadedItem, label: string, fn: () => Promise<unknown>) => {
|
||||||
|
setBusyId(row.id);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
toast({ title: label });
|
||||||
|
void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] });
|
||||||
|
} catch (error) {
|
||||||
|
toast({ variant: 'destructive', title: 'Action failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHandoverDocument = async (row: ImportUnloadedItem) => {
|
||||||
|
setBusyId(row.id);
|
||||||
|
const pdfWindow = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const response = await warehouseService.downloadHandoverDocument(row.id);
|
||||||
|
openPdfBlob(response.data, `handover-${row.bookingReference ?? row.id}.pdf`, pdfWindow);
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({ variant: 'destructive', title: 'Handover document failed', description: extractErrorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm" mt="sm">
|
<Stack gap="sm" mt="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
@@ -1852,9 +2013,92 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
<Badge color="indigo" variant="light" size="sm">{r.currentStatus}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td ta="right">
|
<Table.Td ta="right">
|
||||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||||
Inspect / Report
|
<Tooltip label="View details" withArrow>
|
||||||
</Button>
|
<ActionIcon variant="subtle" color="gray" onClick={() => setViewItem(toInventoryItem(r))}>
|
||||||
|
<Eye size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
{r.currentStatus === 'UNLOADED' && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
loading={busyId === r.id}
|
||||||
|
onClick={() => runRowAction(r, 'Inventory stored', () => storeMutation.mutateAsync(r.id))}
|
||||||
|
>
|
||||||
|
Store
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{['UNLOADED', 'STORED'].includes(r.currentStatus) && r.inspectionStatus === 'PASSED' && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
loading={busyId === r.id}
|
||||||
|
onClick={() => runRowAction(r, 'Ready for pickup', () => readyMutation.mutateAsync(r.id))}
|
||||||
|
>
|
||||||
|
Ready Pickup
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && !r.releaseDate && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
onClick={() => setReleaseItem(toInventoryItem(r))}
|
||||||
|
>
|
||||||
|
Truck Arrival
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
loading={busyId === r.id}
|
||||||
|
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))}
|
||||||
|
>
|
||||||
|
Dispatch
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="green"
|
||||||
|
onClick={() => setDeliverItem(toInventoryItem(r))}
|
||||||
|
>
|
||||||
|
Deliver
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{r.inspectionStatus === 'PASSED' && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<FileText size={14} />}
|
||||||
|
onClick={() => openHandoverDocument(r)}
|
||||||
|
>
|
||||||
|
Handover
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||||
|
Inspect / Report
|
||||||
|
</Button>
|
||||||
|
<Tooltip label="Storage / fee preview" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="teal" onClick={() => setFeeItem(toInventoryItem(r))}>
|
||||||
|
<PackageCheck size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="History" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="gray" onClick={() => setHistoryItem(toInventoryItem(r))}>
|
||||||
|
<History size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
@@ -1868,6 +2112,15 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
onClose={() => setInspectId(null)}
|
onClose={() => setInspectId(null)}
|
||||||
inventoryId={inspectId}
|
inventoryId={inspectId}
|
||||||
/>
|
/>
|
||||||
|
<InventoryDetailModal opened={Boolean(viewItem)} onClose={() => setViewItem(null)} item={viewItem} />
|
||||||
|
<InventoryHistoryModal opened={Boolean(historyItem)} onClose={() => setHistoryItem(null)} item={historyItem} />
|
||||||
|
<FeePreviewModal
|
||||||
|
opened={Boolean(feeItem)}
|
||||||
|
onClose={() => setFeeItem(null)}
|
||||||
|
inventoryId={feeItem?.id ?? null}
|
||||||
|
/>
|
||||||
|
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||||
|
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1905,20 +2158,340 @@ function ImportDispatchQueueTab({ enabled }: { enabled: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
type WarehouseFlowDirection = 'IMPORT' | 'EXPORT' | 'BOTH';
|
||||||
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
type ImportWarehouseTab = 'arrive-queue' | 'unloaded-queue' | 'dispatch-queue' | 'locate-booking';
|
||||||
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
type ExportWarehouseTab = 'receive-queue' | 'received' | 'ready-to-load' | 'loaded' | 'dispatch-queue' | 'locate-booking';
|
||||||
const [tab, setTab] = useState<'IMPORT' | 'EXPORT'>('IMPORT');
|
|
||||||
|
|
||||||
useEffect(() => {
|
interface WarehouseQueueTab<TValue extends string> {
|
||||||
if (opened) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
value: TValue;
|
||||||
}, [opened]);
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WarehouseFlowWorkbenchProps {
|
||||||
|
direction?: WarehouseFlowDirection;
|
||||||
|
enabled?: boolean;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarehouseQueueTabs<TValue extends string>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
}: {
|
||||||
|
value: TValue;
|
||||||
|
onChange: (value: TValue) => void;
|
||||||
|
tabs: WarehouseQueueTab<TValue>[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
value={value}
|
||||||
|
onChange={(next) => onChange((next as TValue) ?? value)}
|
||||||
|
variant="pills"
|
||||||
|
color="edr-green"
|
||||||
|
keepMounted={false}
|
||||||
|
classNames={{ list: 'ov-tablist', tab: 'ov-tab' }}
|
||||||
|
>
|
||||||
|
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
|
||||||
|
<Tabs.List style={{ flexWrap: 'nowrap', width: 'max-content' }}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = value === tab.value;
|
||||||
|
return (
|
||||||
|
<Tabs.Tab
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
leftSection={tab.icon}
|
||||||
|
rightSection={
|
||||||
|
tab.count !== undefined ? (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
radius="sm"
|
||||||
|
variant={active ? 'white' : 'light'}
|
||||||
|
color={active ? 'edr-green' : 'gray'}
|
||||||
|
styles={
|
||||||
|
active
|
||||||
|
? { root: { background: 'rgba(255,255,255,0.9)', color: '#15805f' } }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</Badge>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Tabs.Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tabs.List>
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocateBookingTab({ enabled }: { enabled: boolean }) {
|
||||||
|
const [draft, setDraft] = useState<InventoryInquiryFilter>({});
|
||||||
|
const [applied, setApplied] = useState<InventoryInquiryFilter>({});
|
||||||
|
const [viewResult, setViewResult] = useState<InventoryInquiryResult | null>(null);
|
||||||
|
const hasSearch = Boolean(
|
||||||
|
applied.bookingReference ||
|
||||||
|
applied.containerNumber ||
|
||||||
|
applied.goodsName ||
|
||||||
|
applied.cargoType ||
|
||||||
|
applied.status,
|
||||||
|
);
|
||||||
|
const { data: results = [], isFetching } = useInventoryInquiry(applied, enabled && hasSearch);
|
||||||
|
|
||||||
|
const normalizeDraft = (): InventoryInquiryFilter => ({
|
||||||
|
bookingReference: draft.bookingReference?.trim() || undefined,
|
||||||
|
containerNumber: draft.containerNumber?.trim() || undefined,
|
||||||
|
goodsName: draft.goodsName?.trim() || undefined,
|
||||||
|
cargoType: draft.cargoType?.trim() || undefined,
|
||||||
|
status: draft.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runSearch = () => setApplied(normalizeDraft());
|
||||||
|
const reset = () => {
|
||||||
|
setDraft({});
|
||||||
|
setApplied({});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
<Stack gap="md" mt="sm">
|
||||||
<Stack gap="md">
|
<Group gap="sm" wrap="wrap">
|
||||||
<LocationSelects value={location} onChange={setLocation} />
|
<TextInput
|
||||||
|
label="Booking reference"
|
||||||
|
placeholder="e.g. BK-2026-000051"
|
||||||
|
value={draft.bookingReference ?? ''}
|
||||||
|
onChange={(e) => setDraft((filter) => ({ ...filter, bookingReference: e.currentTarget.value || undefined }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') runSearch();
|
||||||
|
}}
|
||||||
|
w={230}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Container number"
|
||||||
|
placeholder="e.g. MSKU1234567"
|
||||||
|
value={draft.containerNumber ?? ''}
|
||||||
|
onChange={(e) => setDraft((filter) => ({ ...filter, containerNumber: e.currentTarget.value || undefined }))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') runSearch();
|
||||||
|
}}
|
||||||
|
w={220}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Goods / cargo"
|
||||||
|
placeholder="Coffee, steel, etc."
|
||||||
|
value={draft.goodsName ?? draft.cargoType ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.currentTarget.value || undefined;
|
||||||
|
setDraft((filter) => ({ ...filter, goodsName: value, cargoType: value }));
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') runSearch();
|
||||||
|
}}
|
||||||
|
w={200}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Status"
|
||||||
|
placeholder="Any"
|
||||||
|
clearable
|
||||||
|
data={inventoryStatusOptions}
|
||||||
|
value={draft.status ?? null}
|
||||||
|
onChange={(value) => setDraft((filter) => ({ ...filter, status: value as InventoryInquiryFilter['status'] }))}
|
||||||
|
w={190}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Button leftSection={<Search size={16} />} onClick={runSearch}>
|
||||||
|
Locate Booking
|
||||||
|
</Button>
|
||||||
|
<Button variant="default" onClick={reset}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{isFetching ? (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : !hasSearch ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
Search by booking reference, container number, cargo or status to locate inventory.
|
||||||
|
</Text>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
No inventory found for the current filters.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<WarehouseInquiryTable results={results} onView={setViewResult} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InventoryInquiryDetailModal
|
||||||
|
opened={Boolean(viewResult)}
|
||||||
|
onClose={() => setViewResult(null)}
|
||||||
|
result={viewResult}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportWarehouseTabs({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ImportWarehouseTab>('arrive-queue');
|
||||||
|
const { data: arriveRows = [] } = useQuery(api.warehouses.importArriveQueue.queryOptions({ enabled }));
|
||||||
|
const { data: unloadedRows = [] } = useQuery(api.warehouses.importUnloadedQueue.queryOptions({ enabled }));
|
||||||
|
const { data: dispatchRows = [] } = useQuery(api.warehouses.importPickupReadyQueue.queryOptions({ enabled }));
|
||||||
|
const tabs: WarehouseQueueTab<ImportWarehouseTab>[] = [
|
||||||
|
{
|
||||||
|
value: 'arrive-queue',
|
||||||
|
label: 'Arrival Queue',
|
||||||
|
icon: <PackageOpen size={17} strokeWidth={1.85} />,
|
||||||
|
count: arriveRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'unloaded-queue',
|
||||||
|
label: 'Unloaded Queue',
|
||||||
|
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||||
|
count: unloadedRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dispatch-queue',
|
||||||
|
label: 'Dispatch Queue',
|
||||||
|
icon: <Send size={17} strokeWidth={1.85} />,
|
||||||
|
count: dispatchRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'locate-booking',
|
||||||
|
label: 'Locate Booking',
|
||||||
|
icon: <Search size={17} strokeWidth={1.85} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||||
|
|
||||||
|
{activeTab === 'arrive-queue' && (
|
||||||
|
<ImportArriveQueueTab enabled={enabled} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'unloaded-queue' && (
|
||||||
|
<ImportUnloadedQueueTab enabled={enabled} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'dispatch-queue' && (
|
||||||
|
<ImportDispatchQueueTab enabled={enabled} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'locate-booking' && (
|
||||||
|
<LocateBookingTab enabled={enabled} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportWarehouseTabs({
|
||||||
|
enabled,
|
||||||
|
location,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
location: Location;
|
||||||
|
onChanged?: () => void;
|
||||||
|
}) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ExportWarehouseTab>('receive-queue');
|
||||||
|
const { data: eligibleRows = [] } = useQuery(api.warehouses.eligibleBookings.queryOptions({ enabled }));
|
||||||
|
const { data: receivedRows = [] } = useQuery(api.warehouses.receivedExport.queryOptions({ enabled }));
|
||||||
|
const { data: readyRows = [] } = useQuery(api.warehouses.readyToLoadExport.queryOptions({ enabled }));
|
||||||
|
const { data: loadedRows = [] } = useQuery(api.warehouses.loadedExport.queryOptions({ enabled }));
|
||||||
|
const exportEligibleCount = useMemo(
|
||||||
|
() => eligibleRows.filter((row) => row.direction === 'EXPORT').length,
|
||||||
|
[eligibleRows],
|
||||||
|
);
|
||||||
|
const tabs: WarehouseQueueTab<ExportWarehouseTab>[] = [
|
||||||
|
{
|
||||||
|
value: 'receive-queue',
|
||||||
|
label: 'Receive to Warehouse',
|
||||||
|
icon: <Truck size={17} strokeWidth={1.85} />,
|
||||||
|
count: exportEligibleCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'received',
|
||||||
|
label: 'Received',
|
||||||
|
icon: <ClipboardCheck size={17} strokeWidth={1.85} />,
|
||||||
|
count: receivedRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'ready-to-load',
|
||||||
|
label: 'Ready To Load',
|
||||||
|
icon: <Train size={17} strokeWidth={1.85} />,
|
||||||
|
count: readyRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'loaded',
|
||||||
|
label: 'Loaded',
|
||||||
|
icon: <PackageCheck size={17} strokeWidth={1.85} />,
|
||||||
|
count: loadedRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dispatch-queue',
|
||||||
|
label: 'Dispatch Queue',
|
||||||
|
icon: <Send size={17} strokeWidth={1.85} />,
|
||||||
|
count: loadedRows.length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'locate-booking',
|
||||||
|
label: 'Locate Booking',
|
||||||
|
icon: <Search size={17} strokeWidth={1.85} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<WarehouseQueueTabs value={activeTab} onChange={setActiveTab} tabs={tabs} />
|
||||||
|
|
||||||
|
{activeTab === 'receive-queue' && (
|
||||||
|
<EligibleTab direction="EXPORT" location={location} enabled={enabled} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'received' && (
|
||||||
|
<ExportReceivedTab enabled={enabled} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'ready-to-load' && (
|
||||||
|
<ReadyToLoadTab enabled={enabled} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'loaded' && (
|
||||||
|
<LoadedExportTab enabled={enabled} dispatchable={false} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'dispatch-queue' && (
|
||||||
|
<LoadedExportTab enabled={enabled} dispatchable onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'locate-booking' && (
|
||||||
|
<LocateBookingTab enabled={enabled} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WarehouseFlowWorkbench({
|
||||||
|
direction = 'BOTH',
|
||||||
|
enabled = true,
|
||||||
|
onChanged,
|
||||||
|
}: WarehouseFlowWorkbenchProps) {
|
||||||
|
const [location, setLocation] = useState<Location>({ warehouseId: '', yardId: '', zoneId: '' });
|
||||||
|
const [tab, setTab] = useState<Exclude<WarehouseFlowDirection, 'BOTH'>>(
|
||||||
|
direction === 'EXPORT' ? 'EXPORT' : 'IMPORT',
|
||||||
|
);
|
||||||
|
const activeDirection = direction === 'BOTH' ? tab : direction;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) setLocation({ warehouseId: '', yardId: '', zoneId: '' });
|
||||||
|
}, [enabled, direction]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeDirection === 'EXPORT' && (
|
||||||
|
<LocationSelects value={location} onChange={setLocation} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{direction === 'BOTH' ? (
|
||||||
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
|
<Tabs value={tab} onChange={(v) => setTab((v as 'IMPORT' | 'EXPORT') ?? 'IMPORT')}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
|
<Tabs.Tab value="IMPORT" leftSection={<PackageSearch size={16} />}>
|
||||||
@@ -1930,54 +2503,27 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="IMPORT">
|
<Tabs.Panel value="IMPORT">
|
||||||
<Tabs defaultValue="arrive-queue" mt="xs">
|
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab value="arrive-queue" leftSection={<Train size={14} />}>
|
|
||||||
Arrive Queue
|
|
||||||
</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="unloaded-queue">Unloaded Queue</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="arrive-queue">
|
|
||||||
<ImportArriveQueueTab enabled={opened} onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="unloaded-queue">
|
|
||||||
<ImportUnloadedQueueTab enabled={opened} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="dispatch-queue">
|
|
||||||
<ImportDispatchQueueTab enabled={opened} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="EXPORT">
|
<Tabs.Panel value="EXPORT">
|
||||||
<Tabs defaultValue="receive-queue" mt="xs">
|
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||||
<Tabs.List>
|
|
||||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="received">Received</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
|
||||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
|
||||||
</Tabs.List>
|
|
||||||
|
|
||||||
<Tabs.Panel value="receive-queue">
|
|
||||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="received">
|
|
||||||
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="ready-to-load">
|
|
||||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="loaded">
|
|
||||||
<LoadedExportTab enabled={opened} dispatchable={false} onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
<Tabs.Panel value="dispatch-queue">
|
|
||||||
<LoadedExportTab enabled={opened} dispatchable onChanged={onReceived} />
|
|
||||||
</Tabs.Panel>
|
|
||||||
</Tabs>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
) : activeDirection === 'IMPORT' ? (
|
||||||
|
<ImportWarehouseTabs enabled={enabled} onChanged={onChanged} />
|
||||||
|
) : (
|
||||||
|
<ExportWarehouseTabs enabled={enabled} location={location} onChanged={onChanged} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New bulk Receive: Import / Export tabs with eligible PAID bookings. */
|
||||||
|
function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={onClose} title="Receive at warehouse" centered size="80rem">
|
||||||
|
<Stack gap="md">
|
||||||
|
<WarehouseFlowWorkbench enabled={opened} direction="BOTH" onChanged={onReceived} />
|
||||||
|
|
||||||
<Group justify="flex-end" mt="sm">
|
<Group justify="flex-end" mt="sm">
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
|
|||||||
@@ -148,12 +148,13 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
|
<Modal opened={opened} onClose={onClose} title="Customer truck arrival and exit weighing" centered size="lg">
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
Register the customer truck and driver at arrival, record tare weight, then record gross
|
||||||
recorded net weight does not equal gross weight minus tare weight.
|
weight at exit after loading. Gate clearance is blocked when recorded net weight does not
|
||||||
|
equal gross weight minus tare weight.
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -224,7 +225,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||||
Exit Inspection & View Exit Paper
|
Save Truck Arrival & View Exit Paper
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface WarehouseInventoryTableProps {
|
|||||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||||
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
onReleaseDocument?: (item: WarehouseInventoryItem) => void;
|
||||||
|
onHandoverDocument?: (item: WarehouseInventoryItem) => void;
|
||||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||||
selectedIds?: Set<string>;
|
selectedIds?: Set<string>;
|
||||||
onToggleSelect?: (id: string) => void;
|
onToggleSelect?: (id: string) => void;
|
||||||
@@ -55,6 +56,7 @@ export function WarehouseInventoryTable({
|
|||||||
onInspect,
|
onInspect,
|
||||||
onFeePreview,
|
onFeePreview,
|
||||||
onReleaseDocument,
|
onReleaseDocument,
|
||||||
|
onHandoverDocument,
|
||||||
onLastMile,
|
onLastMile,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
@@ -105,6 +107,10 @@ export function WarehouseInventoryTable({
|
|||||||
const kind = itemKind(item);
|
const kind = itemKind(item);
|
||||||
const busy = busyId === item.id;
|
const busy = busyId === item.id;
|
||||||
const nextAction = getNextInventoryAction(item);
|
const nextAction = getNextInventoryAction(item);
|
||||||
|
const canGenerateHandover =
|
||||||
|
item.inspectionStatus === 'PASSED' &&
|
||||||
|
Boolean(item.bookingId) &&
|
||||||
|
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={item.id}>
|
<Table.Tr key={item.id}>
|
||||||
@@ -164,7 +170,7 @@ export function WarehouseInventoryTable({
|
|||||||
loading={busy}
|
loading={busy}
|
||||||
onClick={() => onAdvance(item, nextAction)}
|
onClick={() => onAdvance(item, nextAction)}
|
||||||
>
|
>
|
||||||
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
{nextAction === 'release' ? 'Truck Arrival' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === 'READY_FOR_PICKUP' && (
|
{item.status === 'READY_FOR_PICKUP' && (
|
||||||
@@ -217,6 +223,13 @@ export function WarehouseInventoryTable({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
{onHandoverDocument && canGenerateHandover && (
|
||||||
|
<Tooltip label="Generate customer handover document" withArrow>
|
||||||
|
<ActionIcon variant="subtle" color="teal" onClick={() => onHandoverDocument(item)}>
|
||||||
|
<FileText size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
{onLastMile && item.booking?.lastMileDeliveryAddress && (
|
||||||
<Tooltip label="Last mile delivery" withArrow>
|
<Tooltip label="Last mile delivery" withArrow>
|
||||||
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
<ActionIcon variant="subtle" color="blue" onClick={() => onLastMile(item)}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export { WarehouseInquiryTable } from './WarehouseInquiryTable';
|
|||||||
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
export { CreateWarehouseModal } from './CreateWarehouseModal';
|
||||||
export { CreateYardModal } from './CreateYardModal';
|
export { CreateYardModal } from './CreateYardModal';
|
||||||
export { CreateZoneModal } from './CreateZoneModal';
|
export { CreateZoneModal } from './CreateZoneModal';
|
||||||
export { ReceiveInventoryModal } from './ReceiveInventoryModal';
|
export { ReceiveInventoryModal, WarehouseFlowWorkbench } from './ReceiveInventoryModal';
|
||||||
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
export { WarehouseInfoCard } from './WarehouseInfoCard';
|
||||||
export { MoveInventoryModal } from './MoveInventoryModal';
|
export { MoveInventoryModal } from './MoveInventoryModal';
|
||||||
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
export { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
|
import type {
|
||||||
|
ImportUnloadedItem,
|
||||||
|
WarehouseFeeInvoice,
|
||||||
|
WarehouseInventoryItem,
|
||||||
|
} from '@/types/warehouse';
|
||||||
import type { BookingDetail } from '@/types/booking';
|
import type { BookingDetail } from '@/types/booking';
|
||||||
|
|
||||||
type PdfLine = {
|
type PdfLine = {
|
||||||
@@ -19,6 +23,11 @@ export interface WarehouseExitPaperContext {
|
|||||||
releasedAt?: Date;
|
releasedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WarehouseHandoverPdfContext {
|
||||||
|
item: ImportUnloadedItem | WarehouseInventoryItem;
|
||||||
|
handedOverAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const escapePdfText = (value: string) =>
|
const escapePdfText = (value: string) =>
|
||||||
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
|
||||||
@@ -214,6 +223,67 @@ const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseI
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shortId = (value: unknown) => {
|
||||||
|
const text = firstText(value);
|
||||||
|
return text === '-' ? '-' : text.slice(0, 8);
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactWeight = (value: unknown) => {
|
||||||
|
const num = Number(value ?? 0);
|
||||||
|
if (!Number.isFinite(num) || num <= 0) return '-';
|
||||||
|
return num.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handoverValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||||
|
(item as unknown as Record<string, unknown>)[key];
|
||||||
|
|
||||||
|
const handoverBookingValue = (item: ImportUnloadedItem | WarehouseInventoryItem, key: string) =>
|
||||||
|
((item as WarehouseInventoryItem).booking as unknown as Record<string, unknown> | null | undefined)?.[key];
|
||||||
|
|
||||||
|
export function buildWarehouseHandoverPdf({ item, handedOverAt = new Date() }: WarehouseHandoverPdfContext) {
|
||||||
|
const bookingReference = firstText(
|
||||||
|
handoverValue(item, 'bookingReference'),
|
||||||
|
handoverBookingValue(item, 'reference'),
|
||||||
|
item.bookingId,
|
||||||
|
);
|
||||||
|
const customerName = firstText(
|
||||||
|
handoverValue(item, 'customerName'),
|
||||||
|
handoverBookingValue(item, 'customerName'),
|
||||||
|
handoverBookingValue(item, 'companyName'),
|
||||||
|
);
|
||||||
|
const containerNumber = firstText(handoverValue(item, 'containerNumber'));
|
||||||
|
const cargoType = firstText(handoverValue(item, 'cargoType'), handoverValue(item, 'cargoDescription'));
|
||||||
|
const currentStatus = firstText(handoverValue(item, 'currentStatus'), (item as WarehouseInventoryItem).status);
|
||||||
|
const handoverReference = `HND-${bookingReference.replace(/[^a-zA-Z0-9]+/g, '-')}`;
|
||||||
|
const goodsSummary = firstText(containerNumber, cargoType, (item as WarehouseInventoryItem).goodsId);
|
||||||
|
|
||||||
|
return buildSimplePdf([
|
||||||
|
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
|
||||||
|
{ text: 'Import Goods Handover Document', size: 23, bold: true, yGap: 28, align: 'center' },
|
||||||
|
{ text: '[ EDR TO CUSTOMER ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
|
||||||
|
{ text: `Document No: ${handoverReference}`, bold: true, yGap: 30, align: 'center' },
|
||||||
|
{ text: `Handover Date & Time: ${fmtDate(handedOverAt)}`, align: 'center' },
|
||||||
|
{ text: `From: Ethio-Djibouti Railway S.C.`, align: 'center' },
|
||||||
|
{ text: `To Customer: ${customerName}`, align: 'center' },
|
||||||
|
{ text: `Customer ID: ${shortId(handoverValue(item, 'customerId'))}`, align: 'center' },
|
||||||
|
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
|
||||||
|
{ text: `Booking ID: ${shortId(item.bookingId)}`, align: 'center' },
|
||||||
|
{ text: `Train Schedule: ${firstText(handoverValue(item, 'trainSchedule'))}`, align: 'center' },
|
||||||
|
{ text: `Arrival Time: ${fmtDate(handoverValue(item, 'arrivalTime') ?? (item as WarehouseInventoryItem).arrivedAt)}`, align: 'center' },
|
||||||
|
{ text: `Release Order: ${firstText((item as WarehouseInventoryItem).releaseOrderReference)}`, align: 'center' },
|
||||||
|
{ text: `Release Date: ${fmtDate((item as WarehouseInventoryItem).releaseDate)}`, align: 'center' },
|
||||||
|
{ text: 'GOODS LIST', size: 13, bold: true, yGap: 30, align: 'center' },
|
||||||
|
{ text: `1. ${goodsSummary}`, bold: true, align: 'center' },
|
||||||
|
{ text: `Container: ${containerNumber} Cargo: ${cargoType}`, align: 'center' },
|
||||||
|
{ text: `Weight: ${compactWeight(item.weight)} Status: ${currentStatus}`, align: 'center' },
|
||||||
|
{ text: `Inspection: ${firstText(item.inspectionStatus)} Pickup Option: ${firstText(handoverValue(item, 'pickupOption'), 'TERMINAL_PICKUP')}`, align: 'center' },
|
||||||
|
{ text: `Warehouse: ${firstText((item as WarehouseInventoryItem).warehouse?.name, (item as WarehouseInventoryItem).warehouse?.code)} Yard: ${firstText((item as WarehouseInventoryItem).yard?.name, (item as WarehouseInventoryItem).yard?.code)}`, align: 'center' },
|
||||||
|
{ text: 'This document confirms EDR handed over the listed import goods to the customer after warehouse inspection.', yGap: 30, align: 'center' },
|
||||||
|
], [
|
||||||
|
...buildWarehouseOfficerSealBand(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
|
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
|
||||||
const context: WarehouseExitPaperContext =
|
const context: WarehouseExitPaperContext =
|
||||||
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
|
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export const URL_CONSTANTS = {
|
|||||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||||
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
RELEASE_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/release-document`,
|
||||||
|
HANDOVER_DOCUMENT: (id: string) => `/warehouse-inventory/${id}/handover-document`,
|
||||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||||
// Receive (Import/Export bulk)
|
// Receive (Import/Export bulk)
|
||||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||||
|
|||||||
@@ -180,8 +180,10 @@ export default function NewBookingPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [isGovernment, setIsGovernment] = useState(false);
|
const [isGovernment, setIsGovernment] = useState(false);
|
||||||
const [governmentInstitution, setGovernmentInstitution] = useState("");
|
|
||||||
const [companyId, setCompanyId] = useState<string | null>(null);
|
const [companyId, setCompanyId] = useState<string | null>(null);
|
||||||
|
// Government bookings bill to a real government company + an explicit profile.
|
||||||
|
const [govCompanyId, setGovCompanyId] = useState<string | null>(null);
|
||||||
|
const [govProfileId, setGovProfileId] = useState<string | null>(null);
|
||||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||||
@@ -220,6 +222,42 @@ export default function NewBookingPage() {
|
|||||||
label: c.name || c.email || c.tin || c.id,
|
label: c.name || c.email || c.tin || c.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Active government companies (kind=government) the booking can bill to.
|
||||||
|
const { data: govCompaniesPage, isLoading: govCompaniesLoading } = useQuery({
|
||||||
|
queryKey: ["companies", "government", "active"],
|
||||||
|
queryFn: () =>
|
||||||
|
customersService.list({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 1000,
|
||||||
|
kind: "government",
|
||||||
|
status: "active",
|
||||||
|
}),
|
||||||
|
enabled: isGovernment,
|
||||||
|
});
|
||||||
|
|
||||||
|
const govCompanies = govCompaniesPage?.items ?? [];
|
||||||
|
const govCompanyOptions = govCompanies.map((c) => ({
|
||||||
|
value: c.id,
|
||||||
|
label: c.name || c.tin || c.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Profiles (importer/exporter) of the chosen government company — the booking
|
||||||
|
// must link to one explicitly.
|
||||||
|
const selectedGovCompany = govCompanies.find((c) => c.id === govCompanyId);
|
||||||
|
const govProfileOptions = (selectedGovCompany?.companyProfiles ?? [])
|
||||||
|
.filter((p) => p.status === "active")
|
||||||
|
.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.type === "importer" ? "Import" : p.type === "exporter" ? "Export" : p.type}${
|
||||||
|
p.reference ? ` — ${p.reference}` : ""
|
||||||
|
}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset the chosen profile when the government company changes.
|
||||||
|
useEffect(() => {
|
||||||
|
setGovProfileId(null);
|
||||||
|
}, [govCompanyId]);
|
||||||
|
|
||||||
// Day-level pool: fetch only the days that have a departure on the route (no
|
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||||
// train, no capacity). The batch engine assigns the train after booking.
|
// train, no capacity). The batch engine assigns the train after booking.
|
||||||
const { data: availableDays, isLoading: daysLoading } = useQuery(
|
const { data: availableDays, isLoading: daysLoading } = useQuery(
|
||||||
@@ -306,7 +344,7 @@ export default function NewBookingPage() {
|
|||||||
Boolean(tradeDirection) &&
|
Boolean(tradeDirection) &&
|
||||||
Boolean(serviceTypeId) &&
|
Boolean(serviceTypeId) &&
|
||||||
departureSatisfied &&
|
departureSatisfied &&
|
||||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
(isGovernment ? Boolean(govCompanyId && govProfileId) : Boolean(companyId)) &&
|
||||||
(freightType === "BULK"
|
(freightType === "BULK"
|
||||||
? Boolean(cargoTypeId) && bulkWeight > 0
|
? Boolean(cargoTypeId) && bulkWeight > 0
|
||||||
: allLinesValid);
|
: allLinesValid);
|
||||||
@@ -320,8 +358,8 @@ export default function NewBookingPage() {
|
|||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
bookingsService.create({
|
bookingsService.create({
|
||||||
isGovernment,
|
isGovernment,
|
||||||
governmentInstitution: isGovernment ? governmentInstitution : undefined,
|
companyId: isGovernment ? govCompanyId || undefined : companyId || undefined,
|
||||||
companyId: isGovernment ? undefined : companyId || undefined,
|
companyProfileId: isGovernment ? govProfileId || undefined : undefined,
|
||||||
freightType,
|
freightType,
|
||||||
contractType: "NEW",
|
contractType: "NEW",
|
||||||
equipmentReturn,
|
equipmentReturn,
|
||||||
@@ -390,18 +428,37 @@ export default function NewBookingPage() {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Switch
|
<Switch
|
||||||
label="Government booking"
|
label="Government booking"
|
||||||
description="No company required — institution name instead. Expedited to the scheduling queue."
|
description="Bills to a government entity + profile. Expedited to the scheduling queue."
|
||||||
checked={isGovernment}
|
checked={isGovernment}
|
||||||
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
onChange={(e) => setIsGovernment(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
{isGovernment ? (
|
{isGovernment ? (
|
||||||
<TextInput
|
<Group grow align="flex-start">
|
||||||
label="Government institution"
|
<Select
|
||||||
placeholder="e.g. Ministry of Transport"
|
label="Government entity"
|
||||||
value={governmentInstitution}
|
placeholder="Select government company"
|
||||||
onChange={(e) => setGovernmentInstitution(e.currentTarget.value)}
|
data={govCompanyOptions}
|
||||||
required
|
value={govCompanyId}
|
||||||
/>
|
onChange={setGovCompanyId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
disabled={govCompaniesLoading}
|
||||||
|
nothingFoundMessage="No active government companies"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Profile"
|
||||||
|
placeholder={
|
||||||
|
govCompanyId ? "Select import/export profile" : "Pick an entity first"
|
||||||
|
}
|
||||||
|
data={govProfileOptions}
|
||||||
|
value={govProfileId}
|
||||||
|
onChange={setGovProfileId}
|
||||||
|
searchable
|
||||||
|
required
|
||||||
|
disabled={!govCompanyId}
|
||||||
|
nothingFoundMessage="No active profiles for this entity"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
label="Customer"
|
label="Customer"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Ruler,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
} from "@/services/first-mile.service";
|
} from "@/services/first-mile.service";
|
||||||
import { bookingsService } from "@/services/bookings.service";
|
import { bookingsService } from "@/services/bookings.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
|
import { ratesService } from "@/services/rates.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
@@ -312,6 +314,7 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||||
|
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [assignOpen, setAssignOpen] = useState(false);
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
@@ -330,6 +333,8 @@ const FirstMilePage = () => {
|
|||||||
|
|
||||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
const [distanceValue, setDistanceValue] = useState("");
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
|
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||||
@@ -347,6 +352,14 @@ const FirstMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: ratesData } = useQuery({
|
||||||
|
queryKey: ["rates", "FIRST_MILE"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await ratesService.getByType("FIRST_MILE");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
|
||||||
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
|
||||||
@@ -376,7 +389,7 @@ const FirstMilePage = () => {
|
|||||||
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
|
||||||
firstMileService.update(id, data),
|
firstMileService.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: "Update failed", variant: "destructive" });
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
@@ -384,10 +397,10 @@ const FirstMilePage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateDistanceMutation = useMutation({
|
const updateDistanceMutation = useMutation({
|
||||||
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
||||||
firstMileService.update(id, { exactKm }),
|
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||||
if (activeRecord) {
|
if (activeRecord) {
|
||||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||||
}
|
}
|
||||||
@@ -485,16 +498,39 @@ const FirstMilePage = () => {
|
|||||||
setDistanceValue("");
|
setDistanceValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openInvoice = (record: FirstMileRecord) => {
|
||||||
|
setInvoiceRecord(record);
|
||||||
|
setInvoiceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInvoice = () => {
|
||||||
|
setInvoiceOpen(false);
|
||||||
|
setInvoiceRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveDistance = () => {
|
const handleSaveDistance = () => {
|
||||||
const distance = parseFloat(distanceValue);
|
const distance = parseFloat(distanceValue);
|
||||||
if (!activeId || isNaN(distance) || distance < 0) {
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
|
|
||||||
|
let remainingPayment: number | undefined;
|
||||||
|
if (ratesData?.data) {
|
||||||
|
const firstMileRate = ratesData.data.find(
|
||||||
|
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||||
|
);
|
||||||
|
if (firstMileRate) {
|
||||||
|
const rateValue = parseFloat(firstMileRate.rateValue);
|
||||||
|
remainingPayment = distance * rateValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesFilter = (r: FirstMileRecord) => {
|
const matchesFilter = (r: FirstMileRecord) => {
|
||||||
|
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case "ALL": return true;
|
case "ALL": return true;
|
||||||
case "ASSIGNED": return isAssigned(r);
|
case "ASSIGNED": return isAssigned(r);
|
||||||
@@ -532,7 +568,7 @@ const FirstMilePage = () => {
|
|||||||
.includes(term);
|
.includes(term);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [records, search, statusFilter]);
|
}, [records, search, statusFilter, filterPostPaymentPending]);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
||||||
const pagedRecords = useMemo(() => {
|
const pagedRecords = useMemo(() => {
|
||||||
@@ -701,6 +737,27 @@ const FirstMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "invoice",
|
||||||
|
header: "Invoice",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||||
|
if (!hasDistance) {
|
||||||
|
return <Text c="dimmed">—</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => openInvoice(row.original)}
|
||||||
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
#345
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -767,9 +824,10 @@ const FirstMilePage = () => {
|
|||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
leftSection={<Ruler size={15} />}
|
||||||
onClick={() => openDistance(row.original.id)}
|
onClick={() => openDistance(row.original.id)}
|
||||||
>
|
>
|
||||||
Add Actual distance
|
Add distance
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
@@ -831,6 +889,17 @@ const FirstMilePage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={filterPostPaymentPending ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPostPaymentPending(!filterPostPaymentPending);
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Post Payment Pending
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1122,6 +1191,87 @@ const FirstMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Invoice modal */}
|
||||||
|
<Modal
|
||||||
|
opened={invoiceOpen}
|
||||||
|
onClose={closeInvoice}
|
||||||
|
title={<Text fw={600}>Invoice #345</Text>}
|
||||||
|
size="lg"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{invoiceRecord && (
|
||||||
|
<>
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700}>EDR Freight</Text>
|
||||||
|
<Text fw={600} size="sm">Invoice #345</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
|
||||||
|
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
|
||||||
|
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
|
||||||
|
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
|
||||||
|
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
|
||||||
|
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
|
||||||
|
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
|
||||||
|
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">Post Payment</Text>
|
||||||
|
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">Advanced Payment</Text>
|
||||||
|
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my="xs" />
|
||||||
|
{(() => {
|
||||||
|
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
|
||||||
|
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
|
||||||
|
const difference = postPayment - advancedPayment;
|
||||||
|
|
||||||
|
if (difference > 0) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Remaining to Pay</Text>
|
||||||
|
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
} else if (difference < 0) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Refund</Text>
|
||||||
|
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Status</Text>
|
||||||
|
<Text fw={700} c="blue">Settled</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeInvoice}>Close</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Printer,
|
Printer,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Ruler,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -21,12 +22,14 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
Menu,
|
Menu,
|
||||||
Modal,
|
Modal,
|
||||||
|
NumberInput,
|
||||||
ScrollArea,
|
ScrollArea,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
UnstyledButton,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||||
import { warehouseService } from "@/services/warehouse.service";
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
lastMileService,
|
lastMileService,
|
||||||
} from "@/services/last-mile.service";
|
} from "@/services/last-mile.service";
|
||||||
import { vehiclesService } from "@/services/vehicles.service";
|
import { vehiclesService } from "@/services/vehicles.service";
|
||||||
|
import { ratesService } from "@/services/rates.service";
|
||||||
|
|
||||||
const formatPrice = (amount: number) =>
|
const formatPrice = (amount: number) =>
|
||||||
`ETB ${amount.toLocaleString("en-US", {
|
`ETB ${amount.toLocaleString("en-US", {
|
||||||
@@ -294,6 +298,7 @@ const LastMilePage = () => {
|
|||||||
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||||
|
const [filterPostPaymentPending, setFilterPostPaymentPending] = useState(false);
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [assignOpen, setAssignOpen] = useState(false);
|
const [assignOpen, setAssignOpen] = useState(false);
|
||||||
@@ -311,6 +316,11 @@ const LastMilePage = () => {
|
|||||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||||
const [arrivalSearch, setArrivalSearch] = useState("");
|
const [arrivalSearch, setArrivalSearch] = useState("");
|
||||||
|
|
||||||
|
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||||
|
const [distanceValue, setDistanceValue] = useState("");
|
||||||
|
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||||
|
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
|
||||||
|
|
||||||
const { data: listData, isLoading } = useQuery({
|
const { data: listData, isLoading } = useQuery({
|
||||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -327,6 +337,14 @@ const LastMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: ratesData } = useQuery({
|
||||||
|
queryKey: ["rates", "LAST_MILE"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await ratesService.getByType("LAST_MILE");
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const records = listData?.data ?? [];
|
const records = listData?.data ?? [];
|
||||||
|
|
||||||
const vehicleOptions = useMemo(
|
const vehicleOptions = useMemo(
|
||||||
@@ -352,6 +370,21 @@ const LastMilePage = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateDistanceMutation = useMutation({
|
||||||
|
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
|
||||||
|
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
|
||||||
|
if (activeRecord) {
|
||||||
|
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||||
|
}
|
||||||
|
closeDistance();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({ title: "Update failed", variant: "destructive" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
|
||||||
queryKey: ["warehouse-inventory", "arrival-queue"],
|
queryKey: ["warehouse-inventory", "arrival-queue"],
|
||||||
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
|
||||||
@@ -422,6 +455,49 @@ const LastMilePage = () => {
|
|||||||
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
|
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDistance = (id: string) => {
|
||||||
|
setActiveId(id);
|
||||||
|
setDistanceValue("");
|
||||||
|
setDistanceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDistance = () => {
|
||||||
|
setDistanceOpen(false);
|
||||||
|
setActiveId(null);
|
||||||
|
setDistanceValue("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInvoice = (record: LastMileRecord) => {
|
||||||
|
setInvoiceRecord(record);
|
||||||
|
setInvoiceOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeInvoice = () => {
|
||||||
|
setInvoiceOpen(false);
|
||||||
|
setInvoiceRecord(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveDistance = () => {
|
||||||
|
const distance = parseFloat(distanceValue);
|
||||||
|
if (!activeId || isNaN(distance) || distance < 0) {
|
||||||
|
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remainingPayment: number | undefined;
|
||||||
|
if (ratesData?.data) {
|
||||||
|
const lastMileRate = ratesData.data.find(
|
||||||
|
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
|
||||||
|
);
|
||||||
|
if (lastMileRate) {
|
||||||
|
const rateValue = parseFloat(lastMileRate.rateValue);
|
||||||
|
remainingPayment = distance * rateValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
|
||||||
|
};
|
||||||
|
|
||||||
const activeRecord = useMemo(
|
const activeRecord = useMemo(
|
||||||
() => records.find((r) => r.id === activeId) ?? null,
|
() => records.find((r) => r.id === activeId) ?? null,
|
||||||
[records, activeId],
|
[records, activeId],
|
||||||
@@ -433,6 +509,7 @@ const LastMilePage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const matchesFilter = (r: LastMileRecord) => {
|
const matchesFilter = (r: LastMileRecord) => {
|
||||||
|
if (filterPostPaymentPending && r.isPostPaymentCompleted) return false;
|
||||||
switch (statusFilter) {
|
switch (statusFilter) {
|
||||||
case "ALL": return true;
|
case "ALL": return true;
|
||||||
case "ASSIGNED": return isAssigned(r);
|
case "ASSIGNED": return isAssigned(r);
|
||||||
@@ -470,7 +547,7 @@ const LastMilePage = () => {
|
|||||||
.includes(term);
|
.includes(term);
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [records, search, statusFilter]);
|
}, [records, search, statusFilter, filterPostPaymentPending]);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(filteredRecords.length / pagination.pageSize));
|
||||||
const pagedRecords = useMemo(() => {
|
const pagedRecords = useMemo(() => {
|
||||||
@@ -639,6 +716,27 @@ const LastMilePage = () => {
|
|||||||
meta: { headerClassName, cellClassName },
|
meta: { headerClassName, cellClassName },
|
||||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "invoice",
|
||||||
|
header: "Invoice",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
|
||||||
|
if (!hasDistance) {
|
||||||
|
return <Text c="dimmed">—</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => openInvoice(row.original)}
|
||||||
|
c="blue"
|
||||||
|
fw={500}
|
||||||
|
style={{ textDecoration: "underline", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
#345
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
@@ -704,6 +802,12 @@ const LastMilePage = () => {
|
|||||||
>
|
>
|
||||||
View detail
|
View detail
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<Ruler size={15} />}
|
||||||
|
onClick={() => openDistance(row.original.id)}
|
||||||
|
>
|
||||||
|
Add distance
|
||||||
|
</Menu.Item>
|
||||||
{canPrint && (
|
{canPrint && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<Printer size={15} />}
|
leftSection={<Printer size={15} />}
|
||||||
@@ -764,6 +868,17 @@ const LastMilePage = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={filterPostPaymentPending ? "filled" : "default"}
|
||||||
|
styles={{ label: { fontWeight: 500 } }}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPostPaymentPending(!filterPostPaymentPending);
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Post Payment Pending
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1000,6 +1115,134 @@ const LastMilePage = () => {
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Add Actual Distance modal */}
|
||||||
|
<Modal
|
||||||
|
opened={distanceOpen}
|
||||||
|
onClose={closeDistance}
|
||||||
|
title={<Text fw={600}>Add Actual Distance</Text>}
|
||||||
|
size="md"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{activeRecord && (
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Customer</Text>
|
||||||
|
<Text size="sm">{customerName(activeRecord)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
|
||||||
|
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<NumberInput
|
||||||
|
label="Actual Distance (KM)"
|
||||||
|
placeholder="Enter distance"
|
||||||
|
value={distanceValue}
|
||||||
|
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||||
|
min={0}
|
||||||
|
step={0.1}
|
||||||
|
decimalScale={2}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveDistance}
|
||||||
|
loading={updateDistanceMutation.isPending}
|
||||||
|
disabled={!distanceValue}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Invoice modal */}
|
||||||
|
<Modal
|
||||||
|
opened={invoiceOpen}
|
||||||
|
onClose={closeInvoice}
|
||||||
|
title={<Text fw={600}>Invoice #345</Text>}
|
||||||
|
size="lg"
|
||||||
|
radius="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
{invoiceRecord && (
|
||||||
|
<>
|
||||||
|
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text fw={700}>EDR Freight</Text>
|
||||||
|
<Text fw={600} size="sm">Invoice #345</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
|
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
|
||||||
|
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
|
||||||
|
<InfoRow label="Pickup" value={originYardName(invoiceRecord)} />
|
||||||
|
<InfoRow label="Destination" value={deliveryLocation(invoiceRecord)} />
|
||||||
|
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
|
||||||
|
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
|
||||||
|
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
|
||||||
|
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">Post Payment</Text>
|
||||||
|
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" c="dimmed">Advanced Payment</Text>
|
||||||
|
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider my="xs" />
|
||||||
|
{(() => {
|
||||||
|
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
|
||||||
|
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
|
||||||
|
const difference = postPayment - advancedPayment;
|
||||||
|
|
||||||
|
if (difference > 0) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Remaining to Pay</Text>
|
||||||
|
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
} else if (difference < 0) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Refund</Text>
|
||||||
|
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" w="100%">
|
||||||
|
<Text size="sm" fw={600}>Status</Text>
|
||||||
|
<Text fw={700} c="blue">Settled</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={closeInvoice}>Close</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -296,9 +296,15 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const canDispatch = schedule.status === "SCHEDULED";
|
const canDispatch = schedule.status === "SCHEDULED";
|
||||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
const canPrintMarshalling =
|
||||||
|
(schedule.direction === "IMPORT" || schedule.direction === "EXPORT") &&
|
||||||
|
["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||||
|
|
||||||
const openMarshallingDocument = async () => {
|
const openMarshallingDocument = async (options?: {
|
||||||
|
title?: string;
|
||||||
|
successDescription?: string;
|
||||||
|
errorTitle?: string;
|
||||||
|
}) => {
|
||||||
const pdfWindow = window.open("", "_blank");
|
const pdfWindow = window.open("", "_blank");
|
||||||
try {
|
try {
|
||||||
const blob = await downloadMarshalling.mutateAsync({
|
const blob = await downloadMarshalling.mutateAsync({
|
||||||
@@ -309,15 +315,17 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
toast({
|
toast({
|
||||||
title: "Marshalling document ready",
|
title: options?.title ?? "Marshalling document ready",
|
||||||
description: opened
|
description:
|
||||||
? "The PDF opened in a browser tab for printing or saving."
|
options?.successDescription ??
|
||||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
(opened
|
||||||
|
? "The PDF opened in a browser tab for printing or saving."
|
||||||
|
: "The browser blocked the preview tab, so the PDF was downloaded."),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pdfWindow?.close();
|
pdfWindow?.close();
|
||||||
toast({
|
toast({
|
||||||
title: "Could not open marshalling document",
|
title: options?.errorTitle ?? "Could not open marshalling document",
|
||||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
@@ -708,7 +716,11 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await dispatch.mutateAsync(scheduleId);
|
await dispatch.mutateAsync(scheduleId);
|
||||||
toast({ title: "Train dispatched" });
|
await openMarshallingDocument({
|
||||||
|
title: "Train dispatched",
|
||||||
|
successDescription: "Marshalling document generated for the dispatched train.",
|
||||||
|
errorTitle: "Train dispatched, but document could not open",
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast({
|
toast({
|
||||||
title: "Dispatch failed",
|
title: "Dispatch failed",
|
||||||
|
|||||||
@@ -231,8 +231,8 @@ export default function ArrivalQueuePage() {
|
|||||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
<Badge variant="light" color="teal" size="sm">
|
||||||
{fullyUnloaded ? 'UNLOADED' : train.status}
|
{train.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||||
|
|||||||
@@ -66,14 +66,14 @@ const statusLabel = (status?: string | null) =>
|
|||||||
: (status ?? 'PENDING').replace(/_/g, ' ');
|
: (status ?? 'PENDING').replace(/_/g, ' ');
|
||||||
|
|
||||||
function ExportTrainDetailRows({
|
function ExportTrainDetailRows({
|
||||||
scheduleId,
|
train,
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
}: {
|
}: {
|
||||||
scheduleId: string;
|
train: ExportTrain;
|
||||||
onOpenHistory: (inventoryId: string) => void;
|
onOpenHistory: (inventoryId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(scheduleId);
|
const { data: items = [], isLoading } = useExportDjiboutiTrainItems(train.scheduleId);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -92,10 +92,11 @@ function ExportTrainDetailRows({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.ScrollContainer minWidth={1320}>
|
<Table.ScrollContainer minWidth={1420}>
|
||||||
<Table highlightOnHover verticalSpacing="xs">
|
<Table highlightOnHover verticalSpacing="xs">
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
|
<Table.Th>Wagon</Table.Th>
|
||||||
<Table.Th>Booking ID</Table.Th>
|
<Table.Th>Booking ID</Table.Th>
|
||||||
<Table.Th>Booking Reference</Table.Th>
|
<Table.Th>Booking Reference</Table.Th>
|
||||||
<Table.Th>Customer ID</Table.Th>
|
<Table.Th>Customer ID</Table.Th>
|
||||||
@@ -115,6 +116,11 @@ function ExportTrainDetailRows({
|
|||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{items.map((item: ExportTrainItem) => (
|
{items.map((item: ExportTrainItem) => (
|
||||||
<Table.Tr key={`${item.bookingId}-${item.itemType}-${item.itemId ?? item.inventoryId ?? 'item'}`}>
|
<Table.Tr key={`${item.bookingId}-${item.itemType}-${item.itemId ?? item.inventoryId ?? 'item'}`}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="xs" fw={600}>
|
||||||
|
{item.sequenceNo ? `#${item.sequenceNo}` : '-'} {item.wagonNumber ?? ''}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
<Table.Td>{item.bookingId.slice(0, 8)}</Table.Td>
|
<Table.Td>{item.bookingId.slice(0, 8)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
@@ -384,7 +390,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
|||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={12} bg="var(--mantine-color-gray-0)">
|
<Table.Td colSpan={12} bg="var(--mantine-color-gray-0)">
|
||||||
<ExportTrainDetailRows
|
<ExportTrainDetailRows
|
||||||
scheduleId={train.scheduleId}
|
train={train}
|
||||||
onOpenHistory={setHistoryInventoryId}
|
onOpenHistory={setHistoryInventoryId}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Button, Card } from '@mantine/core';
|
||||||
|
import { PackageSearch } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
|
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||||
|
|
||||||
|
export default function ExportWarehouseFlowPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Export Operations"
|
||||||
|
subtitle="Manage export receive, terminal inventory, loading readiness, loaded items, and dispatch flow."
|
||||||
|
action={
|
||||||
|
<Button variant="light" leftSection={<PackageSearch size={16} />} onClick={() => navigate('/dashboard/import-warehouse')}>
|
||||||
|
Import Operations
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<WarehouseFlowWorkbench direction="EXPORT" />
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Button, Card } from '@mantine/core';
|
||||||
|
import { Truck } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
|
import { WarehouseFlowWorkbench } from '@/components/warehouses';
|
||||||
|
|
||||||
|
export default function ImportWarehouseFlowPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="Import Operations"
|
||||||
|
subtitle="Manage import gate flow, marshalling handoff, arrived trains, unloaded bookings, and dispatch-ready inventory."
|
||||||
|
action={
|
||||||
|
<Button variant="light" leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||||
|
Export Operations
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<WarehouseFlowWorkbench direction="IMPORT" />
|
||||||
|
</Card>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
import { Button, Card, Group, Select, Stack, TextInput } from '@mantine/core';
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
import { PackagePlus, Search } from 'lucide-react';
|
import { PackageOpen, Search, Truck } from 'lucide-react';
|
||||||
|
|
||||||
import { PageContainer, PageHeader } from '@/components/page';
|
import { PageContainer, PageHeader } from '@/components/page';
|
||||||
import {
|
import {
|
||||||
InventoryWorkbench,
|
InventoryWorkbench,
|
||||||
ReceiveInventoryModal,
|
|
||||||
inventoryStatusOptions,
|
inventoryStatusOptions,
|
||||||
} from '@/components/warehouses';
|
} from '@/components/warehouses';
|
||||||
import {
|
import {
|
||||||
@@ -19,13 +18,13 @@ import {
|
|||||||
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
import type { InventoryFilter, InventoryStatus } from '@/types/warehouse';
|
||||||
|
|
||||||
export default function WarehouseInventoryPage() {
|
export default function WarehouseInventoryPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
const initialStatus = (searchParams.get('status') as InventoryStatus | null) ?? undefined;
|
||||||
const [filter, setFilter] = useState<InventoryFilter>(
|
const [filter, setFilter] = useState<InventoryFilter>(
|
||||||
initialStatus ? { status: initialStatus } : {},
|
initialStatus ? { status: initialStatus } : {},
|
||||||
);
|
);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
const queryFilter = useMemo<InventoryFilter>(
|
const queryFilter = useMemo<InventoryFilter>(
|
||||||
@@ -57,9 +56,18 @@ export default function WarehouseInventoryPage() {
|
|||||||
title="Warehouse Inventory"
|
title="Warehouse Inventory"
|
||||||
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
subtitle="Track received items through the storage, reservation, loading and dispatch lifecycle."
|
||||||
action={
|
action={
|
||||||
<Button leftSection={<PackagePlus size={16} />} onClick={() => setModalOpen(true)}>
|
<Group gap="xs">
|
||||||
Receive Inventory
|
<Button
|
||||||
</Button>
|
variant="light"
|
||||||
|
leftSection={<PackageOpen size={16} />}
|
||||||
|
onClick={() => navigate('/dashboard/import-warehouse')}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button leftSection={<Truck size={16} />} onClick={() => navigate('/dashboard/export-warehouse')}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -126,8 +134,6 @@ export default function WarehouseInventoryPage() {
|
|||||||
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
<InventoryWorkbench items={inventoryQuery.data ?? []} isLoading={inventoryQuery.isLoading} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ReceiveInventoryModal opened={modalOpen} onClose={() => setModalOpen(false)} />
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { api } from '../auth/http';
|
||||||
|
|
||||||
|
export interface Rate {
|
||||||
|
id: string;
|
||||||
|
rateType: string;
|
||||||
|
appliesTo: string;
|
||||||
|
trigger: string;
|
||||||
|
containerTypeId: string | null;
|
||||||
|
cargoTypeId: string | null;
|
||||||
|
tradeDirection: string | null;
|
||||||
|
currency: string;
|
||||||
|
rateValue: string;
|
||||||
|
rateUnit: string;
|
||||||
|
status: string;
|
||||||
|
proposedByStaffId: string;
|
||||||
|
approvedByCeoId: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
effectiveFrom: string;
|
||||||
|
effectiveTo: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatesListResponse {
|
||||||
|
data: Rate[];
|
||||||
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ratesService = {
|
||||||
|
list: (pageSize = 1000) =>
|
||||||
|
api.get<RatesListResponse>(`/rates?pageSize=${pageSize}`),
|
||||||
|
getByType: (rateType: string) =>
|
||||||
|
api.get<RatesListResponse>(`/rates?rateType=${rateType}&pageSize=1000`),
|
||||||
|
};
|
||||||
@@ -137,6 +137,10 @@ export const warehouseService = {
|
|||||||
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE_DOCUMENT(id), {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
}),
|
}),
|
||||||
|
downloadHandoverDocument: (id: string) =>
|
||||||
|
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
|
||||||
|
responseType: 'blob',
|
||||||
|
}),
|
||||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export type CompanyType =
|
|||||||
/** Mirrors backend `CompanyStatus`. */
|
/** Mirrors backend `CompanyStatus`. */
|
||||||
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
|
export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted";
|
||||||
|
|
||||||
|
/** Mirrors backend `CompanyKind` — commercial customer vs. government entity. */
|
||||||
|
export type CompanyKind = "commercial" | "government";
|
||||||
|
|
||||||
/** Mirrors backend `ProfileType` (the role a company plays). */
|
/** Mirrors backend `ProfileType` (the role a company plays). */
|
||||||
export type ProfileType =
|
export type ProfileType =
|
||||||
| "importer"
|
| "importer"
|
||||||
@@ -47,6 +50,7 @@ export interface Company {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: CompanyType;
|
type: CompanyType;
|
||||||
|
kind: CompanyKind;
|
||||||
status: CompanyStatus;
|
status: CompanyStatus;
|
||||||
tin: string;
|
tin: string;
|
||||||
vatNumber?: string | null;
|
vatNumber?: string | null;
|
||||||
@@ -73,6 +77,7 @@ export interface CompanyListFilter {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
type?: CompanyType;
|
type?: CompanyType;
|
||||||
|
kind?: CompanyKind;
|
||||||
status?: CompanyStatus;
|
status?: CompanyStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -517,6 +517,9 @@ export interface ExportTrainItem {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
allocatedWeightTons: number | null;
|
||||||
itemType: 'CONTAINER' | 'CARGO';
|
itemType: 'CONTAINER' | 'CARGO';
|
||||||
itemId: string | null;
|
itemId: string | null;
|
||||||
inventoryId: string | null;
|
inventoryId: string | null;
|
||||||
@@ -566,6 +569,9 @@ export interface ImportUnloadedItem {
|
|||||||
pickupOption: string;
|
pickupOption: string;
|
||||||
lastMileRequested: boolean;
|
lastMileRequested: boolean;
|
||||||
currentStatus: string;
|
currentStatus: string;
|
||||||
|
releaseDate: string | null;
|
||||||
|
releaseOrderReference: string | null;
|
||||||
|
deliveredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportTrainItem {
|
export interface ImportTrainItem {
|
||||||
@@ -573,6 +579,9 @@ export interface ImportTrainItem {
|
|||||||
bookingReference: string | null;
|
bookingReference: string | null;
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
customerName: string | null;
|
customerName: string | null;
|
||||||
|
wagonNumber: string | null;
|
||||||
|
sequenceNo: number | null;
|
||||||
|
allocatedWeightTons: number | null;
|
||||||
containerNumber: string | null;
|
containerNumber: string | null;
|
||||||
cargoType: string | null;
|
cargoType: string | null;
|
||||||
weight: number | null;
|
weight: number | null;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import { loadEnv, type Plugin } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
@@ -11,7 +10,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const streamBrowserifyPath = require.resolve("stream-browserify");
|
const streamBrowserifyPath = require.resolve("stream-browserify");
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import LoginPage from "./pages/accounts/LoginPage";
|
|||||||
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
|
||||||
import BillingPage from "./pages/billing/BillingPage";
|
import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
|
||||||
|
import InvoicesList from "./pages/billing/InvoicesList";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||||
@@ -188,7 +189,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
icon: <MapPin size={18} />,
|
icon: <MapPin size={18} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Billing",
|
label: "Invoices",
|
||||||
href: "/billing",
|
href: "/billing",
|
||||||
icon: <Receipt size={18} />,
|
icon: <Receipt size={18} />,
|
||||||
},
|
},
|
||||||
@@ -285,7 +286,8 @@ const App = () => {
|
|||||||
/>
|
/>
|
||||||
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<InvoicesList />} />
|
||||||
|
<Route path="/billing/:id" element={<InvoiceDetailPage />} />
|
||||||
{/* Profile was merged into Settings — keep old links working. */}
|
{/* Profile was merged into Settings — keep old links working. */}
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
|
|||||||
@@ -143,4 +143,10 @@ export const URL_CONSTANTS = {
|
|||||||
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
INTENT: (bookingId: string) => `/api/payments/intents/${bookingId}`,
|
||||||
CHECKOUT: "/api/payments/checkout",
|
CHECKOUT: "/api/payments/checkout",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
BILLING: {
|
||||||
|
MY_INVOICES: "/api/billing/my-invoices",
|
||||||
|
MY_INVOICE_BY_ID: (id: string) => `/api/billing/my-invoices/${id}`,
|
||||||
|
PAY_INVOICE: (id: string) => `/api/billing/my-invoices/${id}/pay`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
23
apps/edr-freight-web/portal/src/lib/currency.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** Currency code carried on invoices / dashboard figures (ETB, USD, DJF, …). */
|
||||||
|
export type Currency = string;
|
||||||
|
|
||||||
|
const SYMBOLS: Record<string, string> = {
|
||||||
|
USD: "$",
|
||||||
|
ETB: "Br",
|
||||||
|
DJF: "DJF",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a money amount with its currency symbol, e.g. `Br 12,500.00`.
|
||||||
|
* Unknown currency codes fall back to printing the raw code.
|
||||||
|
*/
|
||||||
|
export function formatCurrency(
|
||||||
|
amount: number,
|
||||||
|
currency: Currency = "ETB",
|
||||||
|
): string {
|
||||||
|
const symbol = SYMBOLS[currency] ?? currency;
|
||||||
|
return `${symbol} ${Number(amount ?? 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { invoices, type Invoice } from "@/pages/billing/invoices.mock";
|
|
||||||
import { customers, type Customer } from "@/pages/customers/customers.mock";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock "logged-in customer". When auth integrates, replace this with the value
|
|
||||||
* pulled from `@edr/iamui-common` / the JWT context.
|
|
||||||
*/
|
|
||||||
const CURRENT_CUSTOMER_ID = 1;
|
|
||||||
|
|
||||||
export function getCurrentCustomer(): Customer {
|
|
||||||
return (
|
|
||||||
customers.find((c) => c.id === CURRENT_CUSTOMER_ID) ??
|
|
||||||
(customers[0] as Customer)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMyInvoices(): Invoice[] {
|
|
||||||
const me = getCurrentCustomer();
|
|
||||||
return invoices.filter((inv) => inv.customerId === me.id);
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
import type { Currency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { Group, Grid, Select, Stack } from "@mantine/core";
|
import { Group, Grid, Select, Stack } from "@mantine/core";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -34,7 +34,6 @@ export default function MyPortalPage() {
|
|||||||
totalOutstanding,
|
totalOutstanding,
|
||||||
companyName,
|
companyName,
|
||||||
greeting,
|
greeting,
|
||||||
recentInvoices,
|
|
||||||
dashboard,
|
dashboard,
|
||||||
volumePoints,
|
volumePoints,
|
||||||
maxVolume,
|
maxVolume,
|
||||||
@@ -120,7 +119,7 @@ export default function MyPortalPage() {
|
|||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|
||||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
<InvoicesSection invoices={recentInvoices} />
|
<InvoicesSection invoices={outstandingInvoices} />
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
import { Box, Group, Skeleton, Text } from "@mantine/core";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import type { Currency } from "@/pages/billing/invoices.mock";
|
import type { Currency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { formatPct } from "../constants";
|
import { formatPct } from "../constants";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Currency, InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import type { PortalInvoice } from "@/services/invoices.service";
|
||||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
import { format } from "date-fns";
|
import { Freight } from "@edr/types";
|
||||||
import { CheckCircle2, ChevronRight, Clock3, Zap } from "lucide-react";
|
import { ChevronRight, Clock3 } from "lucide-react";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { cv, INVOICE_BADGE } from "../constants";
|
import { cv, INVOICE_BADGE } from "../constants";
|
||||||
@@ -10,26 +10,26 @@ import { Card } from "./Card";
|
|||||||
import { EmptyState } from "./EmptyState";
|
import { EmptyState } from "./EmptyState";
|
||||||
|
|
||||||
interface InvoicesSectionProps {
|
interface InvoicesSectionProps {
|
||||||
invoices: Array<{
|
invoices: PortalInvoice[];
|
||||||
id: number;
|
|
||||||
number: string;
|
|
||||||
bookingReference: string;
|
|
||||||
amount: number;
|
|
||||||
currency: Currency;
|
|
||||||
status: InvoiceStatus;
|
|
||||||
dueDate: string;
|
|
||||||
paidDate: string | null;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max rows to list in the compact dashboard card. */
|
||||||
|
const MAX_ROWS = 5;
|
||||||
|
|
||||||
|
const titleCase = (v: string) =>
|
||||||
|
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "";
|
||||||
|
|
||||||
export const InvoicesSection = memo(function InvoicesSection({
|
export const InvoicesSection = memo(function InvoicesSection({
|
||||||
invoices,
|
invoices,
|
||||||
}: InvoicesSectionProps) {
|
}: InvoicesSectionProps) {
|
||||||
const outstandingInvoices = invoices.filter(
|
// Dashboard shows unpaid invoices only (pending + overdue).
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
const pendingInvoices = invoices.filter(
|
||||||
|
(inv) =>
|
||||||
|
inv.status === Freight.InvoiceStatus.Pending ||
|
||||||
|
inv.status === Freight.InvoiceStatus.Overdue,
|
||||||
);
|
);
|
||||||
const totalOutstanding = outstandingInvoices.reduce(
|
const totalOutstanding = pendingInvoices.reduce(
|
||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + Number(inv.totalAmount),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -56,94 +56,74 @@ export const InvoicesSection = memo(function InvoicesSection({
|
|||||||
<Text fz={24} fw={800} mt={4} c="edr-text">
|
<Text fz={24} fw={800} mt={4} c="edr-text">
|
||||||
{formatCurrency(totalOutstanding || 0, "ETB")}
|
{formatCurrency(totalOutstanding || 0, "ETB")}
|
||||||
</Text>
|
</Text>
|
||||||
<Group
|
<Text fz={12} mt={8} c="edr-amber-text">
|
||||||
justify="space-between"
|
{pendingInvoices.length} invoices unpaid
|
||||||
align="center"
|
</Text>
|
||||||
mt={8}
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Text fz={12} c="edr-amber-text">
|
|
||||||
{outstandingInvoices.length || 2} invoices unpaid
|
|
||||||
</Text>
|
|
||||||
<Group
|
|
||||||
gap={5}
|
|
||||||
align="center"
|
|
||||||
px={14}
|
|
||||||
py={8}
|
|
||||||
bg="edr-accent"
|
|
||||||
className="cursor-pointer rounded-[9px]"
|
|
||||||
>
|
|
||||||
<Zap size={15} color="#fff" />
|
|
||||||
<Text fz={13} fw={700} c="white">
|
|
||||||
Pay all
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{invoices.length === 0 ? (
|
{pendingInvoices.length === 0 ? (
|
||||||
<EmptyState message="No invoices yet." />
|
<EmptyState message="No pending invoices." />
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
{invoices.map((invoice, i) => {
|
{pendingInvoices.slice(0, MAX_ROWS).map((invoice, i) => {
|
||||||
const badge = INVOICE_BADGE[invoice.status];
|
const badge = INVOICE_BADGE[invoice.status];
|
||||||
const dueText =
|
const isOverdue = invoice.status === Freight.InvoiceStatus.Overdue;
|
||||||
invoice.status === "Paid"
|
const dueText = isOverdue
|
||||||
? `Paid ${format(new Date(invoice.paidDate ?? invoice.dueDate), "MMM d")}`
|
? "Overdue"
|
||||||
: invoice.status === "Overdue"
|
: `Due ${new Date(invoice.dueAt).toLocaleDateString()}`;
|
||||||
? "Overdue 3 days"
|
const dueIconColor = isOverdue ? cv("edr-red") : cv("edr-muted");
|
||||||
: `Due ${invoice.dueDate}`;
|
|
||||||
const DueIcon =
|
|
||||||
invoice.status === "Paid" ? CheckCircle2 : Clock3;
|
|
||||||
const dueIconColor =
|
|
||||||
invoice.status === "Paid"
|
|
||||||
? cv("edr-green.5")
|
|
||||||
: cv("edr-muted");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={invoice.id}>
|
<Box key={invoice.id}>
|
||||||
{i > 0 && <Box h={1} bg="edr-divider" />}
|
{i > 0 && <Box h={1} bg="edr-divider" />}
|
||||||
<Stack gap={8} py={10}>
|
<Link
|
||||||
<Group
|
to={`/billing/${invoice.id}`}
|
||||||
justify="space-between"
|
className="no-underline"
|
||||||
align="flex-start"
|
style={{ display: "block", color: "inherit" }}
|
||||||
wrap="nowrap"
|
>
|
||||||
|
<Stack
|
||||||
|
gap={8}
|
||||||
|
py={10}
|
||||||
|
className="cursor-pointer rounded-[9px] transition-colors hover:bg-[var(--mantine-color-edr-soft-0)]"
|
||||||
>
|
>
|
||||||
<Box>
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
<Text fz={13} fw={700} c="edr-text">
|
<Box>
|
||||||
{invoice.number}
|
<Text fz={13} fw={700} c="edr-text">
|
||||||
</Text>
|
{invoice.invoiceNumber}
|
||||||
<Text fz={11} c="edr-muted">
|
</Text>
|
||||||
{invoice.bookingReference}
|
<Text fz={11} c="edr-muted">
|
||||||
</Text>
|
{titleCase(invoice.source)} · {titleCase(invoice.type)}
|
||||||
</Box>
|
</Text>
|
||||||
<Text fz={14} fw={700} c="edr-text">
|
</Box>
|
||||||
{formatCurrency(invoice.amount, invoice.currency)}
|
<Text fz={14} fw={700} c="edr-text">
|
||||||
</Text>
|
{formatCurrency(
|
||||||
</Group>
|
Number(invoice.totalAmount),
|
||||||
<Group
|
invoice.currency,
|
||||||
justify="space-between"
|
)}
|
||||||
align="center"
|
|
||||||
wrap="nowrap"
|
|
||||||
>
|
|
||||||
<Group gap={5} align="center">
|
|
||||||
<DueIcon size={13} color={dueIconColor} />
|
|
||||||
<Text fz={12} c="edr-muted">
|
|
||||||
{dueText}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Box
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
bg={badge.bg}
|
<Group gap={5} align="center">
|
||||||
px={10}
|
<Clock3 size={13} color={dueIconColor} />
|
||||||
py={4}
|
<Text fz={12} c="edr-muted">
|
||||||
className="rounded-full"
|
{dueText}
|
||||||
>
|
</Text>
|
||||||
<Text fz={11} fw={700} c={badge.text}>
|
</Group>
|
||||||
{badge.label}
|
{badge && (
|
||||||
</Text>
|
<Box
|
||||||
</Box>
|
bg={badge.bg}
|
||||||
</Group>
|
px={10}
|
||||||
</Stack>
|
py={4}
|
||||||
|
className="rounded-full"
|
||||||
|
>
|
||||||
|
<Text fz={11} fw={700} c={badge.text}>
|
||||||
|
{badge.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatCurrency } from "@/pages/billing/invoices.mock";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { SimpleGrid } from "@mantine/core";
|
import { SimpleGrid } from "@mantine/core";
|
||||||
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
import { CheckCircle2, Clock3, Layers, Truck, Wallet } from "lucide-react";
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
export const cv = (token: string) => {
|
export const cv = (token: string) => {
|
||||||
const [name, shade] = token.split(".");
|
const [name, shade] = token.split(".");
|
||||||
@@ -514,12 +514,13 @@ export const ACTION_PROPS: Record<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const INVOICE_BADGE: Record<
|
export const INVOICE_BADGE: Record<
|
||||||
InvoiceStatus,
|
Freight.InvoiceStatus,
|
||||||
{ label: string; bg: string; text: string }
|
{ label: string; bg: string; text: string }
|
||||||
> = {
|
> = {
|
||||||
Draft: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
Sent: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
[Freight.InvoiceStatus.Pending]: { label: "Due soon", bg: "edr-amber-soft", text: "edr-amber-text" },
|
||||||
Paid: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "edr-soft", text: "edr-green.7" },
|
||||||
Overdue: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "edr-red-soft", text: "edr-red" },
|
||||||
Cancelled: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "edr-slate-soft", text: "edr-slate" },
|
||||||
|
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "edr-blue-soft", text: "edr-blue" },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo } from "react";
|
import { Freight } from "@edr/types";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { getMyInvoices } from "@/lib/currentCustomer";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { ACTIVE_STATUSES } from "./constants";
|
import { ACTIVE_STATUSES } from "./constants";
|
||||||
|
|
||||||
export function useMyPortalData(selectedProfileId?: string) {
|
export function useMyPortalData(selectedProfileId?: string) {
|
||||||
const { user, customer, company } = useAuth();
|
const { user, customer, company } = useAuth();
|
||||||
const myInvoices = useMemo(() => getMyInvoices(), []);
|
|
||||||
|
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
|
||||||
|
const myInvoices = invoicesQuery.data ?? [];
|
||||||
|
|
||||||
const companyProfiles = company?.company?.companyProfiles ?? [];
|
const companyProfiles = company?.company?.companyProfiles ?? [];
|
||||||
|
|
||||||
@@ -59,11 +60,13 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
).length;
|
).length;
|
||||||
|
|
||||||
const outstandingInvoices = myInvoices.filter(
|
const outstandingInvoices = myInvoices.filter(
|
||||||
(inv) => inv.status === "Sent" || inv.status === "Overdue",
|
(inv) =>
|
||||||
|
inv.status === Freight.InvoiceStatus.Pending ||
|
||||||
|
inv.status === Freight.InvoiceStatus.Overdue,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalOutstanding = outstandingInvoices.reduce(
|
const totalOutstanding = outstandingInvoices.reduce(
|
||||||
(sum, inv) => sum + inv.amount,
|
(sum, inv) => sum + Number(inv.totalAmount),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +94,7 @@ export function useMyPortalData(selectedProfileId?: string) {
|
|||||||
bookingsQuery,
|
bookingsQuery,
|
||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
contractsQuery,
|
contractsQuery,
|
||||||
|
invoicesQuery,
|
||||||
allContracts,
|
allContracts,
|
||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
DollarSign,
|
|
||||||
Download,
|
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Receipt,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewInvoicePage from "./NewInvoicePage";
|
|
||||||
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
|
|
||||||
import { formatCurrency, invoices, type InvoiceStatus } from "./invoices.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type FilterValue = "All" | InvoiceStatus;
|
|
||||||
|
|
||||||
const FILTERS: FilterValue[] = [
|
|
||||||
"All",
|
|
||||||
"Draft",
|
|
||||||
"Sent",
|
|
||||||
"Paid",
|
|
||||||
"Overdue",
|
|
||||||
"Cancelled",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function BillingPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [filter, setFilter] = useState<FilterValue>("All");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return invoices.filter((inv) => {
|
|
||||||
if (filter !== "All" && inv.status !== filter) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
inv.number.toLowerCase().includes(q) ||
|
|
||||||
inv.customer.toLowerCase().includes(q) ||
|
|
||||||
inv.bookingReference.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [filter, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalRevenue = invoices
|
|
||||||
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const outstanding = invoices
|
|
||||||
.filter(
|
|
||||||
(inv) =>
|
|
||||||
(inv.status === "Sent" || inv.status === "Overdue") &&
|
|
||||||
inv.currency === "USD",
|
|
||||||
)
|
|
||||||
.reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const overdueCount = invoices.filter(
|
|
||||||
(inv) => inv.status === "Overdue",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<(typeof invoices)[number]>[] = [
|
|
||||||
{
|
|
||||||
id: "invoice",
|
|
||||||
header: "Invoice",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const inv = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
||||||
<Receipt />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-slate-900">{inv.number}</p>
|
|
||||||
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "customer",
|
|
||||||
header: "Customer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "bookingReference",
|
|
||||||
header: "Booking",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "amount",
|
|
||||||
header: "Amount",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const inv = row.original;
|
|
||||||
return (
|
|
||||||
<span className="text-sm font-medium text-slate-900">
|
|
||||||
{formatCurrency(inv.amount, inv.currency)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "dueDate",
|
|
||||||
header: "Due Date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const invoice = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Download />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<NewInvoicePage
|
|
||||||
mode="edit"
|
|
||||||
invoice={{
|
|
||||||
number: invoice.number,
|
|
||||||
customerId: invoice.customerId,
|
|
||||||
bookingReference: invoice.bookingReference,
|
|
||||||
amount: invoice.amount,
|
|
||||||
currency: invoice.currency,
|
|
||||||
status: invoice.status,
|
|
||||||
issueDate: invoice.issueDate,
|
|
||||||
dueDate: invoice.dueDate,
|
|
||||||
notes: invoice.notes,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewInvoicePage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Void
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteInvoiceDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Billing" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Billing
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Manage invoices, payments, and financial records.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search invoices..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewInvoicePage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
New Invoice
|
|
||||||
</Button>
|
|
||||||
</NewInvoicePage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Revenue (USD)</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{formatCurrency(totalRevenue, "USD")}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<DollarSign />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Outstanding (USD)</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{formatCurrency(outstanding, "USD")}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Clock />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Overdue Invoices</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{overdueCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
|
|
||||||
<AlertCircle />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FILTERS.map((f) => {
|
|
||||||
const isActive = f === filter;
|
|
||||||
const count =
|
|
||||||
f === "All"
|
|
||||||
? invoices.length
|
|
||||||
: invoices.filter((inv) => inv.status === f).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter(f);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
||||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Invoices</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Issued invoices and their payment status.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={() => { }}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: InvoiceStatus }) {
|
|
||||||
const styles: Record<InvoiceStatus, string> = {
|
|
||||||
Draft: "bg-slate-100 text-slate-600",
|
|
||||||
Sent: "bg-sky-100 text-sky-700",
|
|
||||||
Paid: "bg-emerald-100 text-emerald-700",
|
|
||||||
Overdue: "bg-red-100 text-red-700",
|
|
||||||
Cancelled: "bg-amber-100 text-amber-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteInvoiceDialogProps {
|
|
||||||
invoiceNumber: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteInvoiceDialog({
|
|
||||||
invoiceNumber,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteInvoiceDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Void invoice?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will void invoice{" "}
|
|
||||||
<span className="font-semibold text-slate-900">
|
|
||||||
{invoiceNumber}
|
|
||||||
</span>
|
|
||||||
. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Void Invoice
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ArrowLeft, CreditCard, Info } from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||||
|
import {
|
||||||
|
billedTo,
|
||||||
|
fmtDate,
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
isPayable,
|
||||||
|
titleCase,
|
||||||
|
} from "./invoice-ui";
|
||||||
|
|
||||||
|
function MetaItem({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fz={11} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz={14} mt={4} style={{ color: INK }}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDetailPage() {
|
||||||
|
const { id = "" } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: invoice, isLoading, isError } = useQuery(
|
||||||
|
api.invoices.get.queryOptions({ input: { id } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payMutation = useMutation(
|
||||||
|
api.invoices.pay.mutationOptions({
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const url = res.clientAction?.url;
|
||||||
|
if (url) window.location.href = url;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Center py={80}>
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !invoice) {
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px" }}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/billing")}
|
||||||
|
mb="md"
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
<Alert color="red" title="Invoice not found">
|
||||||
|
We couldn't load this invoice. It may not exist or you may not have
|
||||||
|
access to it.
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = isPayable(invoice.status);
|
||||||
|
const lines = invoice.lines ?? [];
|
||||||
|
|
||||||
|
const handlePay = () => {
|
||||||
|
const returnUrl = `${window.location.origin}/payment/success`;
|
||||||
|
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||||
|
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
onClick={() => navigate("/billing")}
|
||||||
|
style={{ alignSelf: "flex-start" }}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Back to invoices
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
|
<Group gap={12} align="center" wrap="wrap">
|
||||||
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
{invoice.invoiceNumber}
|
||||||
|
</Title>
|
||||||
|
<InvoiceStatusBadge status={invoice.status} />
|
||||||
|
</Group>
|
||||||
|
{payable && (
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
leftSection={<CreditCard size={16} />}
|
||||||
|
loading={payMutation.isPending}
|
||||||
|
onClick={handlePay}
|
||||||
|
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
|
||||||
|
>
|
||||||
|
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{payMutation.isError && (
|
||||||
|
<Alert color="red" icon={<Info size={16} />} title="Payment could not be started">
|
||||||
|
Please try again, or contact support if the problem persists.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg">
|
||||||
|
<MetaItem label="Billed To" value={billedTo(invoice)} />
|
||||||
|
<MetaItem label="Source" value={`${titleCase(invoice.source)} · ${invoice.type}`} />
|
||||||
|
<MetaItem label="Issued" value={fmtDate(invoice.issuedAt)} />
|
||||||
|
<MetaItem label="Due" value={fmtDate(invoice.dueAt)} />
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Divider my="lg" color={BORDER} />
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fz={14} fw={700} c={MUTED} style={{ textTransform: "uppercase", letterSpacing: "0.05em" }}>
|
||||||
|
Total
|
||||||
|
</Text>
|
||||||
|
<Text fz={24} fw={800} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(invoice.totalAmount), invoice.currency)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Box px="lg" py="md" style={{ borderBottom: `1px solid ${BORDER}` }}>
|
||||||
|
<Text fz={15} fw={700} style={{ color: INK }}>
|
||||||
|
Line items
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table
|
||||||
|
verticalSpacing={12}
|
||||||
|
horizontalSpacing={20}
|
||||||
|
styles={{
|
||||||
|
th: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: MUTED,
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
|
td: { borderBottom: `1px solid ${BORDER}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Charge</Table.Th>
|
||||||
|
<Table.Th ta="right">Qty</Table.Th>
|
||||||
|
<Table.Th ta="right">Unit Rate</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={4}>
|
||||||
|
<Center py={28}>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
No line items on this invoice.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
{lines.map((line) => (
|
||||||
|
<Table.Tr key={line.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||||
|
{titleCase(line.chargeType)}
|
||||||
|
</Text>
|
||||||
|
{line.description && (
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{line.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{Number(line.quantity)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(line.unitRate), line.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(line.amount), line.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
537
apps/edr-freight-web/portal/src/pages/billing/InvoicesList.tsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
CreditCard,
|
||||||
|
Eye,
|
||||||
|
FileStack,
|
||||||
|
Inbox,
|
||||||
|
Receipt,
|
||||||
|
Search,
|
||||||
|
Wallet,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
import {
|
||||||
|
BORDER,
|
||||||
|
GREEN,
|
||||||
|
INK,
|
||||||
|
MUTED,
|
||||||
|
StatCard,
|
||||||
|
} from "../contracts/contract-ui";
|
||||||
|
import {
|
||||||
|
billedTo,
|
||||||
|
fmtDate,
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
isPayable,
|
||||||
|
PAYABLE_STATUSES,
|
||||||
|
titleCase,
|
||||||
|
} from "./invoice-ui";
|
||||||
|
|
||||||
|
const PAGE_SIZES = ["10", "25", "50"];
|
||||||
|
|
||||||
|
export default function InvoicesList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [pageIndex, setPageIndex] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery(
|
||||||
|
api.invoices.listMy.queryOptions(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const all = useMemo(() => data ?? [], [data]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const outstanding = all.filter((i) =>
|
||||||
|
PAYABLE_STATUSES.includes(i.status),
|
||||||
|
).length;
|
||||||
|
const overdue = all.filter(
|
||||||
|
(i) => i.status === Freight.InvoiceStatus.Overdue,
|
||||||
|
).length;
|
||||||
|
return { outstanding, overdue, total: all.length };
|
||||||
|
}, [all]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return all.filter((inv) => {
|
||||||
|
if (statusFilter && inv.status !== statusFilter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
inv.invoiceNumber.toLowerCase().includes(q) ||
|
||||||
|
inv.source.toLowerCase().includes(q) ||
|
||||||
|
inv.sourceId.toLowerCase().includes(q) ||
|
||||||
|
billedTo(inv).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [all, query, statusFilter]);
|
||||||
|
|
||||||
|
const total = rows.length;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const clampedIndex = Math.min(pageIndex, pageCount - 1);
|
||||||
|
const start = total === 0 ? 0 : clampedIndex * pageSize + 1;
|
||||||
|
const end = Math.min((clampedIndex + 1) * pageSize, total);
|
||||||
|
const pageRows = rows.slice(clampedIndex * pageSize, clampedIndex * pageSize + pageSize);
|
||||||
|
|
||||||
|
const resetPage = () => setPageIndex(0);
|
||||||
|
const goToPage = (i: number) =>
|
||||||
|
setPageIndex(Math.max(0, Math.min(i, pageCount - 1)));
|
||||||
|
|
||||||
|
const hasFilters = !!query || !!statusFilter;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box style={{ padding: "28px 32px 32px" }}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
{/* Header */}
|
||||||
|
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||||
|
<Title order={1} fw={800} fz={26} style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
Invoices
|
||||||
|
</Title>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Summary strip */}
|
||||||
|
<Group gap="md" wrap="wrap" align="stretch">
|
||||||
|
<StatCard
|
||||||
|
label="Outstanding"
|
||||||
|
hint="awaiting payment"
|
||||||
|
value={stats.outstanding}
|
||||||
|
icon={Wallet}
|
||||||
|
color="edr-accent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Overdue"
|
||||||
|
hint="past due date"
|
||||||
|
value={stats.overdue}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Total invoices"
|
||||||
|
value={stats.total}
|
||||||
|
icon={FileStack}
|
||||||
|
color="violet"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Search + filters */}
|
||||||
|
<Paper withBorder radius="lg" p="sm" style={{ borderColor: BORDER }}>
|
||||||
|
<Group gap={10} wrap="wrap" align="center">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by number, source or reference…"
|
||||||
|
leftSection={<Search size={16} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
styles={{ input: { height: 42 } }}
|
||||||
|
style={{ flex: 1, minWidth: 220, maxWidth: 380 }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="Any status"
|
||||||
|
data={[
|
||||||
|
{ value: Freight.InvoiceStatus.Pending, label: "Due" },
|
||||||
|
{ value: Freight.InvoiceStatus.Overdue, label: "Overdue" },
|
||||||
|
{ value: Freight.InvoiceStatus.Paid, label: "Paid" },
|
||||||
|
{ value: Freight.InvoiceStatus.Draft, label: "Draft" },
|
||||||
|
{ value: Freight.InvoiceStatus.Cancelled, label: "Cancelled" },
|
||||||
|
{ value: Freight.InvoiceStatus.Refunded, label: "Refunded" },
|
||||||
|
]}
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
radius="md"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
styles={{ input: { height: 42 } }}
|
||||||
|
aria-label="Filter by status"
|
||||||
|
/>
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<X size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setStatusFilter(null);
|
||||||
|
resetPage();
|
||||||
|
}}
|
||||||
|
styles={{ root: { fontWeight: 600 } }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
radius="lg"
|
||||||
|
style={{ borderColor: BORDER, overflow: "hidden" }}
|
||||||
|
>
|
||||||
|
<Box style={{ overflowX: "auto" }}>
|
||||||
|
<Table
|
||||||
|
verticalSpacing={14}
|
||||||
|
horizontalSpacing={20}
|
||||||
|
highlightOnHover
|
||||||
|
highlightOnHoverColor="#F4FBF8"
|
||||||
|
styles={{
|
||||||
|
th: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: MUTED,
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
tr: { transition: "background-color 120ms ease" },
|
||||||
|
td: {
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
verticalAlign: "middle",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Invoice</Table.Th>
|
||||||
|
<Table.Th>Billed To</Table.Th>
|
||||||
|
<Table.Th>Source</Table.Th>
|
||||||
|
<Table.Th ta="right">Amount</Table.Th>
|
||||||
|
<Table.Th>Issued</Table.Th>
|
||||||
|
<Table.Th>Due</Table.Th>
|
||||||
|
<Table.Th>Status</Table.Th>
|
||||||
|
<Table.Th ta="right">Action</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{isLoading && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Loader color="edr-green" size="sm" />
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && isError && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Center py={48}>
|
||||||
|
<Text fz={13} c="red">
|
||||||
|
Failed to load invoices. Please try again.
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && pageRows.length === 0 && (
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Td colSpan={8}>
|
||||||
|
<Stack align="center" gap={8} py={48}>
|
||||||
|
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{hasFilters
|
||||||
|
? "No invoices match your filters."
|
||||||
|
: "No invoices yet."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading &&
|
||||||
|
!isError &&
|
||||||
|
pageRows.map((inv) => {
|
||||||
|
const payable = isPayable(inv.status);
|
||||||
|
return (
|
||||||
|
<Table.Tr
|
||||||
|
key={inv.id}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={() => navigate(`/billing/${inv.id}`)}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={10} wrap="nowrap" align="center">
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 9,
|
||||||
|
background: "#E6F7EF",
|
||||||
|
color: GREEN,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Receipt size={16} />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fz={14} fw={700} style={{ color: INK }}>
|
||||||
|
{inv.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed">
|
||||||
|
{titleCase(inv.type)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{billedTo(inv)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{titleCase(inv.source)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td ta="right">
|
||||||
|
<Text fz={13} fw={700} style={{ color: INK }}>
|
||||||
|
{formatCurrency(Number(inv.totalAmount), inv.currency)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text
|
||||||
|
fz={13}
|
||||||
|
c={inv.issuedAt ? undefined : "dimmed"}
|
||||||
|
style={{ color: inv.issuedAt ? INK : undefined }}
|
||||||
|
>
|
||||||
|
{fmtDate(inv.issuedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz={13} style={{ color: INK }}>
|
||||||
|
{fmtDate(inv.dueAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<InvoiceStatusBadge status={inv.status} />
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group justify="flex-end" gap={8} wrap="nowrap">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
h={34}
|
||||||
|
variant={payable ? "filled" : "light"}
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={
|
||||||
|
payable ? (
|
||||||
|
<CreditCard size={15} />
|
||||||
|
) : (
|
||||||
|
<Eye size={15} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/billing/${inv.id}`);
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
paddingInline: 14,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
boxShadow: payable
|
||||||
|
? "0 1px 2px rgba(14,163,113,0.25)"
|
||||||
|
: "none",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{payable ? "Pay" : "View"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Pagination footer */}
|
||||||
|
{!isLoading && !isError && total > 0 && (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
wrap="wrap"
|
||||||
|
gap="md"
|
||||||
|
px={20}
|
||||||
|
py={14}
|
||||||
|
style={{ borderTop: `1px solid ${BORDER}`, background: "#FCFDFE" }}
|
||||||
|
>
|
||||||
|
<Group gap={10} align="center">
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
Rows
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
data={PAGE_SIZES}
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
setPageSize(Number(v));
|
||||||
|
setPageIndex(0);
|
||||||
|
}}
|
||||||
|
radius="md"
|
||||||
|
size="xs"
|
||||||
|
comboboxProps={{ withinPortal: true }}
|
||||||
|
style={{ width: 76 }}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
<Text fz={13} c="dimmed">
|
||||||
|
{start}–{end} of {total}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group gap={6} align="center">
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronLeft size={16} />}
|
||||||
|
disabled={clampedIndex === 0}
|
||||||
|
onClick={() => goToPage(clampedIndex - 1)}
|
||||||
|
ariaLabel="Previous page"
|
||||||
|
/>
|
||||||
|
{pageNumbers(clampedIndex, pageCount).map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<Text key={`gap-${i}`} fz={13} c="dimmed" px={4}>
|
||||||
|
…
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<PageChip
|
||||||
|
key={p}
|
||||||
|
page={p}
|
||||||
|
active={p === clampedIndex}
|
||||||
|
onClick={() => goToPage(p)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<PagerButton
|
||||||
|
icon={<ChevronRight size={16} />}
|
||||||
|
disabled={clampedIndex >= pageCount - 1}
|
||||||
|
onClick={() => goToPage(clampedIndex + 1)}
|
||||||
|
ariaLabel="Next page"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact page-number window with ellipses: 1 … 4 5 6 … 12. */
|
||||||
|
function pageNumbers(active: number, count: number): (number | "…")[] {
|
||||||
|
if (count <= 7) return Array.from({ length: count }, (_, i) => i);
|
||||||
|
const out: (number | "…")[] = [0];
|
||||||
|
const lo = Math.max(1, active - 1);
|
||||||
|
const hi = Math.min(count - 2, active + 1);
|
||||||
|
if (lo > 1) out.push("…");
|
||||||
|
for (let i = lo; i <= hi; i++) out.push(i);
|
||||||
|
if (hi < count - 2) out.push("…");
|
||||||
|
out.push(count - 1);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageChip({
|
||||||
|
page,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
minWidth: 32,
|
||||||
|
height: 32,
|
||||||
|
padding: "0 8px",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${active ? GREEN : BORDER}`,
|
||||||
|
background: active ? GREEN : "#FFFFFF",
|
||||||
|
color: active ? "#FFFFFF" : INK,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? 700 : 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page + 1}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PagerButton({
|
||||||
|
icon,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
borderRadius: 9,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
background: "#FFFFFF",
|
||||||
|
color: disabled ? "#C2CCD6" : INK,
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
opacity: disabled ? 0.6 : 1,
|
||||||
|
transition: "all 120ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Calendar, DollarSign, Hash } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
import type { Currency, InvoiceStatus } from "./invoices.mock";
|
|
||||||
|
|
||||||
export interface InvoiceFormData {
|
|
||||||
number?: string;
|
|
||||||
customerId?: number;
|
|
||||||
bookingReference?: string;
|
|
||||||
amount?: number;
|
|
||||||
currency?: Currency;
|
|
||||||
status?: InvoiceStatus;
|
|
||||||
issueDate?: string;
|
|
||||||
dueDate?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewInvoicePageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
invoice?: InvoiceFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewInvoicePage({
|
|
||||||
mode = "create",
|
|
||||||
invoice,
|
|
||||||
children,
|
|
||||||
}: NewInvoicePageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Invoice" : "New Invoice";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update invoice details."
|
|
||||||
: "Create a new invoice for a customer booking.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* Invoice Number */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Invoice Number *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={invoice?.number ?? ""}
|
|
||||||
placeholder="e.g. INV-2026-0001"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.status ?? "Draft"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Draft</option>
|
|
||||||
<option>Sent</option>
|
|
||||||
<option>Paid</option>
|
|
||||||
<option>Overdue</option>
|
|
||||||
<option>Cancelled</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Customer */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Customer *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.customerId ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select customer
|
|
||||||
</option>
|
|
||||||
{customers.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.company}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Booking */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Booking Reference</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.bookingReference ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="">No linked booking</option>
|
|
||||||
{bookings.map((b) => (
|
|
||||||
<option key={b.id} value={b.reference}>
|
|
||||||
{b.reference} — {b.customer}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Amount *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
step="0.01"
|
|
||||||
defaultValue={invoice?.amount ?? 0}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Currency */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Currency</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={invoice?.currency ?? "USD"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>USD</option>
|
|
||||||
<option>ETB</option>
|
|
||||||
<option>DJF</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Issue Date */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Issue Date *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={invoice?.issueDate ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Due Date *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
defaultValue={invoice?.dueDate ?? ""}
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
defaultValue={invoice?.notes ?? ""}
|
|
||||||
placeholder="Payment terms, references, etc."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
60
apps/edr-freight-web/portal/src/pages/billing/invoice-ui.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import type { PortalInvoice } from "@/services/invoices.service";
|
||||||
|
|
||||||
|
/** Statuses a customer can still pay. */
|
||||||
|
export const PAYABLE_STATUSES: Freight.InvoiceStatus[] = [
|
||||||
|
Freight.InvoiceStatus.Pending,
|
||||||
|
Freight.InvoiceStatus.Overdue,
|
||||||
|
];
|
||||||
|
|
||||||
|
export const isPayable = (status: Freight.InvoiceStatus) =>
|
||||||
|
PAYABLE_STATUSES.includes(status);
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<
|
||||||
|
Freight.InvoiceStatus,
|
||||||
|
{ label: string; bg: string; fg: string }
|
||||||
|
> = {
|
||||||
|
[Freight.InvoiceStatus.Draft]: { label: "Draft", bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
[Freight.InvoiceStatus.Pending]: { label: "Due", bg: "#FEF3E2", fg: "#B45309" },
|
||||||
|
[Freight.InvoiceStatus.Paid]: { label: "Paid", bg: "#E6F7EF", fg: "#0A6F4D" },
|
||||||
|
[Freight.InvoiceStatus.Overdue]: { label: "Overdue", bg: "#FDECEC", fg: "#C0392B" },
|
||||||
|
[Freight.InvoiceStatus.Cancelled]: { label: "Cancelled", bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
[Freight.InvoiceStatus.Refunded]: { label: "Refunded", bg: "#EAF1FB", fg: "#2563EB" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function InvoiceStatusBadge({ status }: { status: Freight.InvoiceStatus }) {
|
||||||
|
const s = STATUS_STYLE[status] ?? { label: status, bg: "#EEF2F6", fg: "#64748B" };
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 999,
|
||||||
|
background: s.bg,
|
||||||
|
color: s.fg,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const titleCase = (v: string) =>
|
||||||
|
v ? v.charAt(0).toUpperCase() + v.slice(1).toLowerCase() : "—";
|
||||||
|
|
||||||
|
/** Best label for who an invoice is billed to (profile ref → profile type → company). */
|
||||||
|
export function billedTo(inv: PortalInvoice): string {
|
||||||
|
const profile = inv.companyProfile;
|
||||||
|
if (profile?.reference) return profile.reference;
|
||||||
|
if (profile?.type) return titleCase(profile.type);
|
||||||
|
return inv.company?.name ?? "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtDate = (v: string | null | undefined) =>
|
||||||
|
v ? new Date(v).toLocaleDateString() : "—";
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
|
|
||||||
export type InvoiceStatus =
|
|
||||||
| "Draft"
|
|
||||||
| "Sent"
|
|
||||||
| "Paid"
|
|
||||||
| "Overdue"
|
|
||||||
| "Cancelled";
|
|
||||||
|
|
||||||
export type Currency = "USD" | "ETB" | "DJF";
|
|
||||||
|
|
||||||
export interface Invoice {
|
|
||||||
id: number;
|
|
||||||
number: string;
|
|
||||||
customerId: number;
|
|
||||||
customer: string;
|
|
||||||
bookingReference: string;
|
|
||||||
amount: number;
|
|
||||||
currency: Currency;
|
|
||||||
status: InvoiceStatus;
|
|
||||||
issueDate: string;
|
|
||||||
dueDate: string;
|
|
||||||
paidDate: string | null;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statuses: InvoiceStatus[] = [
|
|
||||||
"Draft",
|
|
||||||
"Sent",
|
|
||||||
"Paid",
|
|
||||||
"Overdue",
|
|
||||||
"Cancelled",
|
|
||||||
];
|
|
||||||
const currencies: Currency[] = ["USD", "ETB", "DJF"];
|
|
||||||
|
|
||||||
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
|
|
||||||
const customer = customers[i % customers.length] as (typeof customers)[number];
|
|
||||||
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
|
||||||
const id = i + 1;
|
|
||||||
const issue = new Date(2026, 3, 1 + (i % 28));
|
|
||||||
const due = new Date(issue);
|
|
||||||
due.setDate(due.getDate() + 30);
|
|
||||||
const status = statuses[i % statuses.length] as InvoiceStatus;
|
|
||||||
const currency = currencies[i % currencies.length] as Currency;
|
|
||||||
const baseAmount = 5000 + (i * 1234) % 25000;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
number: `INV-2026-${String(id).padStart(4, "0")}`,
|
|
||||||
customerId: customer.id,
|
|
||||||
customer: customer.company,
|
|
||||||
bookingReference: booking.reference,
|
|
||||||
amount: Math.round(baseAmount * 100) / 100,
|
|
||||||
currency,
|
|
||||||
status,
|
|
||||||
issueDate: issue.toISOString().slice(0, 10),
|
|
||||||
dueDate: due.toISOString().slice(0, 10),
|
|
||||||
paidDate:
|
|
||||||
status === "Paid"
|
|
||||||
? new Date(due.getTime() - 86400000 * (i % 7))
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10)
|
|
||||||
: null,
|
|
||||||
notes:
|
|
||||||
i % 3 === 0
|
|
||||||
? "Net 30 payment terms."
|
|
||||||
: i % 3 === 1
|
|
||||||
? "Bank transfer preferred."
|
|
||||||
: "Payment due upon receipt.",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export function getInvoiceById(id: number | string): Invoice | undefined {
|
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
|
||||||
return invoices.find((inv) => inv.id === numericId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCurrency(amount: number, currency: Currency): string {
|
|
||||||
const symbols: Record<Currency, string> = {
|
|
||||||
USD: "$",
|
|
||||||
ETB: "Br",
|
|
||||||
DJF: "DJF",
|
|
||||||
};
|
|
||||||
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
})}`;
|
|
||||||
}
|
|
||||||
@@ -248,4 +248,4 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
|||||||
{viewer}
|
{viewer}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,12 @@ import {
|
|||||||
IntentStatus,
|
IntentStatus,
|
||||||
} from "./payments.service";
|
} from "./payments.service";
|
||||||
import { consignmentsService } from "./consignments.service";
|
import { consignmentsService } from "./consignments.service";
|
||||||
|
import {
|
||||||
|
invoicesService,
|
||||||
|
PortalInvoice,
|
||||||
|
PortalInvoiceDetail,
|
||||||
|
PayInvoicePayload,
|
||||||
|
} from "./invoices.service";
|
||||||
import { trackingService } from "./tracking.service";
|
import { trackingService } from "./tracking.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
@@ -597,4 +603,23 @@ export const api = {
|
|||||||
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
({ optionId }) => dropdownSettingsService.removeOption(optionId),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
invoices: {
|
||||||
|
listMy: endpoint<void, PortalInvoice[]>(
|
||||||
|
"invoices",
|
||||||
|
"listMy",
|
||||||
|
invoicesService.listMy,
|
||||||
|
),
|
||||||
|
|
||||||
|
get: endpoint<{ id: string }, PortalInvoiceDetail>(
|
||||||
|
"invoices",
|
||||||
|
"get",
|
||||||
|
({ id }) => invoicesService.get(id),
|
||||||
|
),
|
||||||
|
|
||||||
|
pay: endpoint<
|
||||||
|
{ id: string; payload?: PayInvoicePayload },
|
||||||
|
InitiateResponse
|
||||||
|
>("invoices", "pay", ({ id, payload }) => invoicesService.pay(id, payload)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ export interface SignContractPayload {
|
|||||||
consentText?: string;
|
consentText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApproveDeliveryResponse {
|
||||||
|
bookingId: string;
|
||||||
|
inventoryId: string;
|
||||||
|
approvedAt: string;
|
||||||
|
signerDisplayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookingListFilter {
|
export interface BookingListFilter {
|
||||||
status?: string;
|
status?: string;
|
||||||
/** Comma-separated statuses (overrides `status` when set). */
|
/** Comma-separated statuses (overrides `status` when set). */
|
||||||
@@ -242,6 +249,13 @@ export const bookingsService = {
|
|||||||
return data.data ?? data;
|
return data.data ?? data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
approveDelivery: async (id: string): Promise<ApproveDeliveryResponse> => {
|
||||||
|
const { data } = await client.post(
|
||||||
|
`/api/warehouse-inventory/bookings/${id}/approve-delivery`,
|
||||||
|
);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
getBookableSchedules: async (
|
getBookableSchedules: async (
|
||||||
query: Freight.BookableSchedulesQuery = {},
|
query: Freight.BookableSchedulesQuery = {},
|
||||||
): Promise<Freight.BookableScheduleItem[]> => {
|
): Promise<Freight.BookableScheduleItem[]> => {
|
||||||
|
|||||||
49
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
49
apps/edr-freight-web/portal/src/services/invoices.service.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
import type { InitiateResponse } from "./payments.service";
|
||||||
|
|
||||||
|
const B = URL_CONSTANTS.BILLING;
|
||||||
|
|
||||||
|
/** A customer-facing invoice row, as returned by `GET /billing/my-invoices`. */
|
||||||
|
export type PortalInvoice = Freight.IInvoice;
|
||||||
|
|
||||||
|
/** An invoice plus its line items, as returned by `GET /billing/my-invoices/:id`. */
|
||||||
|
export type PortalInvoiceDetail = Freight.IInvoice & {
|
||||||
|
lines: Freight.IInvoiceLine[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PayInvoicePayload {
|
||||||
|
method?: string;
|
||||||
|
platform?: "web" | "mobile";
|
||||||
|
payerAccount?: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
failureUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const invoicesService = {
|
||||||
|
/** Every invoice billed to the signed-in customer's company, newest first. */
|
||||||
|
listMy: async (): Promise<PortalInvoice[]> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICES);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** One of the customer's invoices, with its line items. */
|
||||||
|
get: async (id: string): Promise<PortalInvoiceDetail> => {
|
||||||
|
const { data } = await client.get(B.MY_INVOICE_BY_ID(id));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Initiate gateway payment for an open invoice; returns the client action. */
|
||||||
|
pay: async (
|
||||||
|
id: string,
|
||||||
|
payload: PayInvoicePayload = {},
|
||||||
|
): Promise<InitiateResponse> => {
|
||||||
|
const { data } = await client.post(B.PAY_INVOICE(id), {
|
||||||
|
platform: "web",
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
-- Migration: Add Configurable Fare Management System
|
-- Migration: Add Configurable Fare Management System
|
||||||
|
|
||||||
-- Main fare configuration table
|
-- Main fare configuration table
|
||||||
CREATE TABLE "fare_configurations" (
|
CREATE TABLE IF NOT EXISTS "fare_configurations" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"name" TEXT NOT NULL,
|
"name" TEXT NOT NULL,
|
||||||
"description" TEXT,
|
"description" TEXT,
|
||||||
@@ -19,7 +19,7 @@ CREATE TABLE "fare_configurations" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Rate structure by nationality and coach/position
|
-- Rate structure by nationality and coach/position
|
||||||
CREATE TABLE "fare_rate_rules" (
|
CREATE TABLE IF NOT EXISTS "fare_rate_rules" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"fare_config_id" TEXT NOT NULL,
|
"fare_config_id" TEXT NOT NULL,
|
||||||
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
||||||
@@ -34,7 +34,7 @@ CREATE TABLE "fare_rate_rules" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
||||||
CREATE TABLE "fare_components" (
|
CREATE TABLE IF NOT EXISTS "fare_components" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"fare_config_id" TEXT NOT NULL,
|
"fare_config_id" TEXT NOT NULL,
|
||||||
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
||||||
@@ -52,7 +52,7 @@ CREATE TABLE "fare_components" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Age-based pricing rules
|
-- Age-based pricing rules
|
||||||
CREATE TABLE "age_pricing_rules" (
|
CREATE TABLE IF NOT EXISTS "age_pricing_rules" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"fare_config_id" TEXT NOT NULL,
|
"fare_config_id" TEXT NOT NULL,
|
||||||
"rule_name" TEXT NOT NULL,
|
"rule_name" TEXT NOT NULL,
|
||||||
@@ -70,7 +70,7 @@ CREATE TABLE "age_pricing_rules" (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Audit trail for configuration changes
|
-- Audit trail for configuration changes
|
||||||
CREATE TABLE "fare_configuration_audit" (
|
CREATE TABLE IF NOT EXISTS "fare_configuration_audit" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"fare_config_id" TEXT NOT NULL,
|
"fare_config_id" TEXT NOT NULL,
|
||||||
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
||||||
@@ -81,37 +81,47 @@ CREATE TABLE "fare_configuration_audit" (
|
|||||||
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Foreign key constraints
|
-- Foreign key constraints (idempotent)
|
||||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
DO $$ BEGIN
|
||||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_rate_rules_fare_config_id_fkey') THEN
|
||||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_components_fare_config_id_fkey') THEN
|
||||||
|
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'age_pricing_rules_fare_config_id_fkey') THEN
|
||||||
|
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fare_configuration_audit_fare_config_id_fkey') THEN
|
||||||
|
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- Indexes for performance
|
-- Indexes for performance (idempotent)
|
||||||
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
CREATE INDEX IF NOT EXISTS "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||||
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
CREATE INDEX IF NOT EXISTS "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||||
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
CREATE UNIQUE INDEX IF NOT EXISTS "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||||
|
|
||||||
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
CREATE INDEX IF NOT EXISTS "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||||
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
CREATE INDEX IF NOT EXISTS "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||||
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
CREATE INDEX IF NOT EXISTS "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||||
|
|
||||||
-- Add legacy mode flag to existing fare tables for gradual migration
|
-- Add legacy mode flag to existing fare tables for gradual migration (idempotent)
|
||||||
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
ALTER TABLE "passenger"."FareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||||
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
ALTER TABLE "passenger"."SegmentFareRule" ADD COLUMN IF NOT EXISTS "migrated_to_config_id" TEXT;
|
||||||
|
|
||||||
-- Add feature flag support
|
-- Add feature flag support
|
||||||
CREATE TABLE "system_features" (
|
CREATE TABLE IF NOT EXISTS "system_features" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
"feature_name" TEXT NOT NULL UNIQUE,
|
"feature_name" TEXT NOT NULL UNIQUE,
|
||||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
"config" JSONB,
|
"config" JSONB,
|
||||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert the configurable fares feature flag
|
-- Insert the configurable fares feature flag
|
||||||
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config")
|
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config", "updated_at")
|
||||||
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}');
|
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}', CURRENT_TIMESTAMP);
|
||||||
|
|||||||
@@ -483,8 +483,8 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = totalMinor;
|
let displayTotalMinor = totalMinor;
|
||||||
@@ -660,8 +660,8 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
@@ -854,8 +854,8 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, combinedBase - discountMinor - loyaltyMinor);
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||||
@@ -1060,7 +1060,7 @@ export class BookingsService {
|
|||||||
loyaltyRedemptionPoints?: number
|
loyaltyRedemptionPoints?: number
|
||||||
) {
|
) {
|
||||||
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
const segmentRoute = `${originStop.station.code}-${destStop.station.code}`;
|
||||||
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence);
|
const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId);
|
||||||
|
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
const adultFareMinor = baseFareMinor * adultCount;
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
@@ -1076,8 +1076,8 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10;
|
||||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor);
|
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseFareMinor,
|
baseFareMinor,
|
||||||
@@ -1103,6 +1103,8 @@ export class BookingsService {
|
|||||||
nationality?: string,
|
nationality?: string,
|
||||||
originStopSeq?: number,
|
originStopSeq?: number,
|
||||||
destStopSeq?: number,
|
destStopSeq?: number,
|
||||||
|
originStationId?: string,
|
||||||
|
destinationStationId?: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
@@ -1149,13 +1151,13 @@ export class BookingsService {
|
|||||||
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
const bestMatch = this.selectBestFareRule(candidates, scheduleId, segmentRoute, fullRoute, nationality);
|
||||||
if (bestMatch) return bestMatch.baseFareMinor;
|
if (bestMatch) return bestMatch.baseFareMinor;
|
||||||
|
|
||||||
// 3. FareEngine — distance × rate-per-km from the schedule's route
|
// 3. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||||
if (schedule?.routeId) {
|
if (schedule?.routeId) {
|
||||||
try {
|
try {
|
||||||
const fare = await this.fareEngine.calculate({
|
const fare = await this.fareEngine.calculate({
|
||||||
routeId: schedule.routeId,
|
routeId: schedule.routeId,
|
||||||
originStationId: schedule.originStationId,
|
originStationId: originStationId ?? schedule.originStationId,
|
||||||
destinationStationId: schedule.destinationStationId,
|
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||||
seatClassId,
|
seatClassId,
|
||||||
nationality,
|
nationality,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto';
|
||||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Booking cutoff: reject new bookings within this many ms of departure. */
|
||||||
|
const BOOKING_CUTOFF_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
function generateRef(): string {
|
function generateRef(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
return 'EDR-' + Array.from({ length: 6 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
|
||||||
@@ -74,6 +77,10 @@ export class GuestBookingService {
|
|||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||||
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
|
}
|
||||||
|
|
||||||
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found');
|
||||||
@@ -142,7 +149,9 @@ export class GuestBookingService {
|
|||||||
dto.seatClassId,
|
dto.seatClassId,
|
||||||
segmentRoute,
|
segmentRoute,
|
||||||
fullRoute,
|
fullRoute,
|
||||||
primaryNationality
|
primaryNationality,
|
||||||
|
dto.originStationId,
|
||||||
|
dto.destinationStationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
const adultFareMinor = baseFareMinor * adultCount;
|
const adultFareMinor = baseFareMinor * adultCount;
|
||||||
@@ -160,8 +169,8 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const taxesMinor = Math.round(totalBaseFareMinor * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor + taxesMinor);
|
const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
let displayTotalMinor = totalMinor;
|
let displayTotalMinor = totalMinor;
|
||||||
@@ -293,6 +302,10 @@ export class GuestBookingService {
|
|||||||
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found');
|
||||||
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
if (!returnSchedule) throw new NotFoundException('Return schedule not found');
|
||||||
|
|
||||||
|
if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||||
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
|
}
|
||||||
|
|
||||||
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId);
|
||||||
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId);
|
||||||
@@ -350,8 +363,8 @@ export class GuestBookingService {
|
|||||||
const primaryNationality = passengersData[0]?.nationality;
|
const primaryNationality = passengersData[0]?.nationality;
|
||||||
|
|
||||||
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
const [outboundBaseFare, returnBaseFare] = await Promise.all([
|
||||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality),
|
this.getBaseFare(dto.scheduleId, dto.seatClassId, outboundSegmentRoute, outboundFullRoute, primaryNationality, dto.originStationId, dto.destinationStationId),
|
||||||
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality),
|
this.getBaseFare(dto.returnScheduleId, returnSeatClassId, returnSegmentRoute, returnFullRoute, primaryNationality, dto.returnOriginStationId, dto.returnDestinationStationId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const paidChildrenCount = Math.max(0, childCount - 1);
|
const paidChildrenCount = Math.max(0, childCount - 1);
|
||||||
@@ -369,8 +382,8 @@ export class GuestBookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const taxesMinor = Math.round(combinedBaseFareMinor * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor + taxesMinor);
|
const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
@@ -505,6 +518,10 @@ export class GuestBookingService {
|
|||||||
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found');
|
||||||
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found');
|
||||||
|
|
||||||
|
if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||||
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
|
}
|
||||||
|
|
||||||
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
@@ -551,11 +568,11 @@ export class GuestBookingService {
|
|||||||
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
this.getBaseFare(dto.scheduleId, dto.seatClassId,
|
||||||
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
`${leg1OriginStop.station.code}-${leg1DestStop.station.code}`,
|
||||||
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
`${leg1Schedule.originStation.code}-${leg1Schedule.destinationStation.code}`,
|
||||||
primaryNationality),
|
primaryNationality, dto.originStationId, dto.transitStationId),
|
||||||
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
this.getBaseFare(dto.leg2ScheduleId, leg2SeatClassId,
|
||||||
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
`${leg2OriginStop.station.code}-${leg2DestStop.station.code}`,
|
||||||
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
`${leg2Schedule.originStation.code}-${leg2Schedule.destinationStation.code}`,
|
||||||
primaryNationality),
|
primaryNationality, dto.transitStationId, dto.leg2DestinationStationId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
const leg1Total = leg1BaseFare * adultCount + leg1BaseFare * paidChildrenCount;
|
||||||
@@ -569,8 +586,8 @@ export class GuestBookingService {
|
|||||||
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
discountMinor = promo.percentOff ? Math.round(combinedBase * promo.percentOff / 100) : (promo.amountOffMinor ?? 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const taxesMinor = Math.round(combinedBase * 0.05);
|
const taxesMinor = 0;
|
||||||
const totalMinor = Math.max(0, combinedBase - discountMinor + taxesMinor);
|
const totalMinor = Math.max(0, combinedBase - discountMinor);
|
||||||
|
|
||||||
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
const displayCurrency = dto.displayCurrency || Currency.ETB;
|
||||||
const displayTotalMinor = displayCurrency !== Currency.ETB
|
const displayTotalMinor = displayCurrency !== Currency.ETB
|
||||||
@@ -702,6 +719,10 @@ export class GuestBookingService {
|
|||||||
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found');
|
||||||
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found');
|
||||||
|
|
||||||
|
if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) {
|
||||||
|
throw new BadRequestException('Bookings are not accepted within 30 minutes of departure');
|
||||||
|
}
|
||||||
|
|
||||||
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId);
|
||||||
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId);
|
||||||
@@ -750,10 +771,10 @@ export class GuestBookingService {
|
|||||||
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
const retL2ClassId = dto.returnLeg2SeatClassId ?? dto.seatClassId;
|
||||||
|
|
||||||
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
const [obL1Fare, obL2Fare, retL1Fare, retL2Fare] = await Promise.all([
|
||||||
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat),
|
this.getBaseFare(dto.scheduleId, dto.seatClassId, `${obL1Origin.station.code}-${obL1Dest.station.code}`, `${obL1Sched.originStation.code}-${obL1Sched.destinationStation.code}`, nat, dto.originStationId, dto.transitStationId),
|
||||||
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat),
|
this.getBaseFare(dto.leg2ScheduleId!, obL2ClassId, `${obL2Origin.station.code}-${obL2Dest.station.code}`, `${obL2Sched.originStation.code}-${obL2Sched.destinationStation.code}`, nat, dto.transitStationId, dto.leg2DestinationStationId),
|
||||||
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat),
|
this.getBaseFare(dto.returnScheduleId!, retL1ClassId, `${retL1Origin.station.code}-${retL1Dest.station.code}`, `${retL1Sched.originStation.code}-${retL1Sched.destinationStation.code}`, nat, dto.returnOriginStationId, dto.returnTransitStationId),
|
||||||
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat),
|
this.getBaseFare(dto.returnLeg2ScheduleId!,retL2ClassId, `${retL2Origin.station.code}-${retL2Dest.station.code}`, `${retL2Sched.originStation.code}-${retL2Sched.destinationStation.code}`, nat, dto.returnTransitStationId, dto.returnLeg2DestinationStationId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
const combinedBase = (obL1Fare + obL2Fare + retL1Fare + retL2Fare) * adultCount +
|
||||||
@@ -951,17 +972,28 @@ export class GuestBookingService {
|
|||||||
segmentRoute?: string,
|
segmentRoute?: string,
|
||||||
fullRoute?: string,
|
fullRoute?: string,
|
||||||
nationality?: string,
|
nationality?: string,
|
||||||
|
originStationId?: string,
|
||||||
|
destinationStationId?: string,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
// 1. FareRule table — explicit override rules
|
// 1. FareRule table — explicit override rules (same priority logic as the fare engine)
|
||||||
const candidates = await this.prisma.fareRule.findMany({
|
const [candidates, seatClass] = await Promise.all([
|
||||||
where: {
|
this.prisma.fareRule.findMany({
|
||||||
seatClassId,
|
where: {
|
||||||
validFrom: { lte: now },
|
seatClassId,
|
||||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
validFrom: { lte: now },
|
||||||
},
|
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.seatClass.findUnique({
|
||||||
|
where: { id: seatClassId },
|
||||||
|
select: { premiumMinor: true, insuranceFeeMinor: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const premiumMinor = seatClass?.premiumMinor ?? 0;
|
||||||
|
const insuranceMinor = seatClass?.insuranceFeeMinor ?? 0;
|
||||||
|
|
||||||
const priorities = [
|
const priorities = [
|
||||||
{ tripId: scheduleId, route: segmentRoute, nationality },
|
{ tripId: scheduleId, route: segmentRoute, nationality },
|
||||||
@@ -982,10 +1014,11 @@ export class GuestBookingService {
|
|||||||
const match = candidates.find(
|
const match = candidates.find(
|
||||||
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
(c) => c.tripId === priority.tripId && c.route === priority.route && c.nationality === priority.nationality,
|
||||||
);
|
);
|
||||||
if (match) return match.baseFareMinor;
|
// Return base fare + seat-class surcharges so the booking total matches the quoted fare
|
||||||
|
if (match) return match.baseFareMinor + premiumMinor + insuranceMinor;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. FareEngine — distance × rate-per-km from the schedule's route
|
// 2. FareEngine — distance × rate-per-km from the booking's actual segment stations
|
||||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
where: { id: scheduleId },
|
where: { id: scheduleId },
|
||||||
select: { routeId: true, originStationId: true, destinationStationId: true },
|
select: { routeId: true, originStationId: true, destinationStationId: true },
|
||||||
@@ -995,12 +1028,15 @@ export class GuestBookingService {
|
|||||||
try {
|
try {
|
||||||
const fare = await this.fareEngine.calculate({
|
const fare = await this.fareEngine.calculate({
|
||||||
routeId: schedule.routeId,
|
routeId: schedule.routeId,
|
||||||
originStationId: schedule.originStationId,
|
// Use the booking's boarding/alighting stations so the distance reflects the
|
||||||
destinationStationId: schedule.destinationStationId,
|
// passenger's actual segment, not the full schedule route.
|
||||||
|
originStationId: originStationId ?? schedule.originStationId,
|
||||||
|
destinationStationId: destinationStationId ?? schedule.destinationStationId,
|
||||||
seatClassId,
|
seatClassId,
|
||||||
nationality,
|
nationality,
|
||||||
});
|
});
|
||||||
return fare.baseFarePerPassengerMinor;
|
// farePerPassengerMinor already includes base + premiumMinor + insuranceFeeMinor
|
||||||
|
return fare.farePerPassengerMinor;
|
||||||
} catch {
|
} catch {
|
||||||
// FareEngine throws if distanceKm is missing; fall through to error
|
// FareEngine throws if distanceKm is missing; fall through to error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,69 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minor-unit decimal places per currency, used to round the CHARGE amount sent to the payment
|
||||||
|
* microservice. DJF has no minor unit (whole francs only); ETB and USD use 2 decimals.
|
||||||
|
*/
|
||||||
|
const CHARGE_CURRENCY_DECIMALS: Record<string, number> = {
|
||||||
|
ETB: 2,
|
||||||
|
USD: 2,
|
||||||
|
DJF: 0,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrencyService {
|
export class CurrencyService {
|
||||||
private readonly logger = new Logger(CurrencyService.name);
|
private readonly logger = new Logger(CurrencyService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async convertEtbMinorToChargeMajor(
|
||||||
|
amountMinorEtb: number,
|
||||||
|
targetCurrency: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const target = targetCurrency.toUpperCase();
|
||||||
|
const decimals = CHARGE_CURRENCY_DECIMALS[target];
|
||||||
|
if (decimals === undefined) {
|
||||||
|
throw new BadRequestException(`Unsupported charge currency: ${targetCurrency}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceMajor = amountMinorEtb / 100;
|
||||||
|
if (target === Currency.ETB) {
|
||||||
|
return this.roundTo(sourceMajor, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rate = await this.getRateOrThrow(Currency.ETB, target as Currency);
|
||||||
|
return this.roundTo(sourceMajor * rate, decimals);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRateOrThrow(
|
||||||
|
fromCurrency: Currency,
|
||||||
|
toCurrency: Currency,
|
||||||
|
): Promise<number> {
|
||||||
|
if (fromCurrency === toCurrency) return 1;
|
||||||
|
const exchangeRate = await this.prisma.currencyExchangeRate.findFirst({
|
||||||
|
where: { fromCurrency, toCurrency },
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
if (!exchangeRate) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`No exchange rate configured for ${fromCurrency}->${toCurrency}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Number(exchangeRate.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private roundTo(value: number, decimals: number): number {
|
||||||
|
const factor = 10 ** decimals;
|
||||||
|
return Math.round(value * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
async convertAmount(
|
async convertAmount(
|
||||||
amountMinor: number,
|
amountMinor: number,
|
||||||
fromCurrency: Currency,
|
fromCurrency: Currency,
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { Type } from 'class-transformer';
|
|||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
|
// Nationality → home currency mapping (keys are uppercase for case-insensitive lookup)
|
||||||
export const NATIONALITY_CURRENCY_MAP: Record<string, Currency> = {
|
export const NATIONALITY_CURRENCY_MAP: Record<string, string> = {
|
||||||
ETHIOPIAN: Currency.ETB,
|
ETHIOPIAN: 'ETB',
|
||||||
DJIBOUTIAN: Currency.DJF,
|
DJIBOUTIAN: 'DJF',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
export function resolveCurrencyFromNationality(nationality?: string): Currency {
|
||||||
if (!nationality) return Currency.ETB;
|
if (!nationality) return 'ETB' as Currency;
|
||||||
return NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? Currency.USD;
|
return (NATIONALITY_CURRENCY_MAP[nationality.toUpperCase()] ?? 'USD') as Currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FareCalculateDto {
|
export class FareCalculateDto {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user