mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge freight/develop into feature/trains-management
This commit is contained in:
@@ -4,7 +4,10 @@
|
|||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true,
|
||||||
"assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
|
"assets": [
|
||||||
|
{ "include": "migrations/**/*", "outDir": "dist" },
|
||||||
|
{ "include": "contracts/templates/**/*", "watchAssets": true }
|
||||||
|
],
|
||||||
"watchAssets": true
|
"watchAssets": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
@@ -27,11 +28,13 @@
|
|||||||
"@tria-plc/iamapi-common": "^0.1.6",
|
"@tria-plc/iamapi-common": "^0.1.6",
|
||||||
"amqp-connection-manager": "^5.0.0",
|
"amqp-connection-manager": "^5.0.0",
|
||||||
"amqplib": "^2.0.1",
|
"amqplib": "^2.0.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.16.1",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"handlebars": "^4.7.9",
|
||||||
"minio": "7.1.3",
|
"minio": "7.1.3",
|
||||||
|
"puppeteer": "^24.2.0",
|
||||||
"pg": "^8.13.0",
|
"pg": "^8.13.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { FilesModule } from "./modules/files/files.module";
|
|||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
|
|
||||||
import { CustomersModule } from "./modules/customers/customers.module";
|
import { CustomersModule } from "./modules/customers/customers.module";
|
||||||
|
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||||
import { BillingModule } from "./modules/billing/billing.module";
|
import { BillingModule } from "./modules/billing/billing.module";
|
||||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||||
@@ -29,13 +30,13 @@ import {
|
|||||||
} from "./seed/edr-freight.seed";
|
} from "./seed/edr-freight.seed";
|
||||||
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
import { EdrOrgSeeder } from "./seed/edr-org.seeder";
|
||||||
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
import { DemoUsersSeeder } from "./seed/demo-users.seeder";
|
||||||
//New Trains, Wangons, Container and Cargo management modules
|
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||||
|
//New Trains, Wagons, Container and Cargo management modules
|
||||||
import { TrainsModule } from "./modules/trains/trains.module";
|
import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
@@ -65,6 +66,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
ConsignmentsModule,
|
ConsignmentsModule,
|
||||||
TrainsModule,
|
TrainsModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
|
CompaniesModule,
|
||||||
TrackingModule,
|
TrackingModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
@@ -80,18 +82,20 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
export class AppModule implements OnApplicationBootstrap {
|
export class AppModule implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly seeder: DataSeeder,
|
private readonly seeder: DataSeeder,
|
||||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||||
|
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
async onApplicationBootstrap() {
|
||||||
await this.seeder.run();
|
await this.seeder.run();
|
||||||
await this.edrOrgSeeder.run();
|
await this.edrOrgSeeder.run();
|
||||||
await this.demoUsersSeeder.run();
|
await this.demoUsersSeeder.run();
|
||||||
|
await this.fileUploadSettingsSeeder.run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
12
apps/edr-freight-api/src/common/resolve-auth-user-id.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type AuthUserPayload = { id?: string; sub?: string } | null | undefined;
|
||||||
|
|
||||||
|
/** Resolve IAM user id from JWT payload attached by JwtGuard. */
|
||||||
|
export function resolveAuthUserId(user: AuthUserPayload): string {
|
||||||
|
const id = user?.id ?? user?.sub;
|
||||||
|
if (!id) {
|
||||||
|
throw new UnauthorizedException('Authentication required');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
224
apps/edr-freight-api/src/contracts/contract-clause-packs.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import type {
|
||||||
|
Article1Clause,
|
||||||
|
ContractClausePack,
|
||||||
|
ContractDirection,
|
||||||
|
ContractFreight,
|
||||||
|
ContractServiceScope,
|
||||||
|
} from './contract-template.types';
|
||||||
|
|
||||||
|
const STANDARD_CONTRACT_DOCUMENTS = [
|
||||||
|
'Amendments (if any)',
|
||||||
|
'This Contract Agreement',
|
||||||
|
'Final Minutes of Negotiation (if any)',
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAYMENT_OBLIGATION =
|
||||||
|
'Pay 100% transportation fees in advance per train set in accordance with Article 5.';
|
||||||
|
|
||||||
|
const HAZARDOUS_OBLIGATION =
|
||||||
|
'Notify EDR 48 hours in advance for hazardous or valuable cargo.';
|
||||||
|
|
||||||
|
function clonePack(pack: ContractClausePack): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective: pack.article1.objective,
|
||||||
|
scope: [...pack.article1.scope],
|
||||||
|
},
|
||||||
|
clientObligations: [...pack.clientObligations],
|
||||||
|
providerObligations: [...pack.providerObligations],
|
||||||
|
contractDocuments: [...pack.contractDocuments],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyForwardingOverlay(
|
||||||
|
pack: ContractClausePack,
|
||||||
|
service: ContractServiceScope,
|
||||||
|
): ContractClausePack {
|
||||||
|
if (service !== 'FORWARDING') return pack;
|
||||||
|
|
||||||
|
const next = clonePack(pack);
|
||||||
|
next.article1.scope.push(
|
||||||
|
'First-mile and/or last-mile coordination, documentation, and handover with road or port partners where included in the agreed service scope.',
|
||||||
|
);
|
||||||
|
next.providerObligations.push(
|
||||||
|
'Coordinate first-mile and last-mile logistics with designated partners and keep the Client informed of handover milestones.',
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportContainerPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for 40ft and/or 20ft full containers from SGTD to Dire Dawa, Modjo dry port and/or Galaan Multipurpose port (GMP), and empty container return from those terminals to SGTD.',
|
||||||
|
scope: [
|
||||||
|
'Railway transport service on the agreed import corridor.',
|
||||||
|
'Cargo handling at Galaan Multipurpose port (GMP) where applicable.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide shipment instructions to EDR for container movements on the agreed corridor.',
|
||||||
|
'Meet minimum container supply per terminal (Modjo, Dire Dawa, GMP) as per EDR operational rules.',
|
||||||
|
'Submit required documents to Djibouti Nagad station at least 24 hours before loading.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||||
|
'Provide safe transportation and deliver within agreed timelines when documents are complete.',
|
||||||
|
'Return empty containers from Dire Dawa, Modjo and GMP to SGTD within seven (7) calendar days of receipt.',
|
||||||
|
'Maintain cargo liability insurance per wagon.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportBulkPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for bulk cargo from SGTD railway freight station at Djibouti to designated Ethiopian rail terminals on the import corridor.',
|
||||||
|
scope: [
|
||||||
|
'Railway bulk transport service on the agreed import corridor.',
|
||||||
|
'Loading and unloading coordination at designated terminals per EDR operational rules.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide accurate commodity description, weight, and shipment instructions for each train movement.',
|
||||||
|
'Ensure cargo is prepared and available at origin per the agreed loading window.',
|
||||||
|
'Submit required customs and operational documents at least 24 hours before loading where applicable.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||||
|
'Provide safe bulk transportation and deliver within agreed timelines when documents are complete.',
|
||||||
|
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportContainerPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for 40ft and/or 20ft full containers from designated Ethiopian dry ports and terminals to SGTD and related export corridors.',
|
||||||
|
scope: [
|
||||||
|
'Railway export transport service on the agreed corridor.',
|
||||||
|
'Terminal coordination at origin yards for export dispatch where applicable.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide export shipment instructions and container release details for each movement.',
|
||||||
|
'Ensure containers are available at origin terminals per EDR operational windows.',
|
||||||
|
'Submit required export, customs, and operational documents at origin at least 24 hours before loading.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign voyage per operational schedule and notify train schedule 48 hours in advance.',
|
||||||
|
'Provide safe transportation to SGTD and hand over for export processing when documents are complete.',
|
||||||
|
'Maintain cargo liability insurance per wagon.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportBulkPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for bulk export cargo from designated Ethiopian rail terminals to SGTD and related export corridors.',
|
||||||
|
scope: [
|
||||||
|
'Railway bulk export transport on the agreed corridor.',
|
||||||
|
'Loading coordination at origin terminals per EDR operational rules.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide accurate commodity description, weight, and export shipment instructions.',
|
||||||
|
'Ensure bulk cargo is prepared and available at origin per the agreed loading window.',
|
||||||
|
'Submit required export and customs documents at least 24 hours before loading where applicable.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||||
|
'Provide safe bulk transportation to SGTD within agreed timelines when documents are complete.',
|
||||||
|
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDomesticContainerPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for 40ft and/or 20ft containers between designated Ethiopian rail terminals on the domestic corridor.',
|
||||||
|
scope: ['Domestic railway container transport between agreed origin and destination yards.'],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide shipment instructions for each domestic container movement.',
|
||||||
|
'Ensure containers are available at origin per EDR operational rules.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign voyage per operational schedule and notify train schedule 48 hours in advance where practicable.',
|
||||||
|
'Provide safe transportation and deliver within agreed timelines when instructions are complete.',
|
||||||
|
'Maintain cargo liability insurance per wagon.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDomesticBulkPack(): ContractClausePack {
|
||||||
|
return {
|
||||||
|
article1: {
|
||||||
|
objective:
|
||||||
|
'To provide railway transportation for bulk cargo between designated Ethiopian rail terminals on the domestic corridor.',
|
||||||
|
scope: ['Domestic railway bulk transport between agreed origin and destination terminals.'],
|
||||||
|
},
|
||||||
|
clientObligations: [
|
||||||
|
'Provide commodity description, weight, and shipment instructions for each movement.',
|
||||||
|
'Ensure cargo is prepared at origin per the agreed loading window.',
|
||||||
|
PAYMENT_OBLIGATION,
|
||||||
|
HAZARDOUS_OBLIGATION,
|
||||||
|
],
|
||||||
|
providerObligations: [
|
||||||
|
'Assign train capacity per operational schedule and notify departure timing 48 hours in advance where practicable.',
|
||||||
|
'Provide safe bulk transportation within agreed timelines.',
|
||||||
|
'Maintain cargo liability insurance per wagon or train consist as applicable.',
|
||||||
|
],
|
||||||
|
contractDocuments: [...STANDARD_CONTRACT_DOCUMENTS],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_PACKS: Record<ContractDirection, Record<ContractFreight, () => ContractClausePack>> = {
|
||||||
|
IMP: {
|
||||||
|
CON: buildImportContainerPack,
|
||||||
|
BULK: buildImportBulkPack,
|
||||||
|
},
|
||||||
|
EXP: {
|
||||||
|
CON: buildExportContainerPack,
|
||||||
|
BULK: buildExportBulkPack,
|
||||||
|
},
|
||||||
|
DOM: {
|
||||||
|
CON: buildDomesticContainerPack,
|
||||||
|
BULK: buildDomesticBulkPack,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildClausePack(
|
||||||
|
direction: ContractDirection,
|
||||||
|
freight: ContractFreight,
|
||||||
|
service: ContractServiceScope,
|
||||||
|
): ContractClausePack {
|
||||||
|
const base = BASE_PACKS[direction][freight]();
|
||||||
|
return applyForwardingOverlay(base, service);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function article1ObjectiveFromClause(article1: Article1Clause): string {
|
||||||
|
return article1.objective;
|
||||||
|
}
|
||||||
57
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
57
apps/edr-freight-api/src/contracts/contract-pdf.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractPdfService {
|
||||||
|
private readonly logger = new Logger(ContractPdfService.name);
|
||||||
|
|
||||||
|
async htmlToPdfBuffer(html: string): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const puppeteer = await import('puppeteer');
|
||||||
|
const browser = await puppeteer.default.launch({
|
||||||
|
headless: true,
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent(html, { waitUntil: 'load' });
|
||||||
|
const pdf = await page.pdf({
|
||||||
|
format: 'A4',
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
|
||||||
|
});
|
||||||
|
return Buffer.from(pdf);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Puppeteer PDF failed, falling back to minimal PDF stub: ${err}`,
|
||||||
|
);
|
||||||
|
return this.fallbackPdfBuffer(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal valid PDF when Chromium is unavailable. */
|
||||||
|
private fallbackPdfBuffer(html: string): Buffer {
|
||||||
|
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').slice(0, 2000);
|
||||||
|
const escaped = text.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||||
|
const stream = `BT /F1 10 Tf 50 750 Td (${escaped}) Tj ET`;
|
||||||
|
const len = stream.length;
|
||||||
|
const pdf = `%PDF-1.4
|
||||||
|
1 0 obj<< /Type /Catalog /Pages 2 0 R >>endobj
|
||||||
|
2 0 obj<< /Type /Pages /Kids [3 0 R] /Count 1 >>endobj
|
||||||
|
3 0 obj<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>endobj
|
||||||
|
4 0 obj<< /Length ${len} >>stream
|
||||||
|
${stream}
|
||||||
|
endstream endobj
|
||||||
|
5 0 obj<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>endobj
|
||||||
|
xref
|
||||||
|
0 6
|
||||||
|
0000000000 65535 f
|
||||||
|
trailer<< /Size 6 /Root 1 0 R >>
|
||||||
|
startxref
|
||||||
|
0
|
||||||
|
%%EOF`;
|
||||||
|
return Buffer.from(pdf, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BookingPricingService } from '../modules/bookings/booking-pricing.service';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { PriceLineItemDto } from '../modules/bookings/dto/generate-price-response.dto';
|
||||||
|
|
||||||
|
export interface PricingScheduleRow {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingSchedule {
|
||||||
|
lineItems: PricingScheduleRow[];
|
||||||
|
surcharges: PricingScheduleRow[];
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
equipmentReturn?: string;
|
||||||
|
originLabel: string;
|
||||||
|
destinationLabel: string;
|
||||||
|
containerLines: Array<{
|
||||||
|
label: string;
|
||||||
|
quantity: number;
|
||||||
|
vgmPerUnitTons: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractPricingScheduleBuilder {
|
||||||
|
constructor(private readonly pricingService: BookingPricingService) {}
|
||||||
|
|
||||||
|
async build(booking: Booking): Promise<PricingSchedule> {
|
||||||
|
const { lineItems, totalAmount, currency } =
|
||||||
|
await this.pricingService.computeContractLineItems(booking);
|
||||||
|
|
||||||
|
const isSurcharge = (l: PriceLineItemDto) =>
|
||||||
|
l.code.includes('SURCHARGE') || l.description.toLowerCase().includes('surcharge');
|
||||||
|
|
||||||
|
const baseLines = lineItems.filter((l) => !isSurcharge(l));
|
||||||
|
const surchargeLines = lineItems.filter(isSurcharge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineItems: baseLines.map((l) => ({
|
||||||
|
label: l.code,
|
||||||
|
description: l.description,
|
||||||
|
amount: l.amount,
|
||||||
|
currency: l.currency,
|
||||||
|
})),
|
||||||
|
surcharges: surchargeLines.map((l) => ({
|
||||||
|
label: l.code,
|
||||||
|
description: l.description,
|
||||||
|
amount: l.amount,
|
||||||
|
currency: l.currency,
|
||||||
|
})),
|
||||||
|
totalAmount,
|
||||||
|
currency,
|
||||||
|
equipmentReturn: booking.equipmentReturn ?? undefined,
|
||||||
|
originLabel: booking.originYard?.label ?? booking.originYard?.code ?? '—',
|
||||||
|
destinationLabel:
|
||||||
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? '—',
|
||||||
|
containerLines: (booking.bookingContainers ?? []).map((c) => ({
|
||||||
|
label:
|
||||||
|
c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId,
|
||||||
|
quantity: c.quantity,
|
||||||
|
vgmPerUnitTons: Number(c.vgmPerUnitTons),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ContractRendererService } from './contract-renderer.service';
|
||||||
|
import { getTemplateMeta } from './contract-template.registry';
|
||||||
|
import type { ContractViewModel } from './contract-view-model.builder';
|
||||||
|
|
||||||
|
describe('ContractRendererService', () => {
|
||||||
|
const renderer = new ContractRendererService();
|
||||||
|
renderer.onModuleInit();
|
||||||
|
|
||||||
|
function minimalView(templateKey: string): ContractViewModel {
|
||||||
|
const template = getTemplateMeta(templateKey);
|
||||||
|
return {
|
||||||
|
bookingId: 'test-id',
|
||||||
|
reference: 'BK-TEST-001',
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
templateKey,
|
||||||
|
template,
|
||||||
|
contractDate: '1 January 2026',
|
||||||
|
contractYear: 2026,
|
||||||
|
client: {
|
||||||
|
companyName: 'Test Co',
|
||||||
|
companyAddress: 'Addis Ababa',
|
||||||
|
companyLocation: 'Ethiopia',
|
||||||
|
phone: '+251900000000',
|
||||||
|
email: 'test@example.com',
|
||||||
|
tinNumber: '1234567890',
|
||||||
|
},
|
||||||
|
pricing: {
|
||||||
|
lineItems: [{ label: 'RAIL', description: 'Rail transport', amount: 1000, currency: 'ETB' }],
|
||||||
|
surcharges: [],
|
||||||
|
totalAmount: 1000,
|
||||||
|
currency: 'ETB',
|
||||||
|
originLabel: 'SGTD',
|
||||||
|
destinationLabel: 'Modjo',
|
||||||
|
containerLines: [{ label: '40ft', quantity: 2, vgmPerUnitTons: 12 }],
|
||||||
|
},
|
||||||
|
signatures: [],
|
||||||
|
canSignCustomer: true,
|
||||||
|
canSignStaff: false,
|
||||||
|
hasContractDocument: false,
|
||||||
|
hasCustomerSignature: false,
|
||||||
|
hasStaffSignature: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders import flagship with Nagad and Article 5', () => {
|
||||||
|
const html = renderer.render(minimalView('IMP_CON_ETB_TRANSPORT_ONLY'));
|
||||||
|
expect(html).toContain('Djibouti Nagad');
|
||||||
|
expect(html).toContain('Article 5: Contract Price');
|
||||||
|
expect(html).toContain('Article 2: Obligations of the Client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders export variant without import empty-return clause', () => {
|
||||||
|
const html = renderer.render(minimalView('EXP_CON_USD_TRANSPORT_ONLY'));
|
||||||
|
expect(html).toContain('export corridors');
|
||||||
|
expect(html).not.toContain('Return empty containers from Dire Dawa');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import Handlebars from 'handlebars';
|
||||||
|
|
||||||
|
import { ContractViewModel } from './contract-view-model.builder';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractRendererService implements OnModuleInit {
|
||||||
|
private readonly templatesDir = path.join(__dirname, 'templates');
|
||||||
|
private readonly compiled = new Map<string, Handlebars.TemplateDelegate>();
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b);
|
||||||
|
|
||||||
|
const partialsDir = path.join(this.templatesDir, '_partials');
|
||||||
|
if (fs.existsSync(partialsDir)) {
|
||||||
|
for (const file of fs.readdirSync(partialsDir)) {
|
||||||
|
if (!file.endsWith('.hbs')) continue;
|
||||||
|
const name = file.replace(/\.hbs$/, '');
|
||||||
|
const content = fs.readFileSync(path.join(partialsDir, file), 'utf-8');
|
||||||
|
Handlebars.registerPartial(name, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(view: ContractViewModel): string {
|
||||||
|
const fileName =
|
||||||
|
view.template.templateFile ?? 'generic.hbs';
|
||||||
|
const template = this.getCompiled(fileName);
|
||||||
|
return template({
|
||||||
|
...view,
|
||||||
|
paymentArticle: view.pricing.currency === 'ETB' ? 'ETB' : 'USD',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCompiled(fileName: string): Handlebars.TemplateDelegate {
|
||||||
|
const cached = this.compiled.get(fileName);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const filePath = path.join(this.templatesDir, fileName);
|
||||||
|
const fallbackPath = path.join(this.templatesDir, 'generic.hbs');
|
||||||
|
const source = fs.existsSync(filePath)
|
||||||
|
? fs.readFileSync(filePath, 'utf-8')
|
||||||
|
: fs.readFileSync(fallbackPath, 'utf-8');
|
||||||
|
|
||||||
|
const compiled = Handlebars.compile(source);
|
||||||
|
this.compiled.set(fileName, compiled);
|
||||||
|
return compiled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import {
|
||||||
|
CONTRACT_TEMPLATE_KEYS,
|
||||||
|
CONTRACT_TEMPLATE_REGISTRY,
|
||||||
|
getTemplateMeta,
|
||||||
|
isValidTemplateKey,
|
||||||
|
} from './contract-template.registry';
|
||||||
|
|
||||||
|
describe('ContractTemplateRegistry', () => {
|
||||||
|
it('defines exactly 24 template keys', () => {
|
||||||
|
expect(CONTRACT_TEMPLATE_KEYS).toHaveLength(24);
|
||||||
|
expect(Object.keys(CONTRACT_TEMPLATE_REGISTRY)).toHaveLength(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys match the direction_freight_currency_service pattern', () => {
|
||||||
|
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||||
|
expect(isValidTemplateKey(key)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each meta has non-empty obligations and article1 scope', () => {
|
||||||
|
for (const key of CONTRACT_TEMPLATE_KEYS) {
|
||||||
|
const meta = CONTRACT_TEMPLATE_REGISTRY[key]!;
|
||||||
|
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||||
|
expect(meta.providerObligations.length).toBeGreaterThan(0);
|
||||||
|
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||||
|
expect(meta.article1.objective.length).toBeGreaterThan(0);
|
||||||
|
expect(meta.contractDocuments.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('IMP_CON_ETB_TRANSPORT_ONLY retains import container flagship clauses', () => {
|
||||||
|
const meta = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||||
|
expect(meta.direction).toBe('IMP');
|
||||||
|
expect(meta.freight).toBe('CON');
|
||||||
|
expect(meta.article1.objective).toContain('SGTD');
|
||||||
|
expect(meta.article1.objective).toContain('empty container return');
|
||||||
|
const clientText = meta.clientObligations.join(' ');
|
||||||
|
expect(clientText).toContain('Djibouti Nagad');
|
||||||
|
const providerText = meta.providerObligations.join(' ');
|
||||||
|
expect(providerText).toContain('seven (7) calendar days');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EXP_CON_USD_TRANSPORT_ONLY uses export-oriented article1', () => {
|
||||||
|
const meta = getTemplateMeta('EXP_CON_USD_TRANSPORT_ONLY');
|
||||||
|
expect(meta.direction).toBe('EXP');
|
||||||
|
expect(meta.article1.objective).toContain('SGTD');
|
||||||
|
expect(meta.providerObligations.join(' ')).not.toContain(
|
||||||
|
'Return empty containers from Dire Dawa',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FORWARDING adds scope and provider obligations', () => {
|
||||||
|
const transport = getTemplateMeta('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||||
|
const forwarding = getTemplateMeta('IMP_CON_ETB_FORWARDING');
|
||||||
|
expect(forwarding.article1.scope.length).toBeGreaterThan(
|
||||||
|
transport.article1.scope.length,
|
||||||
|
);
|
||||||
|
expect(forwarding.providerObligations.length).toBeGreaterThan(
|
||||||
|
transport.providerObligations.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getTemplateMeta fallback includes clause arrays for unknown keys', () => {
|
||||||
|
const meta = getTemplateMeta('UNKNOWN_KEY');
|
||||||
|
expect(meta.clientObligations.length).toBeGreaterThan(0);
|
||||||
|
expect(meta.article1.scope.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
119
apps/edr-freight-api/src/contracts/contract-template.registry.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
article1ObjectiveFromClause,
|
||||||
|
buildClausePack,
|
||||||
|
} from './contract-clause-packs';
|
||||||
|
import type {
|
||||||
|
ContractDirection,
|
||||||
|
ContractFreight,
|
||||||
|
ContractServiceScope,
|
||||||
|
ContractTemplateMeta,
|
||||||
|
} from './contract-template.types';
|
||||||
|
|
||||||
|
export type { ContractTemplateMeta } from './contract-template.types';
|
||||||
|
|
||||||
|
const DIRECTION_LABELS: Record<ContractDirection, string> = {
|
||||||
|
IMP: 'Import',
|
||||||
|
EXP: 'Export',
|
||||||
|
DOM: 'Domestic',
|
||||||
|
};
|
||||||
|
|
||||||
|
const FREIGHT_LABELS: Record<ContractFreight, string> = {
|
||||||
|
CON: 'Container',
|
||||||
|
BULK: 'Bulk',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM'];
|
||||||
|
const FREIGHTS: ContractFreight[] = ['CON', 'BULK'];
|
||||||
|
const CURRENCIES = ['ETB', 'USD'] as const;
|
||||||
|
const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING'];
|
||||||
|
|
||||||
|
const KEY_PATTERN =
|
||||||
|
/^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/;
|
||||||
|
|
||||||
|
function buildMeta(
|
||||||
|
dir: ContractDirection,
|
||||||
|
freight: ContractFreight,
|
||||||
|
currency: string,
|
||||||
|
service: ContractServiceScope,
|
||||||
|
): ContractTemplateMeta {
|
||||||
|
const key = `${dir}_${freight}_${currency}_${service}`;
|
||||||
|
const dirLabel = DIRECTION_LABELS[dir];
|
||||||
|
const freightLabel = FREIGHT_LABELS[freight];
|
||||||
|
const serviceLabel =
|
||||||
|
service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only';
|
||||||
|
|
||||||
|
const corridor =
|
||||||
|
dir === 'IMP'
|
||||||
|
? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable'
|
||||||
|
: dir === 'EXP'
|
||||||
|
? 'from Ethiopian dry ports to SGTD and related export corridors'
|
||||||
|
: 'between designated Ethiopian rail terminals';
|
||||||
|
|
||||||
|
const clauses = buildClausePack(dir, freight, service);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
direction: dir,
|
||||||
|
freight,
|
||||||
|
currency,
|
||||||
|
serviceScope: service,
|
||||||
|
title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`,
|
||||||
|
directionLabel: dirLabel,
|
||||||
|
freightLabel,
|
||||||
|
whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`,
|
||||||
|
article1Objective: article1ObjectiveFromClause(clauses.article1),
|
||||||
|
article1: clauses.article1,
|
||||||
|
clientObligations: clauses.clientObligations,
|
||||||
|
providerObligations: clauses.providerObligations,
|
||||||
|
contractDocuments: clauses.contractDocuments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full template matrix (24 keys). */
|
||||||
|
export const CONTRACT_TEMPLATE_REGISTRY: Record<string, ContractTemplateMeta> =
|
||||||
|
{};
|
||||||
|
|
||||||
|
for (const dir of DIRECTIONS) {
|
||||||
|
for (const freight of FREIGHTS) {
|
||||||
|
for (const currency of CURRENCIES) {
|
||||||
|
for (const service of SERVICES) {
|
||||||
|
const meta = buildMeta(dir, freight, currency, service);
|
||||||
|
CONTRACT_TEMPLATE_REGISTRY[meta.key] = meta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTRACT_TEMPLATE_KEYS = Object.keys(CONTRACT_TEMPLATE_REGISTRY);
|
||||||
|
|
||||||
|
export function listTemplateKeys(): string[] {
|
||||||
|
return CONTRACT_TEMPLATE_KEYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplateMeta(key: string): ContractTemplateMeta {
|
||||||
|
const found = CONTRACT_TEMPLATE_REGISTRY[key];
|
||||||
|
if (found) return found;
|
||||||
|
|
||||||
|
const fallbackClauses = buildClausePack('IMP', 'CON', 'TRANSPORT_ONLY');
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
direction: 'IMP',
|
||||||
|
freight: 'CON',
|
||||||
|
currency: 'USD',
|
||||||
|
serviceScope: 'TRANSPORT_ONLY',
|
||||||
|
title: 'Freight Contract Agreement',
|
||||||
|
directionLabel: 'Freight',
|
||||||
|
freightLabel: 'Cargo',
|
||||||
|
whereas:
|
||||||
|
'The parties agree to railway freight services as described in the schedule below.',
|
||||||
|
article1Objective: article1ObjectiveFromClause(fallbackClauses.article1),
|
||||||
|
article1: fallbackClauses.article1,
|
||||||
|
clientObligations: fallbackClauses.clientObligations,
|
||||||
|
providerObligations: fallbackClauses.providerObligations,
|
||||||
|
contractDocuments: fallbackClauses.contractDocuments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidTemplateKey(key: string): boolean {
|
||||||
|
return KEY_PATTERN.test(key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
|
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||||
|
|
||||||
|
describe('ContractTemplateResolver', () => {
|
||||||
|
const resolver = new ContractTemplateResolver();
|
||||||
|
|
||||||
|
function booking(partial: Partial<Booking>): Booking {
|
||||||
|
return partial as Booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resolves import container ETB transport-only', () => {
|
||||||
|
const key = resolver.resolve(
|
||||||
|
booking({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
serviceType: { code: 'RAIL_ONLY', includesFirstMile: false, includesLastMile: false } as ServiceType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(key).toBe('IMP_CON_ETB_TRANSPORT_ONLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves export bulk USD forwarding', () => {
|
||||||
|
const key = resolver.resolve(
|
||||||
|
booking({
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
freightType: 'BULK',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
serviceType: {
|
||||||
|
code: 'RAIL_FORWARDING',
|
||||||
|
includesFirstMile: true,
|
||||||
|
includesLastMile: false,
|
||||||
|
} as ServiceType,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(key).toBe('EXP_BULK_USD_FORWARDING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps BREAK_BULK cargo to BULK freight', () => {
|
||||||
|
const key = resolver.resolve(
|
||||||
|
booking({
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
paymentCurrency: 'ETB',
|
||||||
|
cargoType: { code: 'BREAK_BULK_GENERAL' } as CargoType,
|
||||||
|
serviceType: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(key).toBe('IMP_BULK_ETB_TRANSPORT_ONLY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves domestic container', () => {
|
||||||
|
const key = resolver.resolve(
|
||||||
|
booking({
|
||||||
|
tradeDirection: 'DOMESTIC',
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
paymentCurrency: 'USD',
|
||||||
|
serviceType: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(key).toBe('DOM_CON_USD_TRANSPORT_ONLY');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||||
|
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractTemplateResolver {
|
||||||
|
resolve(booking: Booking): string {
|
||||||
|
const dir =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? 'IMP'
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? 'EXP'
|
||||||
|
: 'DOM';
|
||||||
|
|
||||||
|
let freight = booking.freightType === 'BULK' ? 'BULK' : 'CON';
|
||||||
|
const cargoCode = (booking.cargoType as CargoType | undefined)?.code ?? '';
|
||||||
|
if (cargoCode.startsWith('BREAK_BULK')) {
|
||||||
|
freight = 'BULK';
|
||||||
|
}
|
||||||
|
|
||||||
|
const currency = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||||
|
const service = this.resolveServiceScope(booking.serviceType);
|
||||||
|
|
||||||
|
return `${dir}_${freight}_${currency}_${service}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveServiceScope(
|
||||||
|
serviceType?: ServiceType | null,
|
||||||
|
): 'TRANSPORT_ONLY' | 'FORWARDING' {
|
||||||
|
if (!serviceType) return 'TRANSPORT_ONLY';
|
||||||
|
const code = (serviceType.code ?? '').toUpperCase();
|
||||||
|
if (
|
||||||
|
serviceType.includesFirstMile ||
|
||||||
|
serviceType.includesLastMile ||
|
||||||
|
code.includes('FORWARD')
|
||||||
|
) {
|
||||||
|
return 'FORWARDING';
|
||||||
|
}
|
||||||
|
return 'TRANSPORT_ONLY';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type ContractDirection = 'IMP' | 'EXP' | 'DOM';
|
||||||
|
export type ContractFreight = 'CON' | 'BULK';
|
||||||
|
export type ContractServiceScope = 'TRANSPORT_ONLY' | 'FORWARDING';
|
||||||
|
|
||||||
|
export interface Article1Clause {
|
||||||
|
objective: string;
|
||||||
|
scope: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractClausePack {
|
||||||
|
article1: Article1Clause;
|
||||||
|
clientObligations: string[];
|
||||||
|
providerObligations: string[];
|
||||||
|
contractDocuments: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractTemplateMeta {
|
||||||
|
key: string;
|
||||||
|
direction: ContractDirection;
|
||||||
|
freight: ContractFreight;
|
||||||
|
currency: string;
|
||||||
|
serviceScope: ContractServiceScope;
|
||||||
|
title: string;
|
||||||
|
directionLabel: string;
|
||||||
|
freightLabel: string;
|
||||||
|
whereas: string;
|
||||||
|
/** Summary line for APIs; mirrors article1.objective */
|
||||||
|
article1Objective: string;
|
||||||
|
article1: Article1Clause;
|
||||||
|
clientObligations: string[];
|
||||||
|
providerObligations: string[];
|
||||||
|
contractDocuments: string[];
|
||||||
|
/** Optional dedicated .hbs file; otherwise uses generic.hbs */
|
||||||
|
templateFile?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BookingsRepository } from '../modules/bookings/bookings.repository';
|
||||||
|
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||||
|
import {
|
||||||
|
BookingContractSignature,
|
||||||
|
ContractSignerRole,
|
||||||
|
} from '../modules/bookings/entities/booking-contract-signature.entity';
|
||||||
|
import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pricing-schedule.builder';
|
||||||
|
import { ContractTemplateResolver } from './contract-template.resolver';
|
||||||
|
import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry';
|
||||||
|
|
||||||
|
export interface ContractSignatureView {
|
||||||
|
role: ContractSignerRole;
|
||||||
|
signerDisplayName: string;
|
||||||
|
signedAt: string;
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractViewModel {
|
||||||
|
bookingId: string;
|
||||||
|
reference: string;
|
||||||
|
status: string;
|
||||||
|
templateKey: string;
|
||||||
|
template: ContractTemplateMeta;
|
||||||
|
contractDate: string;
|
||||||
|
contractYear: number;
|
||||||
|
client: {
|
||||||
|
companyName: string;
|
||||||
|
companyAddress: string;
|
||||||
|
companyLocation: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
tinNumber: string;
|
||||||
|
};
|
||||||
|
pricing: PricingSchedule;
|
||||||
|
signatures: ContractSignatureView[];
|
||||||
|
canSignCustomer: boolean;
|
||||||
|
canSignStaff: boolean;
|
||||||
|
hasContractDocument: boolean;
|
||||||
|
hasCustomerSignature: boolean;
|
||||||
|
hasStaffSignature: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ContractViewModelBuilder {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly templateResolver: ContractTemplateResolver,
|
||||||
|
private readonly pricingBuilder: ContractPricingScheduleBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateKey =
|
||||||
|
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
|
||||||
|
const template = getTemplateMeta(templateKey);
|
||||||
|
const pricing = await this.pricingBuilder.build(booking);
|
||||||
|
const signatures = await this.loadSignatures(bookingId);
|
||||||
|
|
||||||
|
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||||
|
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||||
|
const hasContractFile = Boolean(
|
||||||
|
booking.files?.some((f) => f.code === 'contract'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const view: ContractViewModel = {
|
||||||
|
bookingId: booking.id,
|
||||||
|
reference: booking.reference,
|
||||||
|
status: booking.status,
|
||||||
|
templateKey,
|
||||||
|
template,
|
||||||
|
contractDate: new Date().toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}),
|
||||||
|
contractYear: new Date().getFullYear(),
|
||||||
|
client: {
|
||||||
|
// companyName: booking.customer?.companyName ?? 'Client',
|
||||||
|
// companyAddress: booking.customer?.companyAddress ?? '—',
|
||||||
|
// companyLocation: booking.customer?.companyLocation ?? '—',
|
||||||
|
// phone: booking.customer?.companyPhone ?? booking.customer?.phone ?? '—',
|
||||||
|
// email: booking.customer?.companyEmail ?? booking.customer?.email ?? '—',
|
||||||
|
// tinNumber: booking.customer?.tinNumber ?? '—',
|
||||||
|
companyName: booking.company?.name ?? 'Client',
|
||||||
|
companyAddress: booking.company?.address ?? '—',
|
||||||
|
companyLocation: booking.company?.country ?? '—',
|
||||||
|
phone: booking.company?.phone ?? '—',
|
||||||
|
email: booking.company?.email ?? '—',
|
||||||
|
tinNumber: booking.company?.tin ?? '—',
|
||||||
|
},
|
||||||
|
pricing,
|
||||||
|
signatures,
|
||||||
|
canSignCustomer:
|
||||||
|
booking.status === 'CONTRACT_READY' && !hasCustomer,
|
||||||
|
canSignStaff:
|
||||||
|
booking.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
|
hasContractDocument: hasContractFile,
|
||||||
|
hasCustomerSignature: hasCustomer,
|
||||||
|
hasStaffSignature: hasStaff,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { booking, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSignatures(bookingId: string): Promise<ContractSignatureView[]> {
|
||||||
|
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||||
|
return rows.map((s) => this.toSignatureView(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
toSignatureView(row: BookingContractSignature): ContractSignatureView {
|
||||||
|
return {
|
||||||
|
role: row.signerRole,
|
||||||
|
signerDisplayName: row.signerDisplayName,
|
||||||
|
signedAt: row.signedAt.toISOString(),
|
||||||
|
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<div class="article">
|
||||||
|
<h2>Article 1: Objective and Scope of Services</h2>
|
||||||
|
<p><strong>Objective:</strong> {{template.article1.objective}}</p>
|
||||||
|
{{#if template.article1.scope.length}}
|
||||||
|
<p><strong>Scope:</strong></p>
|
||||||
|
<ol>
|
||||||
|
{{#each template.article1.scope}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<h2>Article 5: Contract Price and Terms of Payment</h2>
|
||||||
|
<div class="article">
|
||||||
|
<h3>Contract Price</h3>
|
||||||
|
<p><strong>Corridor:</strong> {{pricing.originLabel}} → {{pricing.destinationLabel}}</p>
|
||||||
|
{{#if pricing.equipmentReturn}}
|
||||||
|
<p><strong>Equipment return:</strong> {{pricing.equipmentReturn}}</p>
|
||||||
|
{{/if}}
|
||||||
|
{{#if pricing.containerLines.length}}
|
||||||
|
<table class="schedule">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Container type</th><th>Quantity</th><th>VGM / unit (t)</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each pricing.containerLines}}
|
||||||
|
<tr><td>{{label}}</td><td>{{quantity}}</td><td>{{vgmPerUnitTons}}</td></tr>
|
||||||
|
{{/each}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{/if}}
|
||||||
|
<table class="schedule">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Item</th><th>Description</th><th>Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#each pricing.lineItems}}
|
||||||
|
<tr>
|
||||||
|
<td>{{label}}</td>
|
||||||
|
<td>{{description}}</td>
|
||||||
|
<td>{{currency}} {{amount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
{{#each pricing.surcharges}}
|
||||||
|
<tr>
|
||||||
|
<td>{{label}}</td>
|
||||||
|
<td>{{description}}</td>
|
||||||
|
<td>{{currency}} {{amount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"><strong>Total contract value</strong></td>
|
||||||
|
<td><strong>{{pricing.currency}} {{pricing.totalAmount}}</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h3>Terms of payment</h3>
|
||||||
|
<p>All payments shall be made in accordance with EDR policy in <strong>{{paymentArticle}}</strong>, unless otherwise agreed in writing.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="article">
|
||||||
|
<h2>Article 2: Obligations of the Client (summary)</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each template.clientObligations}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="article">
|
||||||
|
<h2>Article 3: Obligations of the Service Provider (summary)</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each template.providerObligations}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<div class="article">
|
||||||
|
<h2>Article 6: Contract Documents</h2>
|
||||||
|
<ol>
|
||||||
|
{{#each template.contractDocuments}}
|
||||||
|
<li>{{this}}</li>
|
||||||
|
{{/each}}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="article">
|
||||||
|
<h2>Article 4: Force Majeure</h2>
|
||||||
|
<p>Neither party is liable for delays due to force majeure interpreted under the Ethiopian Civil Code.</p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="signatures">
|
||||||
|
<div class="sig-block">
|
||||||
|
<p><strong>Service Provider (EDR)</strong></p>
|
||||||
|
{{#if hasStaffSignature}}
|
||||||
|
{{#each signatures}}
|
||||||
|
{{#if (eq role "STAFF")}}
|
||||||
|
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Staff signature" />{{/if}}
|
||||||
|
<p>{{signerDisplayName}}</p>
|
||||||
|
<p class="sig-line">Signed: {{signedAt}}</p>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
{{else}}
|
||||||
|
<p class="sig-line">Authorized representative (pending)</p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="sig-block">
|
||||||
|
<p><strong>Client — {{client.companyName}}</strong></p>
|
||||||
|
{{#if hasCustomerSignature}}
|
||||||
|
{{#each signatures}}
|
||||||
|
{{#if (eq role "CUSTOMER")}}
|
||||||
|
{{#if signatureImageUrl}}<img src="{{signatureImageUrl}}" alt="Customer signature" />{{/if}}
|
||||||
|
<p>{{signerDisplayName}}</p>
|
||||||
|
<p class="sig-line">Signed: {{signedAt}}</p>
|
||||||
|
{{/if}}
|
||||||
|
{{/each}}
|
||||||
|
{{else}}
|
||||||
|
<p class="sig-line">Client representative (pending)</p>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: 'Times New Roman', Times, serif; font-size: 11pt; line-height: 1.45; color: #111; margin: 0; padding: 24px; }
|
||||||
|
h1 { text-align: center; font-size: 14pt; text-transform: uppercase; margin: 0 0 8px; }
|
||||||
|
h2 { font-size: 12pt; margin: 20px 0 8px; text-transform: uppercase; }
|
||||||
|
h3 { font-size: 11pt; margin: 14px 0 6px; }
|
||||||
|
.cover { text-align: center; margin-bottom: 32px; border-bottom: 2px solid #1e3a5f; padding-bottom: 24px; }
|
||||||
|
.meta { margin: 12px 0; }
|
||||||
|
.meta strong { display: inline-block; min-width: 140px; }
|
||||||
|
.article { margin-bottom: 16px; }
|
||||||
|
.article ol { padding-left: 20px; }
|
||||||
|
.article li { margin-bottom: 6px; }
|
||||||
|
table.schedule { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 10pt; }
|
||||||
|
table.schedule th, table.schedule td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
|
||||||
|
table.schedule th { background: #f0f4f8; }
|
||||||
|
.signatures { display: flex; gap: 40px; margin-top: 40px; page-break-inside: avoid; }
|
||||||
|
.sig-block { flex: 1; }
|
||||||
|
.sig-block img { max-height: 64px; max-width: 200px; display: block; margin: 8px 0; }
|
||||||
|
.sig-line { border-top: 1px solid #000; margin-top: 48px; padding-top: 4px; font-size: 10pt; }
|
||||||
|
.pending-banner { background: #fff8e6; border: 1px solid #e6c200; padding: 8px 12px; margin-bottom: 16px; font-size: 10pt; }
|
||||||
|
@media print { body { padding: 0; } }
|
||||||
|
</style>
|
||||||
30
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
30
apps/edr-freight-api/src/contracts/templates/generic.hbs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>{{template.title}} — {{reference}}</title>
|
||||||
|
{{> styles}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="cover">
|
||||||
|
<h1>Contract Agreement</h1>
|
||||||
|
<h1>{{template.title}}</h1>
|
||||||
|
<p class="meta"><strong>Contract Ref No:</strong> {{reference}}</p>
|
||||||
|
<p class="meta"><strong>Year:</strong> {{contractYear}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>This Contract Agreement is made on <strong>{{contractDate}}</strong>.</p>
|
||||||
|
<p><strong>Between</strong> Ethio-Djibouti Standard Gauge Railway Share Company (EDR), Addis Ababa (“Service Provider”), and <strong>{{client.companyName}}</strong> at {{client.companyAddress}}, {{client.companyLocation}} (“Client”). Phone {{client.phone}} / {{client.email}}. TIN {{client.tinNumber}}.</p>
|
||||||
|
|
||||||
|
<h2>Whereas</h2>
|
||||||
|
<p>{{template.whereas}}</p>
|
||||||
|
<p>Now therefore, the parties agree as follows:</p>
|
||||||
|
|
||||||
|
{{> article1}}
|
||||||
|
{{> articles_obligations}}
|
||||||
|
{{> article5_pricing}}
|
||||||
|
{{> force_majeure}}
|
||||||
|
{{> contract_documents}}
|
||||||
|
{{> signatures_block}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class BookingFlowRefactor1749200000000 implements MigrationInterface {
|
||||||
|
name = 'BookingFlowRefactor1749200000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_review_note (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
type VARCHAR(30) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_review_note_booking_id
|
||||||
|
ON freight.booking_review_note(booking_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS marketing_approved_by_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS marketing_approved_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_summary TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS locked_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings SET status = 'SUBMITTED'
|
||||||
|
WHERE status IN ('RFQ_SUBMITTED', 'QUOTATION_SENT', 'QUOTATION_APPROVED');
|
||||||
|
UPDATE freight.bookings SET status = 'REJECTED'
|
||||||
|
WHERE status = 'QUOTATION_REJECTED';
|
||||||
|
UPDATE freight.bookings SET status = 'CANCELLED'
|
||||||
|
WHERE status = 'CANCELLED';
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS locked_at,
|
||||||
|
DROP COLUMN IF EXISTS contract_summary,
|
||||||
|
DROP COLUMN IF EXISTS marketing_approved_at,
|
||||||
|
DROP COLUMN IF EXISTS marketing_approved_by_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_review_note;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { MigrationInterface, QueryRunner, Table, TableIndex, TableUnique } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateCompaniesModule1749200000000 implements MigrationInterface {
|
||||||
|
name = 'CreateCompaniesModule1749200000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'companies',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'name', type: 'varchar', length: '200' },
|
||||||
|
{ name: 'type', type: 'varchar', length: '32' },
|
||||||
|
{ name: 'status', type: 'varchar', length: '32', default: "'pending'" },
|
||||||
|
{ name: 'tin', type: 'varchar', length: '10', isUnique: true },
|
||||||
|
{ name: 'vat_number', type: 'varchar', length: '50', isNullable: true },
|
||||||
|
{ name: 'business_license', type: 'varchar', length: '100', isNullable: true },
|
||||||
|
{ name: 'fan_number', type: 'varchar', length: '16', isNullable: true },
|
||||||
|
{ name: 'country', type: 'varchar', length: '32', default: "'Ethiopia'" },
|
||||||
|
{ name: 'address', type: 'text', isNullable: true },
|
||||||
|
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||||
|
{ name: 'email', type: 'varchar', length: '150', isNullable: true },
|
||||||
|
{ name: 'website', type: 'varchar', length: '200', isNullable: true },
|
||||||
|
{ name: 'attributes', type: 'jsonb', isNullable: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'external_profiles',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'user_id', type: 'uuid' },
|
||||||
|
{ name: 'company_id', type: 'uuid' },
|
||||||
|
{ name: 'first_name', type: 'varchar', length: '100' },
|
||||||
|
{ name: 'last_name', type: 'varchar', length: '100' },
|
||||||
|
{ name: 'email', type: 'varchar', length: '150', isUnique: true },
|
||||||
|
{ name: 'phone', type: 'varchar', length: '20', isNullable: true },
|
||||||
|
{ name: 'national_id', type: 'varchar', length: '50', isNullable: true },
|
||||||
|
{ name: 'job_title', type: 'varchar', length: '100', isNullable: true },
|
||||||
|
{ name: 'is_primary_contact', type: 'boolean', default: false },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['company_id'],
|
||||||
|
referencedTableName: 'companies',
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
schema: 'freight',
|
||||||
|
name: 'ff_clients',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' },
|
||||||
|
{ name: 'forwarder_company_id', type: 'uuid' },
|
||||||
|
{ name: 'client_company_id', type: 'uuid' },
|
||||||
|
{ name: 'relationship_type', type: 'varchar', length: '32', default: "'managed_account'" },
|
||||||
|
{ name: 'can_book_on_behalf', type: 'boolean', default: true },
|
||||||
|
{ name: 'can_view_documents', type: 'boolean', default: true },
|
||||||
|
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||||
|
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
{
|
||||||
|
columnNames: ['forwarder_company_id'],
|
||||||
|
referencedTableName: 'companies',
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnNames: ['client_company_id'],
|
||||||
|
referencedTableName: 'companies',
|
||||||
|
referencedSchema: 'freight',
|
||||||
|
referencedColumnNames: ['id'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['tin'] }));
|
||||||
|
await queryRunner.createIndex('freight.companies', new TableIndex({ columnNames: ['type'] }));
|
||||||
|
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['user_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.external_profiles', new TableIndex({ columnNames: ['company_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['forwarder_company_id'] }));
|
||||||
|
await queryRunner.createIndex('freight.ff_clients', new TableIndex({ columnNames: ['client_company_id'] }));
|
||||||
|
|
||||||
|
await queryRunner.createUniqueConstraint('freight.ff_clients', new TableUnique({
|
||||||
|
columnNames: ['forwarder_company_id', 'client_company_id'],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable('freight.ff_clients');
|
||||||
|
await queryRunner.dropTable('freight.external_profiles');
|
||||||
|
await queryRunner.dropTable('freight.companies');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddBookingFreightType1749300000000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingFreightType1749300000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS freight_type VARCHAR(20);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET freight_type = 'CONTAINER'
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM freight.booking_container bc WHERE bc.booking_id = b.id
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings b
|
||||||
|
SET freight_type = 'BULK'
|
||||||
|
WHERE freight_type IS NULL
|
||||||
|
AND b.cargo_type_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM freight.cargo_types ct
|
||||||
|
WHERE ct.id = b.cargo_type_id AND ct.requires_director_approval = true
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings
|
||||||
|
SET freight_type = 'CONTAINER'
|
||||||
|
WHERE freight_type IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN cargo_type_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN freight_type SET NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD CONSTRAINT chk_bookings_freight_type
|
||||||
|
CHECK (freight_type IN ('CONTAINER', 'BULK'));
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS chk_bookings_freight_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS freight_type;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings SET cargo_type_id = (
|
||||||
|
SELECT id FROM freight.cargo_types LIMIT 1
|
||||||
|
) WHERE cargo_type_id IS NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN cargo_type_id SET NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddFanNumberToCompanies1749300000000 implements MigrationInterface {
|
||||||
|
name = 'AddFanNumberToCompanies1749300000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// fan_number may already exist when CreateCompaniesModule ran with the full schema
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
ADD COLUMN IF NOT EXISTS fan_number varchar(16) NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.companies
|
||||||
|
DROP COLUMN IF EXISTS fan_number;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddContractSignatures1749400000000 implements MigrationInterface {
|
||||||
|
name = 'AddContractSignatures1749400000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_template_key VARCHAR(80),
|
||||||
|
ADD COLUMN IF NOT EXISTS contract_generated_at TIMESTAMPTZ,
|
||||||
|
ADD COLUMN IF NOT EXISTS pricing_breakdown JSONB;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.booking_contract_signatures (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
signer_role VARCHAR(20) NOT NULL,
|
||||||
|
signer_user_id UUID,
|
||||||
|
signer_display_name VARCHAR(200) NOT NULL,
|
||||||
|
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
signature_file_id UUID REFERENCES freight.files(id) ON DELETE SET NULL,
|
||||||
|
consent_text TEXT,
|
||||||
|
ip_address VARCHAR(64),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_booking_contract_signatures_role
|
||||||
|
UNIQUE (booking_id, signer_role)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_booking_contract_signatures_booking_id
|
||||||
|
ON freight.booking_contract_signatures(booking_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_contract_signatures;`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS pricing_breakdown,
|
||||||
|
DROP COLUMN IF EXISTS contract_generated_at,
|
||||||
|
DROP COLUMN IF EXISTS contract_template_key;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddCompanyIdToBookings1749500000000 implements MigrationInterface {
|
||||||
|
name = 'AddCompanyIdToBookings1749500000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN customer_id DROP NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS company_id UUID;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookings_company_id
|
||||||
|
ON freight.bookings(company_id);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_id'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD CONSTRAINT "FK_bookings_company_id"
|
||||||
|
FOREIGN KEY (company_id)
|
||||||
|
REFERENCES freight.companies(id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP CONSTRAINT IF EXISTS "FK_bookings_company_id";
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE freight.bookings SET customer_id = company_id WHERE customer_id IS NULL AND company_id IS NOT NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ALTER COLUMN customer_id SET NOT NULL;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.idx_bookings_company_id;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
DROP COLUMN IF EXISTS company_id;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
|
import { getTemplateMeta } from '../../contracts/contract-template.registry';
|
||||||
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
|
import { MinioService } from '../minio/minio.service';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingContractService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
private readonly minioService: MinioService,
|
||||||
|
private readonly templateResolver: ContractTemplateResolver,
|
||||||
|
private readonly viewModelBuilder: ContractViewModelBuilder,
|
||||||
|
private readonly renderer: ContractRendererService,
|
||||||
|
private readonly pdfService: ContractPdfService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
buildContractSummary(booking: Booking): string {
|
||||||
|
const direction =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? 'Import'
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? 'Export'
|
||||||
|
: booking.tradeDirection;
|
||||||
|
|
||||||
|
const cargo = booking.cargoType;
|
||||||
|
const isBulk = booking.freightType === 'BULK';
|
||||||
|
|
||||||
|
let cargoLabel: string;
|
||||||
|
if (isBulk) {
|
||||||
|
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
|
||||||
|
} else {
|
||||||
|
const lines =
|
||||||
|
booking.bookingContainers?.map((bc) => {
|
||||||
|
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
|
||||||
|
return `${bc.quantity}× ${label}`;
|
||||||
|
}) ?? [];
|
||||||
|
cargoLabel =
|
||||||
|
lines.length > 0
|
||||||
|
? `Container (${lines.join(', ')})`
|
||||||
|
: 'Container (Standard)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSummary(bookingId: string): Promise<{ summary: string }> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
|
||||||
|
return { summary };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||||
|
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||||
|
await this.enrichSignatureUrls(view.signatures);
|
||||||
|
const html = this.renderer.render(view);
|
||||||
|
return {
|
||||||
|
bookingId: view.bookingId,
|
||||||
|
reference: view.reference,
|
||||||
|
status: view.status,
|
||||||
|
templateKey: view.templateKey,
|
||||||
|
title: view.template.title,
|
||||||
|
html,
|
||||||
|
canSignCustomer: view.canSignCustomer,
|
||||||
|
canSignStaff: view.canSignStaff,
|
||||||
|
hasContractDocument: view.hasContractDocument,
|
||||||
|
signatures: view.signatures,
|
||||||
|
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateContract(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['APPROVED']);
|
||||||
|
|
||||||
|
const templateKey = this.templateResolver.resolve(booking);
|
||||||
|
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||||
|
view.templateKey = templateKey;
|
||||||
|
view.template = getTemplateMeta(templateKey);
|
||||||
|
|
||||||
|
const html = this.renderer.render(view);
|
||||||
|
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||||
|
const summary = this.buildContractSummary(booking);
|
||||||
|
|
||||||
|
const file: Express.Multer.File = {
|
||||||
|
fieldname: 'contract',
|
||||||
|
originalname: `contract-${booking.reference}.pdf`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'application/pdf',
|
||||||
|
size: pdfBuffer.length,
|
||||||
|
buffer: pdfBuffer,
|
||||||
|
stream: Readable.from(pdfBuffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'contract',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CONTRACT_READY',
|
||||||
|
contractSummary: summary,
|
||||||
|
contractTemplateKey: templateKey,
|
||||||
|
contractGeneratedAt: now,
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async streamContract(bookingId: string) {
|
||||||
|
try {
|
||||||
|
const record = await this.filesService.findByCode(
|
||||||
|
bookingId,
|
||||||
|
'bookings',
|
||||||
|
'contract',
|
||||||
|
);
|
||||||
|
return this.filesService.streamById(record.id);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException(
|
||||||
|
'Contract document not found. Generate the contract first.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async signContract(
|
||||||
|
bookingId: string,
|
||||||
|
dto: SignContractDto,
|
||||||
|
options: { signerUserId?: string; ipAddress?: string },
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
const role = dto.role as ContractSignerRole;
|
||||||
|
|
||||||
|
if (role === 'CUSTOMER') {
|
||||||
|
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||||
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
|
bookingId,
|
||||||
|
'CUSTOMER',
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||||
|
const existing = await this.bookingsRepository.findContractSignature(
|
||||||
|
bookingId,
|
||||||
|
'STAFF',
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException('Staff has already signed this contract');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
|
||||||
|
const sigFile: Express.Multer.File = {
|
||||||
|
fieldname: `signature_${role.toLowerCase()}`,
|
||||||
|
originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
|
||||||
|
encoding: '7bit',
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: buffer.length,
|
||||||
|
buffer,
|
||||||
|
stream: Readable.from(buffer),
|
||||||
|
destination: '',
|
||||||
|
filename: '',
|
||||||
|
path: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileRecord = await this.filesService.upsertByCode({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
|
||||||
|
file: sigFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.bookingsRepository.saveContractSignature({
|
||||||
|
bookingId,
|
||||||
|
signerRole: role,
|
||||||
|
signerUserId: options.signerUserId ?? null,
|
||||||
|
signerDisplayName: dto.signerDisplayName,
|
||||||
|
signedAt: now,
|
||||||
|
signatureFileId: fileRecord.id,
|
||||||
|
consentText: dto.consentText ?? null,
|
||||||
|
ipAddress: options.ipAddress ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (role === 'CUSTOMER') {
|
||||||
|
updates.status = 'SIGNED_CUSTOMER';
|
||||||
|
updates.customerSignedAt = now;
|
||||||
|
} else {
|
||||||
|
updates.status = 'FULLY_EXECUTED';
|
||||||
|
updates.fullyExecutedAt = now;
|
||||||
|
updates.marketingApprovedAt = now;
|
||||||
|
updates.marketingApprovedById = options.signerUserId ?? null;
|
||||||
|
updates.lockedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSignatures(bookingId: string) {
|
||||||
|
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||||
|
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||||||
|
await this.enrichSignatureUrls(views);
|
||||||
|
return { signatures: views };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichSignatureUrls(
|
||||||
|
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const sig of signatures) {
|
||||||
|
if (!sig.signatureImageUrl) continue;
|
||||||
|
try {
|
||||||
|
const objectName = this.extractObjectName(sig.signatureImageUrl);
|
||||||
|
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
|
||||||
|
} catch {
|
||||||
|
/* keep original url */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractObjectName(url: string): string {
|
||||||
|
const parts = url.split('/');
|
||||||
|
return parts.slice(4).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeSignatureImage(base64: string): Buffer {
|
||||||
|
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
|
||||||
|
return Buffer.from(raw, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
|
||||||
|
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
|
||||||
|
|
||||||
|
/** Normalize and validate booking freight shape (used on create and after update merge). */
|
||||||
|
export function assertFreightShape(input: BookingFreightShapeInput): void {
|
||||||
|
if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
//
|
||||||
|
|
||||||
|
const containers = input.containers ?? [];
|
||||||
|
const hasContainers = containers.length > 0;
|
||||||
|
const hasCargoType = Boolean(input.cargoTypeId);
|
||||||
|
|
||||||
|
if (input.freightType === 'BULK') {
|
||||||
|
if (hasContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'BULK freight cannot include container lines; use cargoTypeId only',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!hasCargoType) {
|
||||||
|
throw new BadRequestException('cargoTypeId is required for BULK freight');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCargoType) {
|
||||||
|
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
|
||||||
|
}
|
||||||
|
if (!hasContainers) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'CONTAINER freight requires at least one container line with containerTypeId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const line of containers) {
|
||||||
|
if (!line.containerTypeId) {
|
||||||
|
throw new BadRequestException('Each container line must include containerTypeId');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
|
||||||
|
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingPaymentService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generatePnr(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||||
|
|
||||||
|
if (booking.paymentCurrency !== 'ETB') {
|
||||||
|
throw new BadRequestException('PNR generation is only for ETB payers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PNR_GENERATED',
|
||||||
|
pnrCode,
|
||||||
|
paymentStatus: 'PNR_GENERATED',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitPaymentProof(
|
||||||
|
bookingId: string,
|
||||||
|
file: Express.Multer.File,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||||
|
|
||||||
|
if (booking.paymentCurrency !== 'USD') {
|
||||||
|
throw new BadRequestException('Payment proof upload is only for USD payers');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.validateProofFile(file);
|
||||||
|
|
||||||
|
await this.filesService.upload({
|
||||||
|
resourceId: bookingId,
|
||||||
|
resource: 'bookings',
|
||||||
|
code: 'payment_proof',
|
||||||
|
file,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
paymentStatus: 'VERIFICATION_IN_PROGRESS',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyPayment(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleBankCallback(pnrCode: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== 'PNR_GENERATED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(booking.id, {
|
||||||
|
status: 'PAID',
|
||||||
|
paymentStatus: 'PAID',
|
||||||
|
} as never);
|
||||||
|
return updated!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentRequestLetter(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
const body = [
|
||||||
|
'PAYMENT REQUEST LETTER (STUB)',
|
||||||
|
`Reference: ${booking.reference}`,
|
||||||
|
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
||||||
|
'Pay at your bank and upload stamped proof.',
|
||||||
|
].join('\n');
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(body, 'utf-8'),
|
||||||
|
filename: `payment-request-${booking.reference}.txt`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateProofFile(file: Express.Multer.File): void {
|
||||||
|
if (!file?.buffer?.length) {
|
||||||
|
throw new BadRequestException('Payment proof file is required');
|
||||||
|
}
|
||||||
|
if (file.size > PROOF_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('Payment proof must be 5MB or less');
|
||||||
|
}
|
||||||
|
if (!PROOF_MIMES.includes(file.mimetype)) {
|
||||||
|
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
|
import { RatesService } from '../rule-engine/services/rates.service';
|
||||||
|
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
|
||||||
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
|
import {
|
||||||
|
AppliedCargoModifier,
|
||||||
|
BookingEvaluationInput,
|
||||||
|
RuleEngineService,
|
||||||
|
} from '../rule-engine/rule-engine.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingPricingService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
|
private readonly ratesService: RatesService,
|
||||||
|
private readonly serviceTypesService: ServiceTypesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||||
|
const booking = await this.requireBooking(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DRAFT']);
|
||||||
|
|
||||||
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
|
console.log('evalInput----', evalInput);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
|
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||||
|
for (const line of baseLines) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
|
const item: PriceLineItemDto = {
|
||||||
|
code: mod.surchargeTypeCode,
|
||||||
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||||
|
amount: mod.calculatedAmount,
|
||||||
|
currency: mod.currency,
|
||||||
|
};
|
||||||
|
lineItems.push(item);
|
||||||
|
total += mod.calculatedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
|
||||||
|
|
||||||
|
await this.bookingsRepository.update(bookingId, {
|
||||||
|
totalAmount: total,
|
||||||
|
priorityScore: ruleResult.priorityScore,
|
||||||
|
pricingBreakdown: {
|
||||||
|
lineItems,
|
||||||
|
totalAmount: total,
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
totalAmount: total,
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
lineItems,
|
||||||
|
warnings: ruleResult.warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||||
|
const containers = await Promise.all(
|
||||||
|
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||||
|
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||||
|
const vgm = Number(bc.vgmPerUnitTons);
|
||||||
|
const qty = bc.quantity;
|
||||||
|
return {
|
||||||
|
containerTypeId: bc.containerTypeId,
|
||||||
|
quantity: qty,
|
||||||
|
vgmPerUnitTons: vgm,
|
||||||
|
totalVgmTons: qty * vgm,
|
||||||
|
isReefer: ct.isReefer,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeId: booking.cargoTypeId ?? null,
|
||||||
|
serviceTypeId: booking.serviceTypeId,
|
||||||
|
paymentCurrency: booking.paymentCurrency,
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
isHazardous: booking.isHazardous,
|
||||||
|
allowConsolidation: booking.allowConsolidation,
|
||||||
|
shippingLineId: booking.shippingLineId,
|
||||||
|
containers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
return booking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line items for contract schedule (uses stored breakdown or recomputes). */
|
||||||
|
async computeContractLineItems(booking: Booking): Promise<{
|
||||||
|
lineItems: PriceLineItemDto[];
|
||||||
|
totalAmount: number;
|
||||||
|
currency: string;
|
||||||
|
}> {
|
||||||
|
const stored = booking.pricingBreakdown as {
|
||||||
|
lineItems?: PriceLineItemDto[];
|
||||||
|
totalAmount?: number;
|
||||||
|
currency?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
if (stored?.lineItems?.length) {
|
||||||
|
return {
|
||||||
|
lineItems: stored.lineItems,
|
||||||
|
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
|
||||||
|
currency: stored.currency ?? booking.paymentCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
const lineItems: PriceLineItemDto[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const baseLines = await this.computeBaseRailLines(booking, evalInput);
|
||||||
|
for (const line of baseLines) {
|
||||||
|
lineItems.push(line);
|
||||||
|
total += line.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mod of ruleResult.appliedModifiers) {
|
||||||
|
lineItems.push({
|
||||||
|
code: mod.surchargeTypeCode,
|
||||||
|
description: `Surcharge: ${mod.surchargeTypeCode}`,
|
||||||
|
amount: mod.calculatedAmount,
|
||||||
|
currency: mod.currency,
|
||||||
|
});
|
||||||
|
total += mod.calculatedAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lineItems.length === 0) {
|
||||||
|
total = Number(booking.totalAmount);
|
||||||
|
lineItems.push({
|
||||||
|
code: 'TOTAL',
|
||||||
|
description: 'Contract total',
|
||||||
|
amount: total,
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineItems,
|
||||||
|
totalAmount: total || Number(booking.totalAmount),
|
||||||
|
currency: booking.paymentCurrency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute priority on submit (USD + service tier). */
|
||||||
|
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
|
||||||
|
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||||
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
|
let score = ruleResult.priorityScore;
|
||||||
|
|
||||||
|
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
|
||||||
|
if (booking.paymentCurrency === 'USD' && serviceType) {
|
||||||
|
const code = (serviceType.code ?? '').toUpperCase();
|
||||||
|
const hasForwarding =
|
||||||
|
serviceType.includesFirstMile ||
|
||||||
|
serviceType.includesLastMile ||
|
||||||
|
code.includes('FORWARD') ||
|
||||||
|
code.includes('Y');
|
||||||
|
const railOnly = code.includes('RAIL') && !hasForwarding;
|
||||||
|
|
||||||
|
if (hasForwarding) score += 1000;
|
||||||
|
else if (railOnly || code.includes('X')) score += 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeBaseRailLines(
|
||||||
|
booking: Booking,
|
||||||
|
evalInput: BookingEvaluationInput,
|
||||||
|
): Promise<PriceLineItemDto[]> {
|
||||||
|
const liveRates = await this.ratesService.findLiveRates();
|
||||||
|
const currency = booking.paymentCurrency;
|
||||||
|
const isBulk = booking.freightType === 'BULK';
|
||||||
|
console.log('liveRates----', liveRates);
|
||||||
|
const rateType =
|
||||||
|
booking.tradeDirection === 'IMPORT'
|
||||||
|
? isBulk
|
||||||
|
? 'BULK_IMPORT'
|
||||||
|
: 'CONTAINER_IMPORT'
|
||||||
|
: booking.tradeDirection === 'EXPORT'
|
||||||
|
? isBulk
|
||||||
|
? 'BULK_EXPORT'
|
||||||
|
: 'CONTAINER_EXPORT'
|
||||||
|
: 'INTERCITY_CONTAINER';
|
||||||
|
|
||||||
|
|
||||||
|
console.log('rateType----', rateType);
|
||||||
|
|
||||||
|
const lines: PriceLineItemDto[] = [];
|
||||||
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
|
|
||||||
|
for (const container of evalInput.containers) {
|
||||||
|
console.log('container----', container);
|
||||||
|
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
|
||||||
|
console.log('rate----', rate);
|
||||||
|
if (!rate) continue;
|
||||||
|
|
||||||
|
const amount = this.amountForRate(rate, container.quantity, wagonCount);
|
||||||
|
lines.push({
|
||||||
|
code: rateType,
|
||||||
|
description: `Base rail (${rateType})`,
|
||||||
|
amount,
|
||||||
|
currency: rate.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
const fallback = liveRates.find(
|
||||||
|
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
|
||||||
|
);
|
||||||
|
if (fallback) {
|
||||||
|
const amount = this.amountForRate(fallback, 1, wagonCount);
|
||||||
|
lines.push({
|
||||||
|
code: rateType,
|
||||||
|
description: `Base rail (${rateType})`,
|
||||||
|
amount,
|
||||||
|
currency: fallback.currency,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRate(
|
||||||
|
rates: Rate[],
|
||||||
|
rateType: string,
|
||||||
|
containerTypeId: string,
|
||||||
|
currency: string,
|
||||||
|
): Rate | undefined {
|
||||||
|
return (
|
||||||
|
rates.find(
|
||||||
|
(r) =>
|
||||||
|
r.rateType === rateType &&
|
||||||
|
r.currency === currency &&
|
||||||
|
r.containerTypeId === containerTypeId,
|
||||||
|
) ??
|
||||||
|
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
|
||||||
|
const value = Number(rate.rateValue);
|
||||||
|
switch (rate.rateUnit) {
|
||||||
|
case 'PER_CONTAINER':
|
||||||
|
return value * quantity;
|
||||||
|
case 'PER_WAGON':
|
||||||
|
return value * wagonCount;
|
||||||
|
case 'PER_TON':
|
||||||
|
return value * quantity;
|
||||||
|
case 'FLAT':
|
||||||
|
return value;
|
||||||
|
default:
|
||||||
|
return value * quantity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async persistPriceRun(
|
||||||
|
bookingId: string,
|
||||||
|
modifiers: AppliedCargoModifier[],
|
||||||
|
_total: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.bookingsRepository.clearPricingArtifacts(bookingId);
|
||||||
|
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||||
|
|
||||||
|
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
|
||||||
|
const rows = modifiers
|
||||||
|
.map((m) => {
|
||||||
|
const snapshotId = snapshotByRateId.get(m.rateId);
|
||||||
|
if (!snapshotId) return null;
|
||||||
|
return {
|
||||||
|
bookingId,
|
||||||
|
surchargeTypeId: m.surchargeTypeId,
|
||||||
|
triggerValue: m.triggerValue,
|
||||||
|
calculatedAmount: m.calculatedAmount,
|
||||||
|
rateSnapshotId: snapshotId,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
await this.bookingsRepository.createCargoModifiers(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
|
||||||
|
export function assertBookingStatus(booking: Booking, allowed: string[]): void {
|
||||||
|
if (!allowed.includes(booking.status)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
|
import { BookingsRepository } from './bookings.repository';
|
||||||
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BookingTransitionService {
|
||||||
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
|
private readonly pricingService: BookingPricingService,
|
||||||
|
private readonly contractService: BookingContractService,
|
||||||
|
@Inject(forwardRef(() => BookingsService))
|
||||||
|
private readonly bookingsService: BookingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async submit(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
|
||||||
|
|
||||||
|
if (Number(booking.totalAmount) <= 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Generate a price before submitting (POST /bookings/:id/generate-price)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
|
||||||
|
await this.ruleEngineService.snapshotLiveRates(bookingId);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'SUBMITTED',
|
||||||
|
priorityScore,
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestChanges(
|
||||||
|
bookingId: string,
|
||||||
|
note: string,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
note,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CHANGES_REQUESTED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['SUBMITTED']);
|
||||||
|
|
||||||
|
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
|
||||||
|
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||||
|
cargoTypeId: booking.cargoTypeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'PENDING_APPROVAL',
|
||||||
|
approvedByStaffId: actorId,
|
||||||
|
approvedByStaffAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async staffReject(
|
||||||
|
bookingId: string,
|
||||||
|
reason: string,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
reason,
|
||||||
|
'REJECTION',
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'REJECTED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveStep(
|
||||||
|
bookingId: string,
|
||||||
|
stepId: string,
|
||||||
|
actorId: string,
|
||||||
|
requiredRole: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, [
|
||||||
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const step = await this.bookingsRepository.findApprovalStepById(
|
||||||
|
bookingId,
|
||||||
|
stepId,
|
||||||
|
);
|
||||||
|
if (!step || step.status !== 'PENDING') {
|
||||||
|
throw new BadRequestException('Approval step not found or already actioned');
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
|
||||||
|
if (!next || next.id !== step.id) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Approval steps must be completed in order',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.requiredRole !== requiredRole) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocksRole = step.approvalRule?.blocksRole;
|
||||||
|
if (blocksRole && blocksRole === requiredRole) {
|
||||||
|
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (requiredRole === 'LINE_STAFF') {
|
||||||
|
updates.status = 'APPROVED_PENDING_SIGNATURE';
|
||||||
|
updates.approvedByStaffId = actorId;
|
||||||
|
updates.approvedByStaffAt = now;
|
||||||
|
} else if (requiredRole === 'DIRECTOR') {
|
||||||
|
updates.signedByDirectorId = actorId;
|
||||||
|
updates.signedByDirectorAt = now;
|
||||||
|
} else if (requiredRole === 'CEO') {
|
||||||
|
updates.signedByCeoId = actorId;
|
||||||
|
updates.signedByCeoAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
|
||||||
|
if (allDone) {
|
||||||
|
updates.status = 'APPROVED';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.bookingsService.findById(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectStep(
|
||||||
|
bookingId: string,
|
||||||
|
stepId: string,
|
||||||
|
actorId: string,
|
||||||
|
reason: string,
|
||||||
|
): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
|
||||||
|
|
||||||
|
const step = await this.bookingsRepository.findApprovalStepById(
|
||||||
|
bookingId,
|
||||||
|
stepId,
|
||||||
|
);
|
||||||
|
if (!step) throw new BadRequestException('Approval step not found');
|
||||||
|
|
||||||
|
await this.bookingsRepository.completeApprovalStep(
|
||||||
|
step.id,
|
||||||
|
actorId,
|
||||||
|
'REJECTED',
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
reason,
|
||||||
|
'REJECTION',
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'REJECTED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async customerSign(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['CONTRACT_READY']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'SIGNED_CUSTOMER',
|
||||||
|
customerSignedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
fullyExecutedAt: new Date(),
|
||||||
|
marketingApprovedById: actorId,
|
||||||
|
marketingApprovedAt: new Date(),
|
||||||
|
lockedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startTransit(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['PAID']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'IN_TRANSIT',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(bookingId: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, ['IN_TRANSIT']);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'COMPLETED',
|
||||||
|
endDate: new Date(),
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||||
|
const booking = await this.bookingsService.findById(bookingId);
|
||||||
|
assertBookingStatus(booking, [
|
||||||
|
'DRAFT',
|
||||||
|
'SUBMITTED',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
'PENDING_APPROVAL',
|
||||||
|
'CONTRACT_READY',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await this.bookingsRepository.createReviewNote(
|
||||||
|
bookingId,
|
||||||
|
reason,
|
||||||
|
'REJECTION',
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await this.bookingsRepository.update(bookingId, {
|
||||||
|
status: 'CANCELLED',
|
||||||
|
} as never);
|
||||||
|
return this.bookingsService.findById(updated!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async enrichBookingResponse(booking: Booking): Promise<Booking & {
|
||||||
|
latestChangeRequestNote?: string | null;
|
||||||
|
contractSummary?: string | null;
|
||||||
|
}> {
|
||||||
|
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||||
|
booking.id,
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
);
|
||||||
|
const summary =
|
||||||
|
booking.contractSummary ??
|
||||||
|
this.contractService.buildContractSummary(booking);
|
||||||
|
return {
|
||||||
|
...booking,
|
||||||
|
latestChangeRequestNote: note?.note ?? null,
|
||||||
|
contractSummary: summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Header,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -10,10 +11,15 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Request,
|
Request,
|
||||||
|
Res,
|
||||||
|
StreamableFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from '@nestjs/common';
|
||||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiBody,
|
ApiBody,
|
||||||
@@ -21,181 +27,405 @@ import {
|
|||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from "@nestjs/swagger";
|
} from '@nestjs/swagger';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
|
||||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
import { BookingContractService } from './booking-contract.service';
|
||||||
import { BookingsService } from "./bookings.service";
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
import { BookingsService } from './bookings.service';
|
||||||
import { UpdateStatusDto } from "./dto/update-status.dto";
|
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
|
||||||
|
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
|
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
|
||||||
|
import {
|
||||||
|
ApproveStepDto,
|
||||||
|
CancelBookingDto,
|
||||||
|
RejectStepDto,
|
||||||
|
RequestChangesDto,
|
||||||
|
StaffRejectDto,
|
||||||
|
} from './dto/request-changes.dto';
|
||||||
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from '../../common/resolve-auth-user-id';
|
||||||
|
|
||||||
@ApiTags("bookings")
|
@ApiTags('bookings')
|
||||||
@Controller("bookings")
|
@Controller('bookings')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BookingsController {
|
export class BookingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookingsService: BookingsService,
|
private readonly bookingsService: BookingsService,
|
||||||
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
private readonly bookingReferenceDataService: BookingReferenceDataService,
|
||||||
|
private readonly pricingService: BookingPricingService,
|
||||||
|
private readonly transitionService: BookingTransitionService,
|
||||||
|
private readonly contractService: BookingContractService,
|
||||||
|
private readonly paymentService: BookingPaymentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
|
||||||
@Post()
|
@Post()
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||||
summary: "Create a new freight booking",
|
@ApiBody({ type: CreateBookingDto })
|
||||||
description:
|
|
||||||
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
|
|
||||||
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
|
|
||||||
})
|
|
||||||
@ApiBody({
|
|
||||||
description:
|
|
||||||
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
|
|
||||||
"Each uploaded file is saved as a row in the files table (resource=bookings).",
|
|
||||||
type: CreateBookingDto,
|
|
||||||
})
|
|
||||||
create(
|
create(
|
||||||
@Body() dto: CreateBookingDto,
|
@Body() dto: CreateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
@Request() req: any,
|
@Request() req: { user?: { id?: string; sub?: string } },
|
||||||
) {
|
) {
|
||||||
console.log(
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
"[BookingsController] Files received:",
|
|
||||||
files?.length,
|
|
||||||
files?.map((f) => ({
|
|
||||||
fieldname: f.fieldname,
|
|
||||||
originalname: f.originalname,
|
|
||||||
size: f.size,
|
|
||||||
mimetype: f.mimetype,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const userId: string | undefined = req.user?.id ?? req.user?.sub;
|
|
||||||
return this.bookingsService.create(dto, files ?? [], userId);
|
return this.bookingsService.create(dto, files ?? [], userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
|
@Patch(':id')
|
||||||
@Patch(":id")
|
|
||||||
@UseInterceptors(AnyFilesInterceptor())
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
@ApiConsumes("multipart/form-data")
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Update a draft booking",
|
summary: 'Update booking',
|
||||||
description:
|
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||||
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
|
||||||
})
|
})
|
||||||
@ApiBody({ type: UpdateBookingDto })
|
@ApiBody({ type: UpdateBookingDto })
|
||||||
update(
|
update(
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@Body() dto: UpdateBookingDto,
|
@Body() dto: UpdateBookingDto,
|
||||||
@UploadedFiles() files: Express.Multer.File[],
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
) {
|
) {
|
||||||
return this.bookingsService.update(id, dto, files ?? []);
|
return this.bookingsService.update(id, dto, files ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. List bookings (paginated + filtered) ───────────────────────────
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||||
summary: "List freight bookings (paginated)",
|
|
||||||
description:
|
|
||||||
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
|
|
||||||
"paymentCurrency, allowConsolidation, consolidationPaired. " +
|
|
||||||
"Sort by createdAt or priorityScore.",
|
|
||||||
})
|
|
||||||
findAll(@Query() filter: FilterBookingDto) {
|
findAll(@Query() filter: FilterBookingDto) {
|
||||||
return this.bookingsService.findAll(filter);
|
return this.bookingsService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Booking form catalog (must be before :id) ─────────────────────────
|
@Get('queues/:queue')
|
||||||
@Get("reference-data")
|
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Booking form catalog",
|
summary: 'List bookings for a dashboard queue',
|
||||||
description:
|
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||||
"Returns yards, container types (grouped by size), service types, shipping lines, " +
|
|
||||||
"and hierarchical cargo types for the booking UI in a single payload.",
|
|
||||||
})
|
})
|
||||||
|
findQueue(
|
||||||
|
@Param('queue') queue: string,
|
||||||
|
@Query() filter: FilterBookingDto,
|
||||||
|
@Query('excludeBulk') excludeBulk?: string,
|
||||||
|
) {
|
||||||
|
return this.bookingsService.findQueue(queue, filter, {
|
||||||
|
excludeBulk: excludeBulk === 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reference-data')
|
||||||
|
@ApiOperation({ summary: 'Booking form catalog' })
|
||||||
@ApiOkResponse({ type: BookingReferenceDataDto })
|
@ApiOkResponse({ type: BookingReferenceDataDto })
|
||||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||||
return this.bookingReferenceDataService.getReferenceData();
|
return this.bookingReferenceDataService.getReferenceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
@Get('by-reference/:reference')
|
||||||
@Get("by-reference/:reference")
|
@ApiOperation({ summary: 'Get booking by reference' })
|
||||||
@ApiOperation({
|
async findByReference(@Param('reference') reference: string) {
|
||||||
summary: "Get a freight booking by reference number",
|
const booking = await this.bookingsService.findByReference(reference);
|
||||||
description: "Lookup booking by its human-readable reference string.",
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
})
|
|
||||||
findByReference(@Param("reference") reference: string) {
|
|
||||||
return this.bookingsService.findByReference(reference);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Get single booking by ID ───────────────────────────────────────
|
@Get(':id')
|
||||||
@Get(":id")
|
@ApiOperation({ summary: 'Get booking by ID' })
|
||||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
const booking = await this.bookingsService.findById(id);
|
||||||
return this.bookingsService.findById(id);
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
|
@Delete(':id')
|
||||||
@Delete(":id")
|
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
@ApiOperation({
|
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||||
summary: "Soft-delete a freight booking",
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
description: "Only DRAFT bookings can be deleted.",
|
|
||||||
})
|
|
||||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.remove(id);
|
return this.bookingsService.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 7. Unified status transition ──────────────────────────────────────
|
@Post(':id/generate-price')
|
||||||
@Patch(":id/status")
|
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||||
@ApiOperation({
|
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||||
summary: "Transition booking status",
|
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
description:
|
return this.pricingService.generatePrice(id);
|
||||||
"Unified endpoint for all status transitions. Actions: " +
|
|
||||||
"SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
|
|
||||||
"Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
|
|
||||||
"Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
|
|
||||||
})
|
|
||||||
updateStatus(
|
|
||||||
@Param("id", ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: UpdateStatusDto,
|
|
||||||
) {
|
|
||||||
return this.bookingsService.updateStatus(id, dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 8. Request or auto-pair consolidation ─────────────────────────────
|
@Post(':id/submit')
|
||||||
@Post(":id/consolidation")
|
@ApiOperation({ summary: 'Customer submit booking' })
|
||||||
|
async submit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.submit(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/request-changes')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Staff return booking for customer updates' })
|
||||||
|
async requestChanges(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RequestChangesDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.requestChanges(
|
||||||
|
id,
|
||||||
|
dto.note,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/accept')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||||
|
async acceptIntake(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.acceptIntake(
|
||||||
|
id,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff/reject')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Staff final reject' })
|
||||||
|
async staffReject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: StaffRejectDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.staffReject(
|
||||||
|
id,
|
||||||
|
dto.reason,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approval-steps/:stepId/approve')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Approve one approval step in sequence' })
|
||||||
|
async approveStep(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
|
@Body() dto: ApproveStepDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.approveStep(
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.requiredRole,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approval-steps/:stepId/reject')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Reject at approval step' })
|
||||||
|
async rejectStep(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('stepId', ParseUUIDPipe) stepId: string,
|
||||||
|
@Body() dto: RejectStepDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.rejectStep(
|
||||||
|
id,
|
||||||
|
stepId,
|
||||||
|
resolveAuthUserId(user),
|
||||||
|
dto.reason,
|
||||||
|
);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/generate')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({ summary: 'Generate contract PDF from template' })
|
||||||
|
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.contractService.generateContract(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract/view')
|
||||||
|
@ApiOkResponse({ type: ContractViewDto })
|
||||||
|
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
|
||||||
|
getContractView(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.contractService.getContractView(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract/document')
|
||||||
|
@ApiOperation({ summary: 'Download contract PDF' })
|
||||||
|
async downloadContractDocument(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { stream, record } = await this.contractService.streamContract(id);
|
||||||
|
res.set({
|
||||||
|
'Content-Type': record.mimeType ?? 'application/pdf',
|
||||||
|
'Content-Disposition': `attachment; filename="${record.name}"`,
|
||||||
|
});
|
||||||
|
return new StreamableFile(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract')
|
||||||
|
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||||
|
async downloadContract(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
return this.downloadContractDocument(id, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/contract/sign')
|
||||||
|
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
|
||||||
|
async signContract(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
|
) {
|
||||||
|
const userId = req.user?.id ?? req.user?.sub;
|
||||||
|
const booking = await this.contractService.signContract(id, dto, {
|
||||||
|
signerUserId: userId,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/contract/signatures')
|
||||||
|
@ApiOperation({ summary: 'List contract signatures' })
|
||||||
|
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.contractService.getSignatures(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/summary')
|
||||||
|
@ApiOperation({ summary: 'Contract summary string for dashboard' })
|
||||||
|
getSummary(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.contractService.getSummary(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer/sign')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Request freight consolidation",
|
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||||
description:
|
|
||||||
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
|
|
||||||
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
|
|
||||||
})
|
})
|
||||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
async customerSign(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
|
||||||
|
) {
|
||||||
|
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
|
||||||
|
const booking = await this.contractService.signContract(id, payload, {
|
||||||
|
signerUserId: req.user?.id ?? req.user?.sub,
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/marketing/approve')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||||
|
})
|
||||||
|
async marketingApprove(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: SignContractDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
@Request() req: { ip?: string },
|
||||||
|
) {
|
||||||
|
const payload: SignContractDto = {
|
||||||
|
...dto,
|
||||||
|
role: 'STAFF',
|
||||||
|
};
|
||||||
|
const booking = await this.contractService.signContract(id, payload, {
|
||||||
|
signerUserId: resolveAuthUserId(user),
|
||||||
|
ipAddress: req.ip,
|
||||||
|
});
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/pnr')
|
||||||
|
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
|
||||||
|
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.paymentService.generatePnr(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/proof')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload USD payment proof' })
|
||||||
|
async submitPaymentProof(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
const file = files?.[0];
|
||||||
|
const booking = await this.paymentService.submitPaymentProof(id, file);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/payment/request-letter')
|
||||||
|
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
|
||||||
|
@Header('Content-Type', 'text/plain')
|
||||||
|
async paymentRequestLetter(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Res({ passthrough: true }) res: Response,
|
||||||
|
) {
|
||||||
|
const { buffer, filename } =
|
||||||
|
await this.paymentService.getPaymentRequestLetter(id);
|
||||||
|
res.set('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
return new StreamableFile(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/payment/verify')
|
||||||
|
@ApiOperation({ summary: 'Finance verify USD payment' })
|
||||||
|
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.paymentService.verifyPayment(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/operations/start-transit')
|
||||||
|
@ApiOperation({ summary: 'Mark in transit' })
|
||||||
|
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.startTransit(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/operations/complete')
|
||||||
|
@ApiOperation({ summary: 'Mark completed' })
|
||||||
|
async complete(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
const booking = await this.transitionService.complete(id);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@ApiOperation({ summary: 'Cancel booking' })
|
||||||
|
async cancel(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: CancelBookingDto,
|
||||||
|
) {
|
||||||
|
const booking = await this.transitionService.cancel(id, dto.reason);
|
||||||
|
return this.transitionService.enrichBookingResponse(booking);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/consolidation')
|
||||||
|
@ApiOperation({ summary: 'Request freight consolidation' })
|
||||||
|
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.bookingsService.requestConsolidation(id);
|
return this.bookingsService.requestConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Remove consolidation pairing ───────────────────────────────────
|
@Delete(':id/consolidation')
|
||||||
@Delete(":id/consolidation")
|
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||||
@ApiOperation({
|
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
summary: "Remove consolidation pairing",
|
|
||||||
description:
|
|
||||||
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
|
||||||
})
|
|
||||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.removeConsolidation(id);
|
return this.bookingsService.removeConsolidation(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Get consolidation details ─────────────────────────────────────
|
@Get(':id/consolidation')
|
||||||
@Get(":id/consolidation")
|
@ApiOperation({ summary: 'Get consolidation details' })
|
||||||
@ApiOperation({
|
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
summary: "Get consolidation details",
|
|
||||||
description:
|
|
||||||
"Returns partner booking details and split billing information.",
|
|
||||||
})
|
|
||||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
|
||||||
return this.bookingsService.getConsolidationDetails(id);
|
return this.bookingsService.getConsolidationDetails(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { CustomersModule } from '../customers/customers.module';
|
// import { CustomersModule } from '../customers/customers.module';
|
||||||
|
import { CompaniesModule } from '../companies/companies.module';
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { MinioModule } from '../minio/minio.module';
|
import { MinioModule } from '../minio/minio.module';
|
||||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||||
|
import { BookingContractService } from './booking-contract.service';
|
||||||
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
|
import { BookingPricingService } from './booking-pricing.service';
|
||||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||||
|
import { BookingTransitionService } from './booking-transition.service';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
import { PaymentsWebhookController } from './payments-webhook.controller';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
|
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
|
||||||
|
import { ContractRendererService } from '../../contracts/contract-renderer.service';
|
||||||
|
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
|
||||||
|
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -24,18 +37,30 @@ import { Booking } from './entities/booking.entity';
|
|||||||
BookingCargoModifier,
|
BookingCargoModifier,
|
||||||
BookingApprovalStep,
|
BookingApprovalStep,
|
||||||
BookingRateSnapshot,
|
BookingRateSnapshot,
|
||||||
|
BookingReviewNote,
|
||||||
|
BookingContractSignature,
|
||||||
]),
|
]),
|
||||||
FilesModule,
|
FilesModule,
|
||||||
MinioModule,
|
MinioModule,
|
||||||
CustomersModule,
|
CompaniesModule,
|
||||||
|
// CustomersModule,
|
||||||
RuleEngineModule,
|
RuleEngineModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController, PaymentsWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
ConsolidationService,
|
ConsolidationService,
|
||||||
BookingReferenceDataService,
|
BookingReferenceDataService,
|
||||||
|
BookingPricingService,
|
||||||
|
BookingTransitionService,
|
||||||
|
BookingContractService,
|
||||||
|
BookingPaymentService,
|
||||||
|
ContractTemplateResolver,
|
||||||
|
ContractViewModelBuilder,
|
||||||
|
ContractPricingScheduleBuilder,
|
||||||
|
ContractRendererService,
|
||||||
|
ContractPdfService,
|
||||||
],
|
],
|
||||||
exports: [BookingsService],
|
exports: [BookingsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { BaseRepository } from '@edr/api-common';
|
import { BaseRepository } from '@edr/api-common';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './entities/booking-container.entity';
|
import { BookingContainer } from './entities/booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||||
|
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import {
|
||||||
|
BookingContractSignature,
|
||||||
|
ContractSignerRole,
|
||||||
|
} from './entities/booking-contract-signature.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||||
|
|
||||||
@@ -56,7 +61,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.createQueryBuilder('booking')
|
.createQueryBuilder('booking')
|
||||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||||
.leftJoinAndSelect('booking.customer', 'customer')
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||||
.leftJoinAndSelect('booking.train', 'train')
|
.leftJoinAndSelect('booking.train', 'train')
|
||||||
.leftJoinAndSelect('booking.serviceType', 'st')
|
.leftJoinAndSelect('booking.serviceType', 'st')
|
||||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
@@ -66,6 +72,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||||
|
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||||
.where('booking.id = :id', { id })
|
.where('booking.id = :id', { id })
|
||||||
.leftJoinAndMapMany(
|
.leftJoinAndMapMany(
|
||||||
'booking.files',
|
'booking.files',
|
||||||
@@ -216,15 +223,35 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get pending approval step for a role. */
|
/** Lowest-order pending approval step (sequential enforcement). */
|
||||||
|
async findNextPendingApprovalStep(
|
||||||
|
bookingId: string,
|
||||||
|
): Promise<BookingApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
|
where: { bookingId, status: 'PENDING' },
|
||||||
|
order: { stepOrder: 'ASC' },
|
||||||
|
relations: ['approvalRule'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findApprovalStepById(
|
||||||
|
bookingId: string,
|
||||||
|
stepId: string,
|
||||||
|
): Promise<BookingApprovalStep | null> {
|
||||||
|
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||||
|
where: { bookingId, id: stepId },
|
||||||
|
relations: ['approvalRule'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get pending approval step for a role (must match next in sequence). */
|
||||||
async findPendingApprovalStep(
|
async findPendingApprovalStep(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
requiredRole: string,
|
requiredRole: string,
|
||||||
): Promise<BookingApprovalStep | null> {
|
): Promise<BookingApprovalStep | null> {
|
||||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||||
where: { bookingId, requiredRole, status: 'PENDING' },
|
if (!next || next.requiredRole !== requiredRole) return null;
|
||||||
order: { stepOrder: 'ASC' },
|
return next;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark an approval step complete. */
|
/** Mark an approval step complete. */
|
||||||
@@ -277,4 +304,121 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
where: { bookingId, rateId },
|
where: { bookingId, rateId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createReviewNote(
|
||||||
|
bookingId: string,
|
||||||
|
note: string,
|
||||||
|
type: ReviewNoteType,
|
||||||
|
authorId?: string,
|
||||||
|
): Promise<BookingReviewNote> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||||
|
return repo.save(
|
||||||
|
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLatestReviewNote(
|
||||||
|
bookingId: string,
|
||||||
|
type?: ReviewNoteType,
|
||||||
|
): Promise<BookingReviewNote | null> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingReviewNote);
|
||||||
|
return repo.findOne({
|
||||||
|
where: type ? { bookingId, type } : { bookingId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearPricingArtifacts(bookingId: string): Promise<void> {
|
||||||
|
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
|
||||||
|
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
|
||||||
|
return this.repository.findOne({ where: { pnrCode } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
|
||||||
|
async findQueue(options: {
|
||||||
|
status: string | string[];
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
excludeBulk?: boolean;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const page = options.page ?? 1;
|
||||||
|
const pageSize = options.pageSize ?? 20;
|
||||||
|
const statuses = Array.isArray(options.status) ? options.status : [options.status];
|
||||||
|
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||||
|
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||||
|
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||||
|
.where('booking.status IN (:...statuses)', { statuses });
|
||||||
|
|
||||||
|
if (options.excludeBulk) {
|
||||||
|
qb.andWhere("booking.freight_type = 'CONTAINER'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortField =
|
||||||
|
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
|
||||||
|
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
const [items, total] = await qb
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize)
|
||||||
|
.getManyAndCount();
|
||||||
|
|
||||||
|
return { items, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
order: Record<string, 'ASC' | 'DESC'>;
|
||||||
|
}): Promise<[Booking[], number]> {
|
||||||
|
return this.repository.findAndCount({
|
||||||
|
where,
|
||||||
|
skip: options.skip,
|
||||||
|
take: options.take,
|
||||||
|
order: options.order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
|
||||||
|
return this.dataSource.getRepository(BookingContractSignature).find({
|
||||||
|
where: { bookingId },
|
||||||
|
relations: ['signatureFile'],
|
||||||
|
order: { signedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findContractSignature(
|
||||||
|
bookingId: string,
|
||||||
|
role: ContractSignerRole,
|
||||||
|
): Promise<BookingContractSignature | null> {
|
||||||
|
return this.dataSource.getRepository(BookingContractSignature).findOne({
|
||||||
|
where: { bookingId, signerRole: role },
|
||||||
|
relations: ['signatureFile'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveContractSignature(
|
||||||
|
data: Partial<BookingContractSignature>,
|
||||||
|
): Promise<BookingContractSignature> {
|
||||||
|
const repo = this.dataSource.getRepository(BookingContractSignature);
|
||||||
|
const existing = await repo.findOne({
|
||||||
|
where: {
|
||||||
|
bookingId: data.bookingId!,
|
||||||
|
signerRole: data.signerRole!,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
Object.assign(existing, data);
|
||||||
|
return repo.save(existing);
|
||||||
|
}
|
||||||
|
return repo.save(repo.create(data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { IsNull, Not } from 'typeorm';
|
import { IsNull, Not } from 'typeorm';
|
||||||
|
|
||||||
import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
@@ -16,10 +17,11 @@ import {
|
|||||||
} from '../rule-engine/rule-engine.service';
|
} from '../rule-engine/rule-engine.service';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
|
import { assertFreightShape } from './booking-freight.util';
|
||||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import { UpdateStatusDto } from './dto/update-status.dto';
|
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
import { FileRecord } from '../files/entities/file.entity';
|
import { FileRecord } from '../files/entities/file.entity';
|
||||||
|
|
||||||
@@ -29,7 +31,8 @@ export class BookingsService {
|
|||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly filesService: FilesService,
|
private readonly filesService: FilesService,
|
||||||
private readonly minioService: MinioService,
|
private readonly minioService: MinioService,
|
||||||
private readonly customersService: CustomersService,
|
// private readonly customersService: CustomersService,
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
private readonly ruleEngineService: RuleEngineService,
|
private readonly ruleEngineService: RuleEngineService,
|
||||||
private readonly containerTypesService: ContainerTypesService,
|
private readonly containerTypesService: ContainerTypesService,
|
||||||
private readonly consolidationService: ConsolidationService,
|
private readonly consolidationService: ConsolidationService,
|
||||||
@@ -42,22 +45,23 @@ export class BookingsService {
|
|||||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build evaluation input from DTO containers. */
|
/** Build evaluation input from booking freight shape. */
|
||||||
private async buildEvalInput(
|
private async buildEvalInput(dto: {
|
||||||
dto: Pick<
|
freightType: FreightType;
|
||||||
CreateBookingDto,
|
cargoTypeId?: string | null;
|
||||||
| 'cargoTypeId'
|
serviceTypeId: string;
|
||||||
| 'serviceTypeId'
|
paymentCurrency: string;
|
||||||
| 'paymentCurrency'
|
tradeDirection: string;
|
||||||
| 'tradeDirection'
|
isHazardous?: boolean;
|
||||||
| 'isHazardous'
|
allowConsolidation?: boolean;
|
||||||
| 'allowConsolidation'
|
shippingLineId?: string | null;
|
||||||
| 'shippingLineId'
|
containers: CreateBookingContainerDto[];
|
||||||
| 'containers'
|
}): Promise<BookingEvaluationInput> {
|
||||||
>,
|
const containerLines =
|
||||||
): Promise<BookingEvaluationInput> {
|
dto.freightType === 'CONTAINER' ? dto.containers : [];
|
||||||
|
|
||||||
const containers = await Promise.all(
|
const containers = await Promise.all(
|
||||||
dto.containers.map(async (c) => {
|
containerLines.map(async (c) => {
|
||||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
||||||
return {
|
return {
|
||||||
@@ -69,13 +73,16 @@ export class BookingsService {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cargoTypeId: dto.cargoTypeId,
|
freightType: dto.freightType,
|
||||||
|
cargoTypeId: dto.cargoTypeId ?? null,
|
||||||
serviceTypeId: dto.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
isHazardous: dto.isHazardous ?? false,
|
||||||
allowConsolidation: dto.allowConsolidation,
|
allowConsolidation:
|
||||||
|
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
containers,
|
containers,
|
||||||
};
|
};
|
||||||
@@ -149,24 +156,52 @@ export class BookingsService {
|
|||||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
let customerId = dto.customerId;
|
// let customerId = dto.customerId;
|
||||||
if (!customerId) {
|
// if (!customerId) {
|
||||||
|
// if (!userId) {
|
||||||
|
// throw new BadRequestException(
|
||||||
|
// 'customerId is required or must be resolvable from auth token',
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// const customer = await this.customersService.findByUserId(userId);
|
||||||
|
// customerId = customer.id;
|
||||||
|
// }
|
||||||
|
|
||||||
|
let companyId = dto.companyId;
|
||||||
|
if (!companyId) {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'customerId is required or must be resolvable from auth token',
|
'companyId is required or must be resolvable from auth token',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const customer = await this.customersService.findByUserId(userId);
|
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||||
customerId = customer.id;
|
companyId = company.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reference = dto.reference || (await this.generateReference());
|
const reference = dto.reference || (await this.generateReference());
|
||||||
const allowConsolidation = await this.resolveConsolidation(
|
const containers = dto.containers ?? [];
|
||||||
dto.containers,
|
assertFreightShape({
|
||||||
dto.allowConsolidation,
|
freightType: dto.freightType,
|
||||||
);
|
cargoTypeId: dto.cargoTypeId,
|
||||||
|
containers,
|
||||||
|
});
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
|
const allowConsolidation =
|
||||||
|
dto.freightType === 'CONTAINER'
|
||||||
|
? await this.resolveConsolidation(containers, dto.allowConsolidation)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const evalInput = await this.buildEvalInput({
|
||||||
|
freightType: dto.freightType as FreightType,
|
||||||
|
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
|
||||||
|
serviceTypeId: dto.serviceTypeId,
|
||||||
|
paymentCurrency: dto.paymentCurrency,
|
||||||
|
tradeDirection: dto.tradeDirection,
|
||||||
|
isHazardous: dto.isHazardous,
|
||||||
|
allowConsolidation,
|
||||||
|
shippingLineId: dto.shippingLineId,
|
||||||
|
containers,
|
||||||
|
});
|
||||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||||
|
|
||||||
@@ -174,7 +209,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.create({
|
const booking = await this.bookingsRepository.create({
|
||||||
reference,
|
reference,
|
||||||
customerId,
|
companyId,
|
||||||
trainId: dto.trainId,
|
trainId: dto.trainId,
|
||||||
contractType: dto.contractType,
|
contractType: dto.contractType,
|
||||||
previousContractId: dto.previousContractId,
|
previousContractId: dto.previousContractId,
|
||||||
@@ -185,7 +220,8 @@ export class BookingsService {
|
|||||||
originYardId: dto.originYardId,
|
originYardId: dto.originYardId,
|
||||||
destinationYardId: dto.destinationYardId,
|
destinationYardId: dto.destinationYardId,
|
||||||
tradeDirection: dto.tradeDirection,
|
tradeDirection: dto.tradeDirection,
|
||||||
cargoTypeId: dto.cargoTypeId,
|
freightType: dto.freightType,
|
||||||
|
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||||
cargoFreeText: dto.cargoFreeText,
|
cargoFreeText: dto.cargoFreeText,
|
||||||
shippingLineId: dto.shippingLineId,
|
shippingLineId: dto.shippingLineId,
|
||||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||||
@@ -203,18 +239,19 @@ export class BookingsService {
|
|||||||
paymentStatus: 'PENDING',
|
paymentStatus: 'PENDING',
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.bookingsRepository.createContainers(
|
if (dto.freightType === 'CONTAINER') {
|
||||||
booking.id,
|
await this.bookingsRepository.createContainers(
|
||||||
dto.containers.map((c, i) => ({
|
booking.id,
|
||||||
containerTypeId: c.containerTypeId,
|
containers.map((c, i) => ({
|
||||||
quantity: c.quantity,
|
containerTypeId: c.containerTypeId,
|
||||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
quantity: c.quantity,
|
||||||
weightResult: ruleResult.containerWeightResults[i],
|
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||||
})),
|
weightResult: ruleResult.containerWeightResults[i],
|
||||||
);
|
})),
|
||||||
|
);
|
||||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -242,24 +279,51 @@ export class BookingsService {
|
|||||||
files: Express.Multer.File[],
|
files: Express.Multer.File[],
|
||||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||||
const existing = await this.findById(id);
|
const existing = await this.findById(id);
|
||||||
if (existing.status !== 'DRAFT') {
|
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||||
throw new BadRequestException('Only DRAFT bookings can be updated');
|
throw new BadRequestException(
|
||||||
|
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
|
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||||
containerTypeId: bc.containerTypeId,
|
let containers =
|
||||||
quantity: bc.quantity,
|
dto.containers ??
|
||||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
existing.bookingContainers?.map((bc) => ({
|
||||||
})) ?? [];
|
containerTypeId: bc.containerTypeId,
|
||||||
|
quantity: bc.quantity,
|
||||||
|
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||||
|
})) ??
|
||||||
|
[];
|
||||||
|
|
||||||
const allowConsolidation = await this.resolveConsolidation(
|
let cargoTypeId =
|
||||||
containers,
|
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
|
||||||
);
|
if (freightType === 'BULK') {
|
||||||
|
containers = [];
|
||||||
|
if (dto.containers !== undefined) {
|
||||||
|
await this.bookingsRepository.deleteContainers(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cargoTypeId = null;
|
||||||
|
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
|
||||||
|
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFreightShape({ freightType, cargoTypeId, containers });
|
||||||
|
|
||||||
|
const allowConsolidation =
|
||||||
|
freightType === 'CONTAINER'
|
||||||
|
? await this.resolveConsolidation(
|
||||||
|
containers,
|
||||||
|
dto.allowConsolidation ?? existing.allowConsolidation,
|
||||||
|
)
|
||||||
|
: false;
|
||||||
|
|
||||||
const evalInput = await this.buildEvalInput({
|
const evalInput = await this.buildEvalInput({
|
||||||
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
|
freightType,
|
||||||
|
cargoTypeId,
|
||||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||||
@@ -275,6 +339,8 @@ export class BookingsService {
|
|||||||
|
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
...dto,
|
...dto,
|
||||||
|
freightType,
|
||||||
|
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
priorityScore: ruleResult.priorityScore,
|
priorityScore: ruleResult.priorityScore,
|
||||||
};
|
};
|
||||||
@@ -285,7 +351,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
await this.bookingsRepository.update(id, updates);
|
await this.bookingsRepository.update(id, updates);
|
||||||
|
|
||||||
if (dto.containers) {
|
if (freightType === 'CONTAINER' && dto.containers) {
|
||||||
await this.bookingsRepository.deleteContainers(id);
|
await this.bookingsRepository.deleteContainers(id);
|
||||||
await this.bookingsRepository.createContainers(
|
await this.bookingsRepository.createContainers(
|
||||||
id,
|
id,
|
||||||
@@ -322,10 +388,12 @@ export class BookingsService {
|
|||||||
|
|
||||||
const where: Record<string, unknown> = {};
|
const where: Record<string, unknown> = {};
|
||||||
if (filter.status) where.status = filter.status;
|
if (filter.status) where.status = filter.status;
|
||||||
if (filter.customerId) where.customerId = filter.customerId;
|
// if (filter.customerId) where.customerId = filter.customerId;
|
||||||
|
if (filter.companyId) where.companyId = filter.companyId;
|
||||||
if (filter.contractType) where.contractType = filter.contractType;
|
if (filter.contractType) where.contractType = filter.contractType;
|
||||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||||
|
if (filter.freightType) where.freightType = filter.freightType;
|
||||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
||||||
if (filter.allowConsolidation !== undefined) {
|
if (filter.allowConsolidation !== undefined) {
|
||||||
@@ -345,6 +413,8 @@ export class BookingsService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
order: { [sortField]: sortDir },
|
order: { [sortField]: sortDir },
|
||||||
|
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
|
||||||
|
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
||||||
});
|
});
|
||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
@@ -390,201 +460,32 @@ export class BookingsService {
|
|||||||
await this.bookingsRepository.softDelete(id);
|
await this.bookingsRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Unified status transition handler. */
|
async findQueue(
|
||||||
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
|
queue: string,
|
||||||
const booking = await this.findById(id);
|
filter: FilterBookingDto,
|
||||||
const { action, actorId, reason, requiredRole } = dto;
|
options?: { excludeBulk?: boolean },
|
||||||
|
): Promise<{ items: Booking[]; total: number }> {
|
||||||
|
const statusMap: Record<string, string | string[]> = {
|
||||||
|
intake: 'SUBMITTED',
|
||||||
|
approval: 'PENDING_APPROVAL',
|
||||||
|
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
|
||||||
|
marketing: 'SIGNED_CUSTOMER',
|
||||||
|
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
|
};
|
||||||
|
|
||||||
switch (action) {
|
const status = statusMap[queue];
|
||||||
case 'SUBMIT':
|
if (!status) {
|
||||||
return this.handleSubmit(booking);
|
throw new BadRequestException(`Unknown queue: ${queue}`);
|
||||||
case 'SEND_QUOTATION':
|
|
||||||
return this.handleSendQuotation(booking);
|
|
||||||
case 'APPROVE_QUOTATION':
|
|
||||||
return this.handleApproveQuotation(booking);
|
|
||||||
case 'REJECT_QUOTATION':
|
|
||||||
return this.handleRejectQuotation(booking, reason);
|
|
||||||
case 'APPROVE_STEP':
|
|
||||||
return this.handleApproveStep(booking, actorId, requiredRole);
|
|
||||||
case 'APPROVE':
|
|
||||||
return this.handleFullyApproved(booking);
|
|
||||||
case 'CUSTOMER_SIGN':
|
|
||||||
return this.handleCustomerSign(booking);
|
|
||||||
case 'MARK_FULLY_EXECUTED':
|
|
||||||
return this.handleFullyExecuted(booking);
|
|
||||||
case 'MARK_PAID':
|
|
||||||
return this.handleMarkPaid(booking);
|
|
||||||
case 'START_TRANSIT':
|
|
||||||
return this.handleStartTransit(booking);
|
|
||||||
case 'COMPLETE':
|
|
||||||
return this.handleComplete(booking);
|
|
||||||
case 'REJECT':
|
|
||||||
return this.handleReject(booking, actorId, reason);
|
|
||||||
case 'CANCEL':
|
|
||||||
return this.handleCancel(booking, reason);
|
|
||||||
default:
|
|
||||||
throw new BadRequestException(`Unknown action: ${action}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
|
|
||||||
private async handleSubmit(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['DRAFT']);
|
|
||||||
|
|
||||||
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
|
|
||||||
await this.ruleEngineService.snapshotLiveRates(booking.id);
|
|
||||||
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
|
|
||||||
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'PENDING_APPROVAL',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSendQuotation(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['RFQ_SUBMITTED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_SENT',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['QUOTATION_SENT']);
|
|
||||||
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'QUOTATION_REJECTED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleApproveStep(
|
|
||||||
booking: Booking,
|
|
||||||
actorId?: string,
|
|
||||||
requiredRole?: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
if (!actorId || !requiredRole) {
|
|
||||||
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const step = await this.bookingsRepository.findPendingApprovalStep(
|
return this.bookingsRepository.findQueue({
|
||||||
booking.id,
|
status,
|
||||||
requiredRole,
|
page: filter.page,
|
||||||
);
|
pageSize: filter.pageSize,
|
||||||
if (!step) {
|
excludeBulk: options?.excludeBulk ?? queue === 'approval',
|
||||||
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
|
sortBy: filter.sortBy,
|
||||||
}
|
sortOrder: filter.sortOrder,
|
||||||
|
});
|
||||||
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
|
|
||||||
|
|
||||||
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
|
|
||||||
if (allDone) {
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.findById(booking.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFullyApproved(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'APPROVED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCustomerSign(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['APPROVED']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'SIGNED_CUSTOMER',
|
|
||||||
customerSignedAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'FULLY_EXECUTED',
|
|
||||||
fullyExecutedAt: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMarkPaid(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'PAID',
|
|
||||||
paymentStatus: 'PAID',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStartTransit(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PAID']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'IN_TRANSIT',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleComplete(booking: Booking): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['IN_TRANSIT']);
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'COMPLETED',
|
|
||||||
endDate: new Date(),
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleReject(
|
|
||||||
booking: Booking,
|
|
||||||
actorId?: string,
|
|
||||||
reason?: string,
|
|
||||||
): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
|
|
||||||
if (!actorId || !reason) {
|
|
||||||
throw new BadRequestException('actorId and reason are required for REJECT');
|
|
||||||
}
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
|
|
||||||
this.assertStatus(booking, [
|
|
||||||
'DRAFT',
|
|
||||||
'RFQ_SUBMITTED',
|
|
||||||
'QUOTATION_SENT',
|
|
||||||
'QUOTATION_APPROVED',
|
|
||||||
'PENDING_APPROVAL',
|
|
||||||
]);
|
|
||||||
if (!reason) throw new BadRequestException('reason is required for CANCEL');
|
|
||||||
const updated = await this.bookingsRepository.update(booking.id, {
|
|
||||||
status: 'CANCELLED',
|
|
||||||
} as never);
|
|
||||||
return updated!;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertStatus(booking: Booking, allowed: string[]): void {
|
|
||||||
if (!allowed.includes(booking.status)) {
|
|
||||||
throw new ConflictException(
|
|
||||||
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestConsolidation(id: string): Promise<{
|
async requestConsolidation(id: string): Promise<{
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class ContractSignatureDto {
|
||||||
|
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
|
||||||
|
role!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
signedAt!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
signatureImageUrl?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContractViewDto {
|
||||||
|
@ApiProperty()
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
reference!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
status!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
templateKey!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Full HTML document for in-browser display' })
|
||||||
|
html!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
canSignCustomer!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
canSignStaff!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
hasContractDocument!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [ContractSignatureDto] })
|
||||||
|
signatures!: ContractSignatureDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
pricingSchedule?: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -11,9 +12,12 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Min,
|
Min,
|
||||||
|
Validate,
|
||||||
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { BOOKING_STATUSES } from '../entities/booking.entity';
|
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||||
|
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||||
|
|
||||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||||
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
|
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
|
||||||
@@ -24,6 +28,7 @@ export {
|
|||||||
BOOKING_STATUSES,
|
BOOKING_STATUSES,
|
||||||
CONTRACT_TYPES,
|
CONTRACT_TYPES,
|
||||||
EQUIPMENT_RETURNS,
|
EQUIPMENT_RETURNS,
|
||||||
|
FREIGHT_TYPES,
|
||||||
TRADE_DIRECTIONS,
|
TRADE_DIRECTIONS,
|
||||||
PAYMENT_CURRENCIES,
|
PAYMENT_CURRENCIES,
|
||||||
};
|
};
|
||||||
@@ -47,16 +52,24 @@ export class CreateBookingContainerDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CreateBookingDto {
|
export class CreateBookingDto {
|
||||||
|
/** Class-level freight shape check (not a request field). */
|
||||||
|
@Validate(BookingFreightShapeConstraint)
|
||||||
|
freightShapeValidation?: boolean;
|
||||||
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
|
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||||
reference?: string;
|
reference?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
|
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsUUID()
|
||||||
|
// customerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
customerId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -107,9 +120,17 @@ export class CreateBookingDto {
|
|||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
tradeDirection!: string;
|
tradeDirection!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
|
@ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
|
||||||
|
@IsIn([...FREIGHT_TYPES])
|
||||||
|
freightType!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Required for BULK; must be omitted for CONTAINER',
|
||||||
|
})
|
||||||
|
@ValidateIf((o) => o.freightType === 'BULK')
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
cargoTypeId!: string;
|
cargoTypeId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ maxLength: 200 })
|
@ApiPropertyOptional({ maxLength: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -157,11 +178,16 @@ export class CreateBookingDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
financialTerms?: string;
|
financialTerms?: string;
|
||||||
|
|
||||||
@ApiProperty({ type: [CreateBookingContainerDto] })
|
@ApiPropertyOptional({
|
||||||
|
type: [CreateBookingContainerDto],
|
||||||
|
description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
|
||||||
|
})
|
||||||
|
@ValidateIf((o) => o.freightType === 'CONTAINER')
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CreateBookingContainerDto)
|
@Type(() => CreateBookingContainerDto)
|
||||||
containers!: CreateBookingContainerDto[];
|
containers?: CreateBookingContainerDto[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: false })
|
@ApiPropertyOptional({ default: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
|
import {
|
||||||
|
BOOKING_STATUSES,
|
||||||
|
FREIGHT_TYPES,
|
||||||
|
PAYMENT_CURRENCIES,
|
||||||
|
TRADE_DIRECTIONS,
|
||||||
|
} from './create-booking.dto';
|
||||||
|
|
||||||
export class FilterBookingDto {
|
export class FilterBookingDto {
|
||||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||||
@@ -9,10 +14,15 @@ export class FilterBookingDto {
|
|||||||
@IsIn([...BOOKING_STATUSES])
|
@IsIn([...BOOKING_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
// @ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsUUID()
|
||||||
|
// customerId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
customerId?: string;
|
companyId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -28,6 +38,11 @@ export class FilterBookingDto {
|
|||||||
@IsUUID()
|
@IsUUID()
|
||||||
cargoTypeId?: string;
|
cargoTypeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...FREIGHT_TYPES])
|
||||||
|
freightType?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class PriceLineItemDto {
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeneratePriceResponseDto {
|
||||||
|
@ApiProperty()
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalAmount!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
currency!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PriceLineItemDto] })
|
||||||
|
lineItems!: PriceLineItemDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
warnings!: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class RequestChangesDto {
|
||||||
|
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
note!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StaffRejectDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApproveStepDto {
|
||||||
|
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||||
|
@IsString()
|
||||||
|
requiredRole!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RejectStepDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CancelBookingDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
reason!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BankCallbackDto {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
pnrCode!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignContractDto {
|
||||||
|
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
|
||||||
|
@IsIn(['CUSTOMER', 'STAFF'])
|
||||||
|
role!: 'CUSTOMER' | 'STAFF';
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
signatureImageBase64!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
consentText?: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { PartialType } from "@nestjs/mapped-types";
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { Validate } from 'class-validator';
|
||||||
|
|
||||||
import { CreateBookingDto } from "./create-booking.dto";
|
import { CreateBookingDto } from './create-booking.dto';
|
||||||
|
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||||
|
|
||||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
|
export class UpdateBookingDto extends PartialType(CreateBookingDto) {
|
||||||
|
@Validate(BookingFreightShapeConstraint)
|
||||||
|
freightShapeValidation?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
|
||||||
|
|
||||||
const STATUS_ACTIONS = [
|
|
||||||
'SUBMIT',
|
|
||||||
'SEND_QUOTATION',
|
|
||||||
'APPROVE_QUOTATION',
|
|
||||||
'REJECT_QUOTATION',
|
|
||||||
'APPROVE_STEP',
|
|
||||||
'APPROVE',
|
|
||||||
'CUSTOMER_SIGN',
|
|
||||||
'MARK_FULLY_EXECUTED',
|
|
||||||
'MARK_PAID',
|
|
||||||
'START_TRANSIT',
|
|
||||||
'COMPLETE',
|
|
||||||
'REJECT',
|
|
||||||
'CANCEL',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export { STATUS_ACTIONS };
|
|
||||||
|
|
||||||
export class UpdateStatusDto {
|
|
||||||
@ApiProperty({ enum: STATUS_ACTIONS })
|
|
||||||
@IsIn([...STATUS_ACTIONS])
|
|
||||||
action!: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
actorId?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
requiredRole?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
|
||||||
|
|
||||||
|
export interface BookingFreightShapeInput {
|
||||||
|
freightType?: string;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
containers?: Array<{ containerTypeId?: string }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'BookingFreightShape', async: false })
|
||||||
|
export class BookingFreightShapeConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(_value: unknown, args: ValidationArguments): boolean {
|
||||||
|
const dto = args.object as BookingFreightShapeInput;
|
||||||
|
if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const containers = dto.containers ?? [];
|
||||||
|
const hasContainers = containers.length > 0;
|
||||||
|
const hasCargoType =
|
||||||
|
dto.cargoTypeId !== undefined &&
|
||||||
|
dto.cargoTypeId !== null &&
|
||||||
|
String(dto.cargoTypeId).trim() !== '';
|
||||||
|
|
||||||
|
if (dto.freightType === 'BULK') {
|
||||||
|
if (hasContainers) return false;
|
||||||
|
if (!hasCargoType) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.freightType === 'CONTAINER') {
|
||||||
|
if (hasCargoType) return false;
|
||||||
|
if (!hasContainers) return false;
|
||||||
|
return containers.every(
|
||||||
|
(c) =>
|
||||||
|
c.containerTypeId !== undefined &&
|
||||||
|
c.containerTypeId !== null &&
|
||||||
|
String(c.containerTypeId).trim() !== '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
const dto = args.object as BookingFreightShapeInput;
|
||||||
|
if (dto.freightType === 'BULK') {
|
||||||
|
return 'BULK freight requires cargoTypeId and must not include container lines';
|
||||||
|
}
|
||||||
|
if (dto.freightType === 'CONTAINER') {
|
||||||
|
return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId';
|
||||||
|
}
|
||||||
|
return 'Invalid freight type shape';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||||
|
import { FileRecord } from '../../files/entities/file.entity';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const;
|
||||||
|
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_contract_signatures' })
|
||||||
|
@Unique(['bookingId', 'signerRole'])
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class BookingContractSignature extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'signer_role', type: 'varchar', length: 20 })
|
||||||
|
signerRole!: ContractSignerRole;
|
||||||
|
|
||||||
|
@Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
|
||||||
|
signerUserId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
|
||||||
|
signerDisplayName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'signed_at', type: 'timestamptz' })
|
||||||
|
signedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
|
||||||
|
signatureFileId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => FileRecord, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'signature_file_id' })
|
||||||
|
signatureFile?: FileRecord | null;
|
||||||
|
|
||||||
|
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||||
|
consentText?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
|
||||||
|
ipAddress?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
|
||||||
|
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
||||||
|
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class BookingReviewNote extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'author_id', type: 'uuid', nullable: true })
|
||||||
|
authorId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'note', type: 'text' })
|
||||||
|
note!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 30 })
|
||||||
|
type!: ReviewNoteType;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
import { Customer } from '../../customers/entities/customer.entity';
|
// import { Customer } from '../../customers/entities/customer.entity';
|
||||||
|
import { Company } from '../../companies/entities/company.entity';
|
||||||
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
|
||||||
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
|
||||||
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
|
||||||
@@ -11,36 +12,68 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
|
|||||||
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
|
||||||
import { BookingContainer } from './booking-container.entity';
|
import { BookingContainer } from './booking-container.entity';
|
||||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||||
|
import { BookingReviewNote } from './booking-review-note.entity';
|
||||||
|
|
||||||
export const BOOKING_STATUSES = [
|
export const BOOKING_STATUSES = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'RFQ_SUBMITTED',
|
'SUBMITTED',
|
||||||
'QUOTATION_SENT',
|
'CHANGES_REQUESTED',
|
||||||
'QUOTATION_APPROVED',
|
|
||||||
'QUOTATION_REJECTED',
|
|
||||||
'PENDING_APPROVAL',
|
'PENDING_APPROVAL',
|
||||||
|
'APPROVED_PENDING_SIGNATURE',
|
||||||
'APPROVED',
|
'APPROVED',
|
||||||
|
'CONTRACT_READY',
|
||||||
'SIGNED_CUSTOMER',
|
'SIGNED_CUSTOMER',
|
||||||
'FULLY_EXECUTED',
|
'FULLY_EXECUTED',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_TRANSIT',
|
'IN_TRANSIT',
|
||||||
'COMPLETED',
|
'COMPLETED',
|
||||||
|
'REJECTED',
|
||||||
'CANCELLED',
|
'CANCELLED',
|
||||||
'PENDING_CONSOLIDATION',
|
'PENDING_CONSOLIDATION',
|
||||||
'CONSOLIDATED',
|
'CONSOLIDATED',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||||
|
|
||||||
|
export const PAYMENT_STATUSES = [
|
||||||
|
'PENDING',
|
||||||
|
'PNR_GENERATED',
|
||||||
|
'VERIFICATION_IN_PROGRESS',
|
||||||
|
'PAID',
|
||||||
|
'FAILED',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||||
|
|
||||||
|
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||||
|
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||||
|
|
||||||
|
/** Statuses where the customer may edit booking fields. */
|
||||||
|
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||||
|
'DRAFT',
|
||||||
|
'CHANGES_REQUESTED',
|
||||||
|
];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'bookings' })
|
@Entity({ schema: 'freight', name: 'bookings' })
|
||||||
export class Booking extends BaseEntity {
|
export class Booking extends BaseEntity {
|
||||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||||
reference!: string;
|
reference!: string;
|
||||||
|
|
||||||
@Column({ name: 'customer_id', type: 'uuid' })
|
// Legacy — superseded by companyId (column kept in DB)
|
||||||
customerId!: string;
|
// @Column({ name: 'customer_id', type: 'uuid' })
|
||||||
|
// customerId!: string;
|
||||||
|
// @ManyToOne(() => Customer)
|
||||||
|
// @JoinColumn({ name: 'customer_id' })
|
||||||
|
// customer?: Customer;
|
||||||
|
|
||||||
@ManyToOne(() => Customer)
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
@JoinColumn({ name: 'customer_id' })
|
companyId!: string;
|
||||||
customer?: Customer;
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company?: Company;
|
||||||
|
|
||||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||||
trainId?: string | null;
|
trainId?: string | null;
|
||||||
@@ -104,8 +137,11 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||||
tradeDirection!: string;
|
tradeDirection!: string;
|
||||||
|
|
||||||
@Column({ name: 'cargo_type_id', type: 'uuid' })
|
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||||
cargoTypeId!: string;
|
freightType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
|
||||||
@ManyToOne(() => CargoType)
|
@ManyToOne(() => CargoType)
|
||||||
@JoinColumn({ name: 'cargo_type_id' })
|
@JoinColumn({ name: 'cargo_type_id' })
|
||||||
@@ -169,6 +205,27 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||||
fullyExecutedAt?: Date | null;
|
fullyExecutedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
|
||||||
|
marketingApprovedById?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
|
||||||
|
marketingApprovedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contract_summary', type: 'text', nullable: true })
|
||||||
|
contractSummary?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true })
|
||||||
|
contractTemplateKey?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
|
||||||
|
contractGeneratedAt?: Date | null;
|
||||||
|
|
||||||
|
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
|
||||||
|
pricingBreakdown?: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
|
||||||
|
lockedAt?: Date | null;
|
||||||
|
|
||||||
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
@Column({ name: 'priority_score', type: 'int', default: 0 })
|
||||||
priorityScore!: number;
|
priorityScore!: number;
|
||||||
|
|
||||||
@@ -194,6 +251,9 @@ export class Booking extends BaseEntity {
|
|||||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||||
rateSnapshots?: BookingRateSnapshot[];
|
rateSnapshots?: BookingRateSnapshot[];
|
||||||
|
|
||||||
|
@OneToMany(() => BookingReviewNote, (n) => n.booking)
|
||||||
|
reviewNotes?: BookingReviewNote[];
|
||||||
|
|
||||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||||
createForeignKeyConstraints: false,
|
createForeignKeyConstraints: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { BookingPaymentService } from './booking-payment.service';
|
||||||
|
import { BankCallbackDto } from './dto/request-changes.dto';
|
||||||
|
|
||||||
|
@ApiTags('payments')
|
||||||
|
@Controller('webhooks/payments')
|
||||||
|
export class PaymentsWebhookController {
|
||||||
|
constructor(private readonly paymentService: BookingPaymentService) {}
|
||||||
|
|
||||||
|
@Post('bank')
|
||||||
|
@ApiOperation({ summary: 'Bank payment callback (stub)' })
|
||||||
|
bankCallback(@Body() dto: BankCallbackDto) {
|
||||||
|
return this.paymentService.handleBankCallback(dto.pnrCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
|
||||||
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||||
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { CompaniesService } from './companies.service';
|
||||||
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
|
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||||
|
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||||
|
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||||
|
import { ResponseCompanyDto } from './dto/response-company.dto';
|
||||||
|
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
|
||||||
|
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
|
||||||
|
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
|
||||||
|
interface CurrentIamUser {
|
||||||
|
id: string;
|
||||||
|
name?: { en: string; am: string };
|
||||||
|
email?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiTags('Companies')
|
||||||
|
@Controller('companies')
|
||||||
|
export class CompaniesController {
|
||||||
|
constructor(
|
||||||
|
private readonly companiesService: CompaniesService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('getInfo')
|
||||||
|
@ApiOperation({ summary: 'Get company info for the current user' })
|
||||||
|
async getInfo(@CurrentUser() user: CurrentIamUser): Promise<CompanyInfoResponseDto> {
|
||||||
|
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
|
||||||
|
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
|
||||||
|
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
|
||||||
|
return new ProfileResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('profile')
|
||||||
|
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
|
||||||
|
async updateProfile(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: UpdateProfileDto,
|
||||||
|
): Promise<ProfileResponseDto> {
|
||||||
|
return this.companiesService.updateProfile(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
|
||||||
|
async createWithProfile(
|
||||||
|
@CurrentUser() user: CurrentIamUser,
|
||||||
|
@Body() dto: CreateCompanyWithProfileDto,
|
||||||
|
): Promise<CompanyInfoResponseDto> {
|
||||||
|
const nameParts = (user.name?.en ?? '').split(' ');
|
||||||
|
const { profile, company } = await this.companiesService.createCompanyWithProfile(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
firstName: nameParts[0] || '',
|
||||||
|
lastName: nameParts.slice(-1)[0] || '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
phone: user.phoneNumber ?? '',
|
||||||
|
},
|
||||||
|
dto,
|
||||||
|
);
|
||||||
|
return new CompanyInfoResponseDto(profile, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' })
|
||||||
|
async create(@Body() dto: CreateCompanyDto): Promise<ResponseCompanyDto> {
|
||||||
|
const company = await this.companiesService.createCompany(dto);
|
||||||
|
return new ResponseCompanyDto(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: 'List all companies' })
|
||||||
|
async findAll(): Promise<ResponseCompanyDto[]> {
|
||||||
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
|
return companies.map((c) => new ResponseCompanyDto(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('type/:type')
|
||||||
|
@ApiOperation({ summary: 'Find companies by type' })
|
||||||
|
async findByType(@Param('type') type: string): Promise<ResponseCompanyDto[]> {
|
||||||
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
|
return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('search')
|
||||||
|
@ApiOperation({ summary: 'Search companies by name' })
|
||||||
|
async search(@Query('name') name: string): Promise<ResponseCompanyDto[]> {
|
||||||
|
const companies = await this.companiesService.findAllCompanies();
|
||||||
|
return companies
|
||||||
|
.filter((c) => c.name.toLowerCase().includes(name.toLowerCase()))
|
||||||
|
.map((c) => new ResponseCompanyDto(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get company by ID' })
|
||||||
|
async findById(@Param('id', ParseUUIDPipe) id: string): Promise<ResponseCompanyDto> {
|
||||||
|
const company = await this.companiesService.findCompanyById(id);
|
||||||
|
return new ResponseCompanyDto(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a company' })
|
||||||
|
async update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: UpdateCompanyDto,
|
||||||
|
): Promise<ResponseCompanyDto> {
|
||||||
|
const company = await this.companiesService.updateCompany(id, dto);
|
||||||
|
return new ResponseCompanyDto(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@ApiOperation({ summary: 'Soft-delete a company' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
await this.companiesService.deleteCompany(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':companyId/documents')
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
|
||||||
|
async uploadDocuments(
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
@UploadedFiles() files: Array<Express.Multer.File>,
|
||||||
|
) {
|
||||||
|
return this.filesService.uploadMany(companyId, 'companies', files);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':companyId/profiles')
|
||||||
|
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
|
||||||
|
async createProfile(
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
@Body() dto: CreateExternalProfileDto,
|
||||||
|
): Promise<ResponseExternalProfileDto> {
|
||||||
|
const profile = await this.companiesService.createProfile({ ...dto, companyId });
|
||||||
|
return new ResponseExternalProfileDto(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':companyId/profiles')
|
||||||
|
@ApiOperation({ summary: 'List profiles for a company' })
|
||||||
|
async listProfiles(
|
||||||
|
@Param('companyId', ParseUUIDPipe) companyId: string,
|
||||||
|
): Promise<ResponseExternalProfileDto[]> {
|
||||||
|
const profiles = await this.companiesService.findProfilesByCompany(companyId);
|
||||||
|
return profiles.map((p) => new ResponseExternalProfileDto(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile/user/:userId')
|
||||||
|
@ApiOperation({ summary: 'Get profile by IAM user ID' })
|
||||||
|
async findProfileByUser(
|
||||||
|
@Param('userId', ParseUUIDPipe) userId: string,
|
||||||
|
): Promise<ResponseExternalProfileDto> {
|
||||||
|
const profile = await this.companiesService.findProfileByUserId(userId);
|
||||||
|
return new ResponseExternalProfileDto(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ff-clients')
|
||||||
|
@ApiOperation({ summary: 'Link a forwarder to a client company' })
|
||||||
|
async createFFClient(@Body() dto: CreateFFClientDto): Promise<ResponseFFClientDto> {
|
||||||
|
const client = await this.companiesService.createFFClient(dto);
|
||||||
|
return new ResponseFFClientDto(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':forwarderCompanyId/clients')
|
||||||
|
@ApiOperation({ summary: 'List clients of a forwarder' })
|
||||||
|
async listFFClients(
|
||||||
|
@Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string,
|
||||||
|
): Promise<ResponseFFClientDto[]> {
|
||||||
|
const clients = await this.companiesService.findForwarderClients(forwarderCompanyId);
|
||||||
|
return clients.map((c) => new ResponseFFClientDto(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('ff-clients/:id')
|
||||||
|
@ApiOperation({ summary: 'Remove a forwarder-client relationship' })
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||||
|
await this.companiesService.deleteFFClient(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
|
import { CompaniesController } from './companies.controller';
|
||||||
|
import { CompaniesService } from './companies.service';
|
||||||
|
import { CompaniesRepository } from './companies.repository';
|
||||||
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { Company } from './entities/company.entity';
|
||||||
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
|
||||||
|
controllers: [CompaniesController],
|
||||||
|
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
|
||||||
|
exports: [CompaniesService],
|
||||||
|
})
|
||||||
|
export class CompaniesModule {}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { Company } from './entities/company.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompaniesRepository extends BaseRepository<Company> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Company)
|
||||||
|
repo: Repository<Company>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTin(tin: string): Promise<Company | null> {
|
||||||
|
return this.repository.findOne({ where: { tin } as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByType(type: string): Promise<Company[]> {
|
||||||
|
return this.repository.find({ where: { type } as any, order: { name: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByName(name: string): Promise<Company[]> {
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('company')
|
||||||
|
.where('company.name ILIKE :name', { name: `%${name}%` })
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async existsByTin(tin: string): Promise<boolean> {
|
||||||
|
const count = await this.repository.count({ where: { tin } as any });
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
194
apps/edr-freight-api/src/modules/companies/companies.service.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { CompaniesRepository } from './companies.repository';
|
||||||
|
import { ExternalProfileRepository } from './external-profile.repository';
|
||||||
|
import { FFClientRepository } from './ff-client.repository';
|
||||||
|
import { CreateCompanyDto } from './dto/create-company.dto';
|
||||||
|
import { UpdateCompanyDto } from './dto/update-company.dto';
|
||||||
|
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
|
||||||
|
import { CreateFFClientDto } from './dto/create-ff-client.dto';
|
||||||
|
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
|
||||||
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
|
import { ProfileResponseDto } from './dto/profile-response.dto';
|
||||||
|
import { Company } from './entities/company.entity';
|
||||||
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
|
||||||
|
export interface UserIdentity {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompaniesService {
|
||||||
|
constructor(
|
||||||
|
private readonly companiesRepo: CompaniesRepository,
|
||||||
|
private readonly profilesRepo: ExternalProfileRepository,
|
||||||
|
private readonly ffClientsRepo: FFClientRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async createCompany(dto: CreateCompanyDto): Promise<Company> {
|
||||||
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||||
|
}
|
||||||
|
return this.companiesRepo.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> {
|
||||||
|
if (dto.tin) {
|
||||||
|
const exists = await this.companiesRepo.existsByTin(dto.tin);
|
||||||
|
if (exists) {
|
||||||
|
throw new ConflictException(`Company with TIN ${dto.tin} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingProfile = await this.profilesRepo.findByEmail(identity.email);
|
||||||
|
if (existingProfile) {
|
||||||
|
throw new ConflictException(`Profile with email ${identity.email} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await this.companiesRepo.create({
|
||||||
|
name: dto.companyName,
|
||||||
|
type: dto.companyType,
|
||||||
|
tin: dto.tin ?? '',
|
||||||
|
vatNumber: dto.vatNumber ?? null,
|
||||||
|
businessLicense: dto.fanNumber ?? null,
|
||||||
|
fanNumber: dto.fanNumber ?? null,
|
||||||
|
country: dto.companyLocation ?? 'Ethiopia',
|
||||||
|
address: dto.companyAddress ?? null,
|
||||||
|
phone: dto.companyPhone ?? null,
|
||||||
|
email: dto.companyEmail ?? null,
|
||||||
|
attributes: dto.attributes ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = await this.profilesRepo.create({
|
||||||
|
userId: identity.userId,
|
||||||
|
companyId: company.id,
|
||||||
|
firstName: identity.firstName,
|
||||||
|
lastName: identity.lastName,
|
||||||
|
email: identity.email,
|
||||||
|
phone: identity.phone,
|
||||||
|
jobTitle: dto.jobTitle ?? null,
|
||||||
|
isPrimaryContact: dto.isPrimaryContact ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { company, profile };
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllCompanies(): Promise<Company[]> {
|
||||||
|
return this.companiesRepo.findAll({ order: { name: 'ASC' as any } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCompanyById(id: string): Promise<Company> {
|
||||||
|
const company = await this.companiesRepo.findById(id);
|
||||||
|
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
|
||||||
|
const company = profile.company;
|
||||||
|
if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`);
|
||||||
|
|
||||||
|
return { profile, company };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCompany(id: string, dto: UpdateCompanyDto): Promise<Company> {
|
||||||
|
await this.findCompanyById(id);
|
||||||
|
const updated = await this.companiesRepo.update(id, dto);
|
||||||
|
if (!updated) throw new NotFoundException(`Company ${id} not found`);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
|
||||||
|
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||||
|
|
||||||
|
const companyUpdates: Record<string, any> = {};
|
||||||
|
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
|
||||||
|
|
||||||
|
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
||||||
|
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
|
||||||
|
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
|
||||||
|
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
|
||||||
|
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
|
||||||
|
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
|
||||||
|
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
|
||||||
|
if (dto.fanNumber !== undefined) {
|
||||||
|
companyUpdates.businessLicense = dto.fanNumber;
|
||||||
|
companyUpdates.fanNumber = dto.fanNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
|
||||||
|
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
|
||||||
|
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
|
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
|
||||||
|
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
|
||||||
|
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
|
||||||
|
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
|
||||||
|
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
||||||
|
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
|
||||||
|
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
|
companyUpdates.attributes = attrUpdates;
|
||||||
|
|
||||||
|
const updated = await this.companiesRepo.update(company.id, companyUpdates);
|
||||||
|
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
|
||||||
|
return new ProfileResponseDto(profile, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCompany(id: string): Promise<void> {
|
||||||
|
await this.findCompanyById(id);
|
||||||
|
await this.companiesRepo.softDelete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createProfile(dto: CreateExternalProfileDto): Promise<ExternalProfile> {
|
||||||
|
await this.findCompanyById(dto.companyId);
|
||||||
|
|
||||||
|
const existing = await this.profilesRepo.findByEmail(dto.email);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Profile with email ${dto.email} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.profilesRepo.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findProfileByUserId(userId: string): Promise<ExternalProfile> {
|
||||||
|
const profile = await this.profilesRepo.findByUserId(userId);
|
||||||
|
if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findProfilesByCompany(companyId: string): Promise<ExternalProfile[]> {
|
||||||
|
return this.profilesRepo.findByCompanyId(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createFFClient(dto: CreateFFClientDto): Promise<FFClient> {
|
||||||
|
await this.findCompanyById(dto.forwarderCompanyId);
|
||||||
|
await this.findCompanyById(dto.clientCompanyId);
|
||||||
|
|
||||||
|
const existing = await this.ffClientsRepo.findRelationship(
|
||||||
|
dto.forwarderCompanyId,
|
||||||
|
dto.clientCompanyId,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException('This forwarder-client relationship already exists');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.ffClientsRepo.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findForwarderClients(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||||
|
return this.ffClientsRepo.findByForwarder(forwarderCompanyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFFClient(id: string): Promise<void> {
|
||||||
|
const client = await this.ffClientsRepo.findById(id);
|
||||||
|
if (!client) throw new NotFoundException(`FFClient ${id} not found`);
|
||||||
|
await this.ffClientsRepo.softDelete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Company } from '../entities/company.entity';
|
||||||
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
import { ResponseCompanyDto } from './response-company.dto';
|
||||||
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
|
export class CompanyInfoResponseDto {
|
||||||
|
profile: ResponseExternalProfileDto;
|
||||||
|
company: ResponseCompanyDto;
|
||||||
|
|
||||||
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
|
this.profile = new ResponseExternalProfileDto(profile);
|
||||||
|
this.company = new ResponseCompanyDto(company);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator';
|
||||||
|
import { CompanyType } from '../entities/company.entity';
|
||||||
|
|
||||||
|
export class CreateCompanyWithProfileDto {
|
||||||
|
@IsEnum(CompanyType)
|
||||||
|
companyType!: CompanyType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
companyName!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(150)
|
||||||
|
companyEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
companyPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
companyLocation?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
tin?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
vatNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(16)
|
||||||
|
fanNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
jobTitle?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrimaryContact?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
|
||||||
|
import { CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
|
|
||||||
|
export class CreateCompanyDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsEnum(CompanyType)
|
||||||
|
type!: CompanyType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CompanyStatus)
|
||||||
|
status?: CompanyStatus;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@Length(10, 10)
|
||||||
|
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||||
|
tin!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
vatNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
businessLicense?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
address?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(150)
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
website?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
attributes?: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateExternalProfileDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@IsEmail()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
nationalId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
jobTitle?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isPrimaryContact?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator';
|
||||||
|
import { FFClientRelationship } from '../entities/ff-client.entity';
|
||||||
|
|
||||||
|
export class CreateFFClientDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
forwarderCompanyId!: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
clientCompanyId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(FFClientRelationship)
|
||||||
|
relationshipType?: FFClientRelationship;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canBookOnBehalf?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
canViewDocuments?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Company } from '../entities/company.entity';
|
||||||
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
|
||||||
|
export class ProfileResponseDto {
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
companyEmail: string | null;
|
||||||
|
companyPhone: string | null;
|
||||||
|
companyLocation: string;
|
||||||
|
companyAddress: string | null;
|
||||||
|
tinNumber: string;
|
||||||
|
vatNumber: string | null;
|
||||||
|
fanNumber: string | null;
|
||||||
|
|
||||||
|
contactPersonName: string | null;
|
||||||
|
contactPersonPhone: string | null;
|
||||||
|
generalManagerName: string | null;
|
||||||
|
generalManagerEmail: string | null;
|
||||||
|
generalManagerPhone: string | null;
|
||||||
|
|
||||||
|
poaName: string | null;
|
||||||
|
poaPhone: string | null;
|
||||||
|
poaEmail: string | null;
|
||||||
|
poaLocation: string | null;
|
||||||
|
poaAddress: string | null;
|
||||||
|
|
||||||
|
profileId: string;
|
||||||
|
|
||||||
|
constructor(profile: ExternalProfile, company: Company) {
|
||||||
|
this.companyId = company.id;
|
||||||
|
this.companyName = company.name;
|
||||||
|
this.companyEmail = company.email ?? null;
|
||||||
|
this.companyPhone = company.phone ?? null;
|
||||||
|
this.companyLocation = company.country;
|
||||||
|
this.companyAddress = company.address ?? null;
|
||||||
|
this.tinNumber = company.tin;
|
||||||
|
this.vatNumber = company.vatNumber ?? null;
|
||||||
|
this.fanNumber = company.fanNumber ?? null;
|
||||||
|
this.profileId = profile.id;
|
||||||
|
|
||||||
|
const attrs = company.attributes ?? {};
|
||||||
|
this.contactPersonName = attrs.contactPersonName ?? null;
|
||||||
|
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
|
||||||
|
this.generalManagerName = attrs.generalManagerName ?? null;
|
||||||
|
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
|
||||||
|
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
|
||||||
|
this.poaName = attrs.poaName ?? null;
|
||||||
|
this.poaPhone = attrs.poaPhone ?? null;
|
||||||
|
this.poaEmail = attrs.poaEmail ?? null;
|
||||||
|
this.poaLocation = attrs.poaLocation ?? null;
|
||||||
|
this.poaAddress = attrs.poaAddress ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Company, CompanyType, CompanyStatus } from '../entities/company.entity';
|
||||||
|
import { ResponseExternalProfileDto } from './response-external-profile.dto';
|
||||||
|
|
||||||
|
export class ResponseCompanyDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: CompanyType;
|
||||||
|
status: CompanyStatus;
|
||||||
|
tin: string;
|
||||||
|
vatNumber?: string | null;
|
||||||
|
businessLicense?: string | null;
|
||||||
|
fanNumber?: string | null;
|
||||||
|
country: string;
|
||||||
|
address?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
website?: string | null;
|
||||||
|
attributes?: Record<string, any> | null;
|
||||||
|
profiles?: ResponseExternalProfileDto[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(company: Company) {
|
||||||
|
this.id = company.id;
|
||||||
|
this.name = company.name;
|
||||||
|
this.type = company.type;
|
||||||
|
this.status = company.status;
|
||||||
|
this.tin = company.tin;
|
||||||
|
this.vatNumber = company.vatNumber;
|
||||||
|
this.businessLicense = company.businessLicense;
|
||||||
|
this.fanNumber = company.fanNumber;
|
||||||
|
this.country = company.country;
|
||||||
|
this.address = company.address;
|
||||||
|
this.phone = company.phone;
|
||||||
|
this.email = company.email;
|
||||||
|
this.website = company.website;
|
||||||
|
this.attributes = company.attributes;
|
||||||
|
this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p));
|
||||||
|
this.createdAt = company.createdAt;
|
||||||
|
this.updatedAt = company.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { ExternalProfile } from '../entities/external-profile.entity';
|
||||||
|
|
||||||
|
export class ResponseExternalProfileDto {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
companyId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string | null;
|
||||||
|
nationalId?: string | null;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
isPrimaryContact: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(profile: ExternalProfile) {
|
||||||
|
this.id = profile.id;
|
||||||
|
this.userId = profile.userId;
|
||||||
|
this.companyId = profile.companyId;
|
||||||
|
this.firstName = profile.firstName;
|
||||||
|
this.lastName = profile.lastName;
|
||||||
|
this.email = profile.email;
|
||||||
|
this.phone = profile.phone;
|
||||||
|
this.nationalId = profile.nationalId;
|
||||||
|
this.jobTitle = profile.jobTitle;
|
||||||
|
this.isPrimaryContact = profile.isPrimaryContact;
|
||||||
|
this.createdAt = profile.createdAt;
|
||||||
|
this.updatedAt = profile.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { FFClient, FFClientRelationship } from '../entities/ff-client.entity';
|
||||||
|
|
||||||
|
export class ResponseFFClientDto {
|
||||||
|
id: string;
|
||||||
|
forwarderCompanyId: string;
|
||||||
|
clientCompanyId: string;
|
||||||
|
relationshipType: FFClientRelationship;
|
||||||
|
canBookOnBehalf: boolean;
|
||||||
|
canViewDocuments: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(client: FFClient) {
|
||||||
|
this.id = client.id;
|
||||||
|
this.forwarderCompanyId = client.forwarderCompanyId;
|
||||||
|
this.clientCompanyId = client.clientCompanyId;
|
||||||
|
this.relationshipType = client.relationshipType;
|
||||||
|
this.canBookOnBehalf = client.canBookOnBehalf;
|
||||||
|
this.canViewDocuments = client.canViewDocuments;
|
||||||
|
this.createdAt = client.createdAt;
|
||||||
|
this.updatedAt = client.updatedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateCompanyDto } from './create-company.dto';
|
||||||
|
|
||||||
|
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateExternalProfileDto } from './create-external-profile.dto';
|
||||||
|
|
||||||
|
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateFFClientDto } from './create-ff-client.dto';
|
||||||
|
|
||||||
|
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateProfileDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(200)
|
||||||
|
companyName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
@MaxLength(150)
|
||||||
|
companyEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
companyPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
companyLocation?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyAddress?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Length(10, 10)
|
||||||
|
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
|
||||||
|
tin?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
vatNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(16)
|
||||||
|
fanNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactPersonName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
contactPersonPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
generalManagerName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
generalManagerEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
generalManagerPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
poaName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
poaPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmail()
|
||||||
|
poaEmail?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
poaLocation?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
poaAddress?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||||
|
import { ExternalProfile } from './external-profile.entity';
|
||||||
|
|
||||||
|
export enum CompanyType {
|
||||||
|
Customer = 'customer',
|
||||||
|
Forwarder = 'forwarder',
|
||||||
|
Transporter = 'transporter',
|
||||||
|
Broker = 'broker',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CompanyStatus {
|
||||||
|
Active = 'active',
|
||||||
|
Pending = 'pending',
|
||||||
|
Suspended = 'suspended',
|
||||||
|
Blacklisted = 'blacklisted',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'companies' })
|
||||||
|
@Index(['tin'])
|
||||||
|
@Index(['type'])
|
||||||
|
export class Company extends BaseEntity {
|
||||||
|
@Column({ name: 'name', type: 'varchar', length: 200 })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType })
|
||||||
|
type!: CompanyType;
|
||||||
|
|
||||||
|
@Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending })
|
||||||
|
status!: CompanyStatus;
|
||||||
|
|
||||||
|
@Column({ name: 'tin', type: 'varchar', length: 10, unique: true })
|
||||||
|
tin!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true })
|
||||||
|
vatNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true })
|
||||||
|
businessLicense?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true })
|
||||||
|
fanNumber?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' })
|
||||||
|
country!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'address', type: 'text', nullable: true })
|
||||||
|
address?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||||
|
phone?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'email', type: 'varchar', length: 150, nullable: true })
|
||||||
|
email?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'website', type: 'varchar', length: 200, nullable: true })
|
||||||
|
website?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'attributes', type: 'jsonb', nullable: true })
|
||||||
|
attributes?: Record<string, any> | null;
|
||||||
|
|
||||||
|
@OneToMany(() => ExternalProfile, (profile) => profile.company)
|
||||||
|
profiles?: ExternalProfile[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
|
||||||
|
import { Company } from './company.entity';
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'external_profiles' })
|
||||||
|
@Index(['userId'])
|
||||||
|
@Index(['companyId'])
|
||||||
|
export class ExternalProfile extends BaseEntity {
|
||||||
|
@Column({ name: 'user_id', type: 'uuid' })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'company_id', type: 'uuid' })
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company, (company) => company.profiles)
|
||||||
|
@JoinColumn({ name: 'company_id' })
|
||||||
|
company!: Company;
|
||||||
|
|
||||||
|
@Column({ name: 'first_name', type: 'varchar', length: 100 })
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_name', type: 'varchar', length: 100 })
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'email', type: 'varchar', length: 150, unique: true })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'phone', type: 'varchar', length: 20, nullable: true })
|
||||||
|
phone?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'national_id', type: 'varchar', length: 50, nullable: true })
|
||||||
|
nationalId?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'job_title', type: 'varchar', length: 100, nullable: true })
|
||||||
|
jobTitle?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'is_primary_contact', type: 'boolean', default: false })
|
||||||
|
isPrimaryContact!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm';
|
||||||
|
import { Company } from './company.entity';
|
||||||
|
|
||||||
|
export enum FFClientRelationship {
|
||||||
|
ManagedAccount = 'managed_account',
|
||||||
|
SubAgent = 'sub_agent',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity({ schema: 'freight', name: 'ff_clients' })
|
||||||
|
@Unique(['forwarderCompanyId', 'clientCompanyId'])
|
||||||
|
@Index(['forwarderCompanyId'])
|
||||||
|
@Index(['clientCompanyId'])
|
||||||
|
export class FFClient extends BaseEntity {
|
||||||
|
@Column({ name: 'forwarder_company_id', type: 'uuid' })
|
||||||
|
forwarderCompanyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'forwarder_company_id' })
|
||||||
|
forwarderCompany!: Company;
|
||||||
|
|
||||||
|
@Column({ name: 'client_company_id', type: 'uuid' })
|
||||||
|
clientCompanyId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Company)
|
||||||
|
@JoinColumn({ name: 'client_company_id' })
|
||||||
|
clientCompany!: Company;
|
||||||
|
|
||||||
|
@Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount })
|
||||||
|
relationshipType!: FFClientRelationship;
|
||||||
|
|
||||||
|
@Column({ name: 'can_book_on_behalf', type: 'boolean', default: true })
|
||||||
|
canBookOnBehalf!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'can_view_documents', type: 'boolean', default: true })
|
||||||
|
canViewDocuments!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { ExternalProfile } from './entities/external-profile.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExternalProfileRepository extends BaseRepository<ExternalProfile> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ExternalProfile)
|
||||||
|
repo: Repository<ExternalProfile>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByUserId(userId: string): Promise<ExternalProfile | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { userId } as any,
|
||||||
|
relations: ['company'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByCompanyId(companyId: string): Promise<ExternalProfile[]> {
|
||||||
|
return this.repository.find({ where: { companyId } as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByEmail(email: string): Promise<ExternalProfile | null> {
|
||||||
|
return this.repository.findOne({ where: { email } as any });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
import { FFClient } from './entities/ff-client.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FFClientRepository extends BaseRepository<FFClient> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(FFClient)
|
||||||
|
repo: Repository<FFClient>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByForwarder(forwarderCompanyId: string): Promise<FFClient[]> {
|
||||||
|
return this.repository.find({ where: { forwarderCompanyId } as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByClient(clientCompanyId: string): Promise<FFClient[]> {
|
||||||
|
return this.repository.find({ where: { clientCompanyId } as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRelationship(
|
||||||
|
forwarderCompanyId: string,
|
||||||
|
clientCompanyId: string,
|
||||||
|
): Promise<FFClient | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { forwarderCompanyId, clientCompanyId } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,12 @@ export class FileUploadSettingsController {
|
|||||||
return this.service.getByCode(code);
|
return this.service.getByCode(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("by-entity/:entity")
|
||||||
|
@ApiOperation({ summary: "Get all file upload settings for an entity type (customer, booking, etc.)" })
|
||||||
|
getByEntity(@Param("entity") entity: string) {
|
||||||
|
return this.service.getByEntity(entity);
|
||||||
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ export class FileUploadSettingsRepository
|
|||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Look up all settings for a given entity (e.g. "customer", "booking"). */
|
||||||
|
findByEntity(entity: string): Promise<FileUploadSetting[]> {
|
||||||
|
return this.repository.find({
|
||||||
|
where: { entity },
|
||||||
|
order: { label: "ASC" },
|
||||||
|
relations: { fields: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Look up a setting by its stable code. */
|
/** Look up a setting by its stable code. */
|
||||||
findByCode(code: string): Promise<FileUploadSetting | null> {
|
findByCode(code: string): Promise<FileUploadSetting | null> {
|
||||||
return this.repository.findOne({
|
return this.repository.findOne({
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ export class FileUploadSettingsService {
|
|||||||
return setting;
|
return setting;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getByEntity(entity: string): Promise<FileUploadSetting[]> {
|
||||||
|
return this.repository.findByEntity(entity);
|
||||||
|
}
|
||||||
|
|
||||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||||
const setting = await this.repository.findByCode(code);
|
const setting = await this.repository.findByCode(code);
|
||||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface IFileUploadSettingsRepository {
|
|||||||
findAll(): Promise<FileUploadSetting[]>;
|
findAll(): Promise<FileUploadSetting[]>;
|
||||||
findById(id: string): Promise<FileUploadSetting | null>;
|
findById(id: string): Promise<FileUploadSetting | null>;
|
||||||
findByCode(code: string): Promise<FileUploadSetting | null>;
|
findByCode(code: string): Promise<FileUploadSetting | null>;
|
||||||
|
findByEntity(entity: string): Promise<FileUploadSetting[]>;
|
||||||
|
|
||||||
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
|
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
|||||||
): Promise<FileRecord | null> {
|
): Promise<FileRecord | null> {
|
||||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteByCode(
|
||||||
|
resourceId: string,
|
||||||
|
resource: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.repository.delete({ resourceId, resource, code });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export class FilesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
||||||
|
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
||||||
|
const { resourceId, resource, code } = input;
|
||||||
|
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||||
|
return this.upload(input);
|
||||||
|
}
|
||||||
|
|
||||||
async uploadMany(
|
async uploadMany(
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
resource: string,
|
resource: string,
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { NotificationsService } from "./notifications.service";
|
import { NotificationsService } from "./notifications.service";
|
||||||
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
|
import { HttpModule } from "@nestjs/axios";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [NotificationsService],
|
imports: [HttpModule],
|
||||||
|
controllers: [],
|
||||||
|
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService],
|
||||||
exports: [NotificationsService],
|
exports: [NotificationsService],
|
||||||
})
|
})
|
||||||
export class NotificationsModule {}
|
export class NotificationsModule { }
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
|
||||||
|
import { NotificationStrategy } from "./strategies/notification.strategy";
|
||||||
|
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type StrategyMethod = "sms" | "email"
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class NotificationsService {
|
export class NotificationsService {
|
||||||
private readonly logger = new Logger(NotificationsService.name);
|
private readonly logger = new Logger(NotificationsService.name);
|
||||||
|
private readonly strategies: Map<StrategyMethod, NotificationStrategy>
|
||||||
|
constructor(private readonly email: EmailNotificationStrategy, private readonly sms: SmsNotificationStrategy) {
|
||||||
|
this.strategies = new Map([
|
||||||
|
["sms", this.sms as NotificationStrategy],
|
||||||
|
["email", this.email as NotificationStrategy]
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a notification to an operator or customer.
|
* Dispatch a notification to an operator or customer.
|
||||||
* TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service.
|
* TODO: wire to email/SMS provider (SendGrid, SMS API, etc.) via a mailer service.
|
||||||
*/
|
*/
|
||||||
async send(recipient: string, subject: string, body: string): Promise<void> {
|
|
||||||
this.logger.log(`[notify] ${recipient} :: ${subject} :: ${body}`);
|
async directSend(method: StrategyMethod, recipient: string, message: string) {
|
||||||
|
const strategy = this.strategies.get(method);
|
||||||
|
if (!strategy) {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
const sent = await strategy.send(recipient, message)
|
||||||
|
this.logger.log(`is sent - ${sent}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EmailNotificationStrategy implements NotificationStrategy {
|
||||||
|
private readonly logger = new Logger(EmailNotificationStrategy.name);
|
||||||
|
constructor() { }
|
||||||
|
async send(recipient: string, message: string): Promise<boolean> {
|
||||||
|
this.logger.log(`${recipient}, ${message}`)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable} from "@nestjs/common";
|
||||||
|
import { NotificationStrategy } from "./notification.strategy";
|
||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SmsNotificationStrategy implements NotificationStrategy {
|
||||||
|
constructor(private readonly httpService: HttpService, private readonly configService: ConfigService) { }
|
||||||
|
async send(recipient: string, message: string) {
|
||||||
|
const url = this.configService.get("OZIKING_SMS_URL")
|
||||||
|
const body = {
|
||||||
|
to: recipient,
|
||||||
|
text: message
|
||||||
|
}
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.httpService.post(
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.status === 201;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export abstract class NotificationStrategy {
|
||||||
|
abstract send(recipient: string, message: string): Promise<boolean>
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
|
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||||
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
|
import {
|
||||||
|
type AuthUserPayload,
|
||||||
|
resolveAuthUserId,
|
||||||
|
} from '../../../common/resolve-auth-user-id';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { RatesService } from '../services/rates.service';
|
import { RatesService } from '../services/rates.service';
|
||||||
|
|
||||||
@@ -38,9 +44,13 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||||
create(@Body() dto: CreateRateDto) {
|
create(
|
||||||
return this.service.create(dto);
|
@Body() dto: CreateRateDto,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.service.create(dto, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@@ -56,9 +66,13 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/approve')
|
@Post(':id/approve')
|
||||||
|
@UseGuards(JwtGuard)
|
||||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||||
approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
|
approve(
|
||||||
return this.service.approve(id, dto);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: AuthUserPayload,
|
||||||
|
) {
|
||||||
|
return this.service.approve(id, resolveAuthUserId(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ export class CreateRateDto {
|
|||||||
@IsIn([...RATE_UNITS])
|
@IsIn([...RATE_UNITS])
|
||||||
rateUnit!: string;
|
rateUnit!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
|
|
||||||
@IsUUID()
|
|
||||||
proposedByStaffId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
|
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
|
||||||
@IsDateString()
|
@IsDateString()
|
||||||
effectiveFrom!: string;
|
effectiveFrom!: string;
|
||||||
@@ -49,12 +45,6 @@ export class CreateRateDto {
|
|||||||
effectiveTo?: string;
|
effectiveTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApproveRateDto {
|
|
||||||
@ApiProperty({ description: 'ID of the CEO approving this rate' })
|
|
||||||
@IsUUID()
|
|
||||||
approvedByCeoId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SubmitRateForApprovalDto {
|
export class SubmitRateForApprovalDto {
|
||||||
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
|
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export interface BookingContainerEvalInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingEvaluationInput {
|
export interface BookingEvaluationInput {
|
||||||
cargoTypeId: string;
|
cargoTypeId?: string | null;
|
||||||
|
freightType?: 'CONTAINER' | 'BULK';
|
||||||
serviceTypeId: string;
|
serviceTypeId: string;
|
||||||
paymentCurrency: string;
|
paymentCurrency: string;
|
||||||
tradeDirection: string;
|
tradeDirection: string;
|
||||||
@@ -115,13 +116,19 @@ export class RuleEngineService {
|
|||||||
let priorityScore = 0;
|
let priorityScore = 0;
|
||||||
let requiresDirectorApproval = false;
|
let requiresDirectorApproval = false;
|
||||||
|
|
||||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
if (input.freightType === 'BULK') {
|
||||||
if (!cargoType) {
|
|
||||||
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
|
||||||
} else if (cargoType.requiresDirectorApproval) {
|
|
||||||
requiresDirectorApproval = true;
|
requiresDirectorApproval = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.cargoTypeId) {
|
||||||
|
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||||
|
if (!cargoType) {
|
||||||
|
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
||||||
|
} else if (cargoType.requiresDirectorApproval) {
|
||||||
|
requiresDirectorApproval = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const container of input.containers) {
|
for (const container of input.containers) {
|
||||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||||
container.containerTypeId,
|
container.containerTypeId,
|
||||||
@@ -232,16 +239,29 @@ export class RuleEngineService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
|
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||||
*/
|
*/
|
||||||
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
|
async instantiateApprovalSteps(
|
||||||
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
|
bookingId: string,
|
||||||
if (!cargoType) {
|
options: {
|
||||||
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
|
freightType: 'CONTAINER' | 'BULK';
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<BookingApprovalStep[]> {
|
||||||
|
let requiresDirectorApproval = options.freightType === 'BULK';
|
||||||
|
|
||||||
|
if (options.cargoTypeId) {
|
||||||
|
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
|
||||||
|
if (!cargoType) {
|
||||||
|
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
|
||||||
|
}
|
||||||
|
if (cargoType.requiresDirectorApproval) {
|
||||||
|
requiresDirectorApproval = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chain = await this.approvalRulesRepo.findChainForCargo(
|
const chain = await this.approvalRulesRepo.findChainForCargo(
|
||||||
cargoType.requiresDirectorApproval,
|
requiresDirectorApproval,
|
||||||
);
|
);
|
||||||
|
|
||||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||||
@@ -46,7 +46,7 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a rate in DRAFT status. */
|
/** Create a rate in DRAFT status. */
|
||||||
async create(dto: CreateRateDto): Promise<Rate> {
|
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||||
return this.repository.create({
|
return this.repository.create({
|
||||||
rateType: dto.rateType as Rate['rateType'],
|
rateType: dto.rateType as Rate['rateType'],
|
||||||
containerTypeId: dto.containerTypeId,
|
containerTypeId: dto.containerTypeId,
|
||||||
@@ -55,7 +55,7 @@ export class RatesService {
|
|||||||
rateValue: dto.rateValue,
|
rateValue: dto.rateValue,
|
||||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
proposedByStaffId: dto.proposedByStaffId,
|
proposedByStaffId,
|
||||||
effectiveFrom: new Date(dto.effectiveFrom),
|
effectiveFrom: new Date(dto.effectiveFrom),
|
||||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
|
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
|
||||||
});
|
});
|
||||||
@@ -74,7 +74,6 @@ export class RatesService {
|
|||||||
if (dto.currency) updates.currency = dto.currency;
|
if (dto.currency) updates.currency = dto.currency;
|
||||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||||
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
|
||||||
if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
|
|
||||||
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
|
||||||
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
|
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
|
||||||
const updated = await this.repository.update(id, updates);
|
const updated = await this.repository.update(id, updates);
|
||||||
@@ -93,14 +92,14 @@ export class RatesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** CEO approves a rate — moves to LIVE. */
|
/** CEO approves a rate — moves to LIVE. */
|
||||||
async approve(id: string, dto: ApproveRateDto): Promise<Rate> {
|
async approve(id: string, approverUserId: string): Promise<Rate> {
|
||||||
const rate = await this.findById(id);
|
const rate = await this.findById(id);
|
||||||
if (rate.status !== 'PENDING_APPROVAL') {
|
if (rate.status !== 'PENDING_APPROVAL') {
|
||||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||||
}
|
}
|
||||||
const updated = await this.repository.update(id, {
|
const updated = await this.repository.update(id, {
|
||||||
status: 'LIVE',
|
status: 'LIVE',
|
||||||
approvedByCeoId: dto.approvedByCeoId,
|
approvedByCeoId: approverUserId,
|
||||||
approvedAt: new Date(),
|
approvedAt: new Date(),
|
||||||
});
|
});
|
||||||
return updated!;
|
return updated!;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class SurchargeTypesService {
|
|||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
const [data, total] = await this.repository.findAndCount({
|
||||||
where,
|
where,
|
||||||
|
relations: { rate: true },
|
||||||
order: { label: 'ASC' },
|
order: { label: 'ASC' },
|
||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
|
|||||||
125
apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
Normal file
125
apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
||||||
|
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||||
|
|
||||||
|
const COMPANY_ONBOARDING_DOCUMENTS = [
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_customer",
|
||||||
|
label: "Customer onboarding documents",
|
||||||
|
entity: "customer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_forwarder",
|
||||||
|
label: "Forwarder onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_transporter",
|
||||||
|
label: "Transporter onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "company_onboarding_documents_forwarder_dj",
|
||||||
|
label: "Djibouti forwarder onboarding documents",
|
||||||
|
entity: "other",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const COMPANY_ONBOARDING_DESCRIPTION =
|
||||||
|
"Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers.";
|
||||||
|
|
||||||
|
const COMPANY_ONBOARDING_FIELDS = [
|
||||||
|
{
|
||||||
|
fileKey: "business_license",
|
||||||
|
fileLabel: "Business License / Trade License",
|
||||||
|
helpText: "Verified against the government trade system during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "tin_certificate",
|
||||||
|
fileLabel: "TIN Certificate",
|
||||||
|
helpText: "Verified against the TIN registry during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileKey: "national_id_passport",
|
||||||
|
fileLabel: "National ID / Passport",
|
||||||
|
helpText: "Verified against the National ID API during registration.",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: ["pdf", "jpg", "jpeg", "png"],
|
||||||
|
maxSizeMb: 10,
|
||||||
|
displayOrder: 3,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FileUploadSettingsSeeder {
|
||||||
|
private readonly logger = new Logger(FileUploadSettingsSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const settingRepository = manager.getRepository(FileUploadSetting);
|
||||||
|
const fieldRepository = manager.getRepository(FileUploadField);
|
||||||
|
|
||||||
|
for (const documentSetting of COMPANY_ONBOARDING_DOCUMENTS) {
|
||||||
|
await settingRepository.upsert(
|
||||||
|
{
|
||||||
|
code: documentSetting.code,
|
||||||
|
label: documentSetting.label,
|
||||||
|
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||||
|
entity: documentSetting.entity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
conflictPaths: { code: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const setting = await settingRepository.findOne({
|
||||||
|
where: { code: documentSetting.code },
|
||||||
|
select: { id: true, code: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
throw new Error(`file_upload_setting_seed_failed:${documentSetting.code}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await fieldRepository.delete({ settingId: setting.id });
|
||||||
|
|
||||||
|
await fieldRepository.insert(
|
||||||
|
COMPANY_ONBOARDING_FIELDS.map((field, index) => ({
|
||||||
|
settingId: setting.id,
|
||||||
|
fileKey: field.fileKey,
|
||||||
|
fileLabel: field.fileLabel,
|
||||||
|
helpText: field.helpText,
|
||||||
|
isRequired: field.isRequired,
|
||||||
|
isMultiple: field.isMultiple,
|
||||||
|
maxFiles: field.maxFiles,
|
||||||
|
allowedExtensions: [...field.allowedExtensions],
|
||||||
|
maxSizeMb: field.maxSizeMb,
|
||||||
|
displayOrder: field.displayOrder ?? index + 1,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
"Ensured company onboarding file upload settings for external companies",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -8,12 +7,17 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Settings,
|
Settings,
|
||||||
SlidersHorizontal,
|
SlidersHorizontal,
|
||||||
|
Train,
|
||||||
|
Truck,
|
||||||
|
Container,
|
||||||
|
Package,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||||
@@ -30,16 +34,11 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
|||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
const queryClient = new QueryClient({
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
defaultOptions: {
|
import WagonsPage from "./pages/wagons/WagonsPage";
|
||||||
queries: {
|
import ContainersPage from "./pages/containers_management/ContainersPage";
|
||||||
retry: 1,
|
import CargoesPage from "./pages/cargoes/CargoesPage";
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -59,6 +58,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
...demoItems,
|
...demoItems,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Fleet Management",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Trains",
|
||||||
|
href: "/dashboard/trains",
|
||||||
|
icon: <Train />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Wagons",
|
||||||
|
href: "/dashboard/wagons",
|
||||||
|
icon: <Truck />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Containers",
|
||||||
|
href: "/dashboard/containers",
|
||||||
|
icon: <Container />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cargoes",
|
||||||
|
href: "/dashboard/cargoes",
|
||||||
|
icon: <Package />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Administration",
|
title: "Administration",
|
||||||
items: [
|
items: [
|
||||||
@@ -188,18 +212,15 @@ const App = () => {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
</Routes>
|
||||||
</Routes>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<Routes>
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
|
|
||||||
@@ -208,6 +229,16 @@ const App = () => {
|
|||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
|
<Route
|
||||||
|
path="booking-requests/:id/contract"
|
||||||
|
element={<BookingContractPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route path="trains" element={<TrainsPage />} />
|
||||||
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
|
<Route path="wagons" element={<WagonsPage />} />
|
||||||
|
<Route path="containers" element={<ContainersPage />} />
|
||||||
|
<Route path="cargoes" element={<CargoesPage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
@@ -251,8 +282,7 @@ const App = () => {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||||
|
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
|
||||||
|
import { Badge } from "@edr/ui-common";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApprovalStepsCardProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only approval chain visualization; actions live in BookingActionsToolbar. */
|
||||||
|
export function ApprovalStepsCard({ booking }: ApprovalStepsCardProps) {
|
||||||
|
const steps = useMemo(
|
||||||
|
() =>
|
||||||
|
[...(booking.approvalSteps ?? [])].sort(
|
||||||
|
(a, b) => a.stepOrder - b.stepOrder,
|
||||||
|
),
|
||||||
|
[booking.approvalSteps],
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextPending = getNextPendingApprovalStep(steps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-amber-200/80 bg-gradient-to-b from-amber-50/40 to-card shadow-sm dark:from-amber-950/20">
|
||||||
|
<div className="flex items-center gap-3 border-b border-amber-200/50 bg-amber-50/50 px-5 py-4 dark:bg-amber-950/30">
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-amber-500/15 text-amber-800 dark:text-amber-300">
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
Approval chain
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Next:{" "}
|
||||||
|
{nextPending
|
||||||
|
? `${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||||
|
: steps.length
|
||||||
|
? "All steps complete"
|
||||||
|
: "Accept submission to begin"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-5">
|
||||||
|
{steps.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Use <strong className="font-semibold text-foreground">Accept for approval</strong>{" "}
|
||||||
|
in staff actions to instantiate steps.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepRow
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
isNext={nextPending?.id === step.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepRow({
|
||||||
|
step,
|
||||||
|
isNext,
|
||||||
|
}: {
|
||||||
|
step: BookingApprovalStep;
|
||||||
|
isNext: boolean;
|
||||||
|
}) {
|
||||||
|
const statusStyles =
|
||||||
|
step.status === "APPROVED"
|
||||||
|
? "bg-emerald-500/15 text-emerald-800 dark:text-emerald-300"
|
||||||
|
: step.status === "REJECTED"
|
||||||
|
? "bg-red-500/15 text-red-800 dark:text-red-300"
|
||||||
|
: isNext
|
||||||
|
? "bg-amber-500/15 text-amber-800 dark:text-amber-300"
|
||||||
|
: "bg-muted text-muted-foreground";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors",
|
||||||
|
isNext
|
||||||
|
? "border-primary/30 bg-primary/[0.03] shadow-sm"
|
||||||
|
: "border-border/60 bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
|
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-xs font-bold text-muted-foreground">
|
||||||
|
{step.stepOrder}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{step.requiredRole}
|
||||||
|
</p>
|
||||||
|
{step.remarks && (
|
||||||
|
<p className="truncate text-xs text-muted-foreground">
|
||||||
|
{step.remarks}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("shrink-0 text-[9px] uppercase", statusStyles)}
|
||||||
|
>
|
||||||
|
{step.status}
|
||||||
|
</Badge>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
|
import {
|
||||||
|
listRowHasActions,
|
||||||
|
type BookingActionContext,
|
||||||
|
} from "@/features/bookings/booking-actions.config";
|
||||||
|
import type { BookingListRow } from "@/types/booking";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
interface BookingActionsMenuProps {
|
||||||
|
row: BookingListRow;
|
||||||
|
/** Compact table cell vs. larger detail toolbar */
|
||||||
|
variant?: "table" | "toolbar";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingActionsMenu({
|
||||||
|
row,
|
||||||
|
variant = "table",
|
||||||
|
className,
|
||||||
|
}: BookingActionsMenuProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const context: BookingActionContext = {
|
||||||
|
status: row.status,
|
||||||
|
paymentCurrency: row.paymentCurrency,
|
||||||
|
reference: row.reference,
|
||||||
|
};
|
||||||
|
|
||||||
|
const flow = useBookingActionDialog(row.id, context);
|
||||||
|
const { actions, pendingAction, mutations } = flow;
|
||||||
|
|
||||||
|
const goToContract = () =>
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
|
|
||||||
|
const showUsdPaymentHint =
|
||||||
|
row.status === "FULLY_EXECUTED" && row.paymentCurrency === "USD";
|
||||||
|
const hasMenu = listRowHasActions(row) || showUsdPaymentHint;
|
||||||
|
|
||||||
|
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||||
|
|
||||||
|
if (!hasMenu && variant === "table") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-muted-foreground hover:text-primary"
|
||||||
|
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||||
|
aria-label="View booking"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-end gap-1",
|
||||||
|
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{variant === "table" && primary && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
||||||
|
disabled={mutations.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
primary.id === "viewContract"
|
||||||
|
? goToContract()
|
||||||
|
: flow.openAction(primary)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<primary.icon className="size-3.5" />
|
||||||
|
{primary.shortLabel}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{variant === "toolbar" && actions.length > 0 ? (
|
||||||
|
<div className="flex w-full flex-wrap gap-2">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
action.variant === "destructive"
|
||||||
|
? "outline"
|
||||||
|
: action.primary
|
||||||
|
? "default"
|
||||||
|
: "outline"
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 shadow-sm",
|
||||||
|
action.variant === "destructive" &&
|
||||||
|
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
|
||||||
|
)}
|
||||||
|
disabled={mutations.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
action.id === "viewContract"
|
||||||
|
? goToContract()
|
||||||
|
: flow.openAction(action)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={variant === "table" ? "ghost" : "outline"}
|
||||||
|
size={variant === "table" ? "icon" : "sm"}
|
||||||
|
className={cn(
|
||||||
|
variant === "table" ? "size-8" : "gap-2",
|
||||||
|
"shrink-0",
|
||||||
|
)}
|
||||||
|
disabled={mutations.isPending}
|
||||||
|
aria-label="Booking actions"
|
||||||
|
>
|
||||||
|
{mutations.isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
|
||||||
|
{row.reference}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={action.id}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 cursor-pointer",
|
||||||
|
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
action.id === "viewContract"
|
||||||
|
? goToContract()
|
||||||
|
: flow.openAction(action)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 opacity-70" />
|
||||||
|
<span>{action.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{showUsdPaymentHint && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Upload className="size-4 opacity-70" />
|
||||||
|
Upload payment proof…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{(actions.length > 0 || showUsdPaymentHint) && (
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="gap-2 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/dashboard/booking-requests/${row.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-4 opacity-70" />
|
||||||
|
Open full details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BookingConfirmDialog
|
||||||
|
open={flow.dialogOpen}
|
||||||
|
onOpenChange={flow.setDialogOpen}
|
||||||
|
action={pendingAction}
|
||||||
|
reference={flow.mergedContext.reference}
|
||||||
|
inputValue={flow.inputValue}
|
||||||
|
onInputChange={flow.setInputValue}
|
||||||
|
onConfirm={flow.runAction}
|
||||||
|
isPending={mutations.isPending || flow.detailLoading}
|
||||||
|
confirmDisabled={flow.confirmDisabled}
|
||||||
|
extra={
|
||||||
|
flow.detailLoading ? (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading approval steps…
|
||||||
|
</p>
|
||||||
|
) : pendingAction?.id === "approve" &&
|
||||||
|
!flow.mergedContext.approvalSteps?.length ? (
|
||||||
|
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
||||||
|
No pending approval step found. Accept the submission on the detail
|
||||||
|
page first.
|
||||||
|
</p>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Download, Upload, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||||
|
import { bookingSurface } from "./booking-ui.styles";
|
||||||
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
|
import { Button } from "@edr/ui-common";
|
||||||
|
|
||||||
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
|
interface BookingActionsToolbarProps {
|
||||||
|
booking: BookingDetail;
|
||||||
|
mutations: Mutations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail-page actions: primary toolbar + payment uploads + downloads. */
|
||||||
|
export function BookingActionsToolbar({
|
||||||
|
booking,
|
||||||
|
mutations,
|
||||||
|
}: BookingActionsToolbarProps) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const row = toBookingListRow(booking);
|
||||||
|
const { status, paymentCurrency } = booking;
|
||||||
|
const pending = mutations.isPending;
|
||||||
|
|
||||||
|
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||||
|
const blob = await fn();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === "REJECTED" ||
|
||||||
|
status === "CANCELLED" ||
|
||||||
|
status === "COMPLETED"
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "CHANGES_REQUESTED") {
|
||||||
|
return (
|
||||||
|
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
||||||
|
{booking.latestChangeRequestNote && (
|
||||||
|
<p className="rounded-lg border bg-muted/30 p-3 text-sm leading-relaxed">
|
||||||
|
{booking.latestChangeRequestNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PanelShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
|
||||||
|
return (
|
||||||
|
<PanelShell
|
||||||
|
title="No staff actions"
|
||||||
|
description="Monitor until the customer or system advances status."
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PanelShell
|
||||||
|
title="Staff actions"
|
||||||
|
description="Confirm each step before it is applied."
|
||||||
|
>
|
||||||
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
|
</PanelShell>
|
||||||
|
|
||||||
|
{status === "FULLY_EXECUTED" && paymentCurrency === "USD" && (
|
||||||
|
<PanelShell title="Payment (USD)" description="Upload proof of payment.">
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) mutations.submitPaymentProof.mutate(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={pending}
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Upload payment proof
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
downloadBlob(
|
||||||
|
() => mutations.downloadPaymentLetter(),
|
||||||
|
`payment-letter-${booking.reference}.txt`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Request letter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PanelShell>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "CONTRACT_READY" && (
|
||||||
|
<PanelShell title="Documents" description="Download generated contract.">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
downloadBlob(
|
||||||
|
() => mutations.downloadContract(),
|
||||||
|
`contract-${booking.reference}.txt`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
Download contract
|
||||||
|
</Button>
|
||||||
|
</PanelShell>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanelShell({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
muted?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
muted
|
||||||
|
? bookingSurface.sectionCard
|
||||||
|
: `${bookingSurface.sectionCard} ring-1 ring-primary/10`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={bookingSurface.sectionHeader}>
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||||
|
<Zap className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Textarea,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
interface BookingConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
action: BookingActionDef | null;
|
||||||
|
reference?: string;
|
||||||
|
inputValue: string;
|
||||||
|
onInputChange: (value: string) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
|
extra?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
action,
|
||||||
|
reference,
|
||||||
|
inputValue,
|
||||||
|
onInputChange,
|
||||||
|
onConfirm,
|
||||||
|
isPending,
|
||||||
|
confirmDisabled = false,
|
||||||
|
extra,
|
||||||
|
}: BookingConfirmDialogProps) {
|
||||||
|
if (!action || !action.confirmTitle) return null;
|
||||||
|
|
||||||
|
const Icon = action.icon;
|
||||||
|
const needsInput = Boolean(action.input);
|
||||||
|
const inputMissing = needsInput && !inputValue.trim();
|
||||||
|
const isDestructive = action.variant === "destructive";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-b px-6 py-5",
|
||||||
|
isDestructive
|
||||||
|
? "bg-gradient-to-br from-red-500/10 via-background to-background"
|
||||||
|
: "bg-gradient-to-br from-primary/8 via-background to-background",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader className="gap-3 text-left">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm",
|
||||||
|
isDestructive
|
||||||
|
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
||||||
|
: "bg-primary/15 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1 pt-0.5">
|
||||||
|
<DialogTitle className="text-base leading-snug">
|
||||||
|
{action.confirmTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
{reference && (
|
||||||
|
<p className="font-mono text-xs font-semibold text-muted-foreground">
|
||||||
|
{reference}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-left text-sm leading-relaxed">
|
||||||
|
{action.confirmDescription}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
{needsInput && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{action.inputLabel}
|
||||||
|
<span className="text-red-600"> *</span>
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
placeholder={action.inputPlaceholder}
|
||||||
|
rows={4}
|
||||||
|
className="min-h-[100px] resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isDestructive ? "destructive" : "default"}
|
||||||
|
disabled={isPending || inputMissing || confirmDisabled}
|
||||||
|
className="min-w-[7rem] gap-2"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Icon className="size-4" />
|
||||||
|
)}
|
||||||
|
{action.shortLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Banknote, Receipt } from "lucide-react";
|
||||||
|
|
||||||
|
import type { BookingDetail } from "@/types/booking";
|
||||||
|
import { Separator } from "@edr/ui-common";
|
||||||
|
import { bookingSurface } from "./booking-ui.styles";
|
||||||
|
|
||||||
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
|
const amount = Number(booking.totalAmount);
|
||||||
|
const modifiers = booking.cargoModifiers ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={bookingSurface.sectionCard}>
|
||||||
|
<div className={bookingSurface.sectionHeader}>
|
||||||
|
<div className="flex size-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Banknote className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
Pricing & payment
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">Commercial terms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<div className="rounded-xl border border-primary/15 bg-gradient-to-br from-primary/[0.06] to-transparent p-4">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Total amount
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-2xl font-bold tracking-tight text-foreground">
|
||||||
|
{booking.paymentCurrency}{" "}
|
||||||
|
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Row label="Payment status" value={booking.paymentStatus} />
|
||||||
|
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||||
|
{modifiers.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<p className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<Receipt className="size-3" />
|
||||||
|
Surcharges applied
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{modifiers.map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.id}
|
||||||
|
className="flex justify-between rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-muted-foreground">Modifier</span>
|
||||||
|
<span className="font-mono font-semibold tabular-nums">
|
||||||
|
{Number(m.calculatedAmount).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
mono
|
||||||
|
? "font-mono text-xs font-semibold text-foreground"
|
||||||
|
: "font-medium text-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export function BookingPriorityBadge({ score }: { score: number }) {
|
||||||
|
if (score >= 1000) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
|
||||||
|
Urgent
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (score >= 500) {
|
||||||
|
return (
|
||||||
|
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
|
||||||
|
High
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
||||||
|
Normal
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface StatItem {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
hint?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
accent?: "default" | "amber" | "emerald" | "rose";
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentStyles = {
|
||||||
|
default: "bg-primary/10 text-primary",
|
||||||
|
amber: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||||
|
emerald: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
||||||
|
rose: "bg-rose-500/10 text-rose-700 dark:text-rose-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const accent = item.accent ?? "default";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="group relative overflow-hidden rounded-xl border border-border bg-card p-5 shadow-sm transition-all duration-200 hover:border-primary/20 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
{item.hint && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{item.hint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-11 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-105",
|
||||||
|
accentStyles[accent],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Badge } from "@edr/ui-common";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||||
|
|
||||||
|
export function BookingStatusBadge({ status }: { status: string }) {
|
||||||
|
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||||
|
label: status,
|
||||||
|
color: "bg-muted text-muted-foreground border-border",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
|
||||||
|
style.color,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user