mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge freight/develop into feature/trains-management
This commit is contained in:
@@ -4,7 +4,10 @@
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [{ "include": "migrations/**/*", "outDir": "dist" }],
|
||||
"assets": [
|
||||
{ "include": "migrations/**/*", "outDir": "dist" },
|
||||
{ "include": "contracts/templates/**/*", "watchAssets": true }
|
||||
],
|
||||
"watchAssets": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
@@ -27,11 +28,13 @@
|
||||
"@tria-plc/iamapi-common": "^0.1.6",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"axios": "^1.16.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"minio": "7.1.3",
|
||||
"puppeteer": "^24.2.0",
|
||||
"pg": "^8.13.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -76,4 +79,4 @@
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { FilesModule } from "./modules/files/files.module";
|
||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||
|
||||
import { CustomersModule } from "./modules/customers/customers.module";
|
||||
import { CompaniesModule } from "./modules/companies/companies.module";
|
||||
import { TrackingModule } from "./modules/tracking/tracking.module";
|
||||
import { BillingModule } from "./modules/billing/billing.module";
|
||||
import { NotificationsModule } from "./modules/notifications/notifications.module";
|
||||
@@ -29,13 +30,13 @@ import {
|
||||
} from "./seed/edr-freight.seed";
|
||||
import { EdrOrgSeeder } from "./seed/edr-org.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 { WagonsModule } from './modules/wagons/wagons.module';
|
||||
import { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
@@ -65,6 +66,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
ConsignmentsModule,
|
||||
TrainsModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
TrackingModule,
|
||||
BillingModule,
|
||||
NotificationsModule,
|
||||
@@ -80,18 +82,20 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder, FileUploadSettingsSeeder],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
constructor(
|
||||
private readonly seeder: DataSeeder,
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
) { }
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
await this.seeder.run();
|
||||
await this.edrOrgSeeder.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,
|
||||
Delete,
|
||||
Get,
|
||||
Header,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
@@ -10,10 +11,15 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
Request,
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
} from '@nestjs/common';
|
||||
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 {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
@@ -21,181 +27,405 @@ import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
} from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { BookingReferenceDataService } from "./booking-reference-data.service";
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
import { FilterBookingDto } from "./dto/filter-booking.dto";
|
||||
import { UpdateBookingDto } from "./dto/update-booking.dto";
|
||||
import { UpdateStatusDto } from "./dto/update-status.dto";
|
||||
import { BookingContractService } from './booking-contract.service';
|
||||
import { BookingPaymentService } from './booking-payment.service';
|
||||
import { BookingPricingService } from './booking-pricing.service';
|
||||
import { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingReferenceDataService } from './booking-reference-data.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
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")
|
||||
@Controller("bookings")
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService,
|
||||
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()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Create a new freight booking",
|
||||
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,
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
|
||||
@ApiBody({ type: CreateBookingDto })
|
||||
create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: any,
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
) {
|
||||
console.log(
|
||||
"[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;
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.bookingsService.create(dto, files ?? [], userId);
|
||||
}
|
||||
|
||||
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
|
||||
@Patch(":id")
|
||||
@Patch(':id')
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: "Update a draft booking",
|
||||
description:
|
||||
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
||||
summary: 'Update booking',
|
||||
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
) {
|
||||
return this.bookingsService.update(id, dto, files ?? []);
|
||||
}
|
||||
|
||||
// ── 3. List bookings (paginated + filtered) ───────────────────────────
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: "List freight bookings (paginated)",
|
||||
description:
|
||||
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
|
||||
"paymentCurrency, allowConsolidation, consolidationPaired. " +
|
||||
"Sort by createdAt or priorityScore.",
|
||||
})
|
||||
@ApiOperation({ summary: 'List freight bookings (paginated)' })
|
||||
findAll(@Query() filter: FilterBookingDto) {
|
||||
return this.bookingsService.findAll(filter);
|
||||
}
|
||||
|
||||
// ── Booking form catalog (must be before :id) ─────────────────────────
|
||||
@Get("reference-data")
|
||||
@Get('queues/:queue')
|
||||
@ApiOperation({
|
||||
summary: "Booking form catalog",
|
||||
description:
|
||||
"Returns yards, container types (grouped by size), service types, shipping lines, " +
|
||||
"and hierarchical cargo types for the booking UI in a single payload.",
|
||||
summary: 'List bookings for a dashboard queue',
|
||||
description: 'Queues: intake, approval, signatures, marketing, finance',
|
||||
})
|
||||
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 })
|
||||
getReferenceData(): Promise<BookingReferenceDataDto> {
|
||||
return this.bookingReferenceDataService.getReferenceData();
|
||||
}
|
||||
|
||||
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
|
||||
@Get("by-reference/:reference")
|
||||
@ApiOperation({
|
||||
summary: "Get a freight booking by reference number",
|
||||
description: "Lookup booking by its human-readable reference string.",
|
||||
})
|
||||
findByReference(@Param("reference") reference: string) {
|
||||
return this.bookingsService.findByReference(reference);
|
||||
@Get('by-reference/:reference')
|
||||
@ApiOperation({ summary: 'Get booking by reference' })
|
||||
async findByReference(@Param('reference') reference: string) {
|
||||
const booking = await this.bookingsService.findByReference(reference);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── 4. Get single booking by ID ───────────────────────────────────────
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a freight booking by ID" })
|
||||
findOne(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.findById(id);
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get booking by ID' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
|
||||
@Delete(":id")
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: "Soft-delete a freight booking",
|
||||
description: "Only DRAFT bookings can be deleted.",
|
||||
})
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.remove(id);
|
||||
}
|
||||
|
||||
// ── 7. Unified status transition ──────────────────────────────────────
|
||||
@Patch(":id/status")
|
||||
@ApiOperation({
|
||||
summary: "Transition booking status",
|
||||
description:
|
||||
"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);
|
||||
@Post(':id/generate-price')
|
||||
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
|
||||
@ApiOkResponse({ type: GeneratePriceResponseDto })
|
||||
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.pricingService.generatePrice(id);
|
||||
}
|
||||
|
||||
// ── 8. Request or auto-pair consolidation ─────────────────────────────
|
||||
@Post(":id/consolidation")
|
||||
@Post(':id/submit')
|
||||
@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({
|
||||
summary: "Request freight consolidation",
|
||||
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.",
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
})
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 9. Remove consolidation pairing ───────────────────────────────────
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Remove consolidation pairing",
|
||||
description:
|
||||
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
||||
})
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@Delete(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Remove consolidation pairing' })
|
||||
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
}
|
||||
|
||||
// ── 10. Get consolidation details ─────────────────────────────────────
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Get consolidation details",
|
||||
description:
|
||||
"Returns partner booking details and split billing information.",
|
||||
})
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
@Get(':id/consolidation')
|
||||
@ApiOperation({ summary: 'Get consolidation details' })
|
||||
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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 { MinioModule } from '../minio/minio.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 { BookingTransitionService } from './booking-transition.service';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { PaymentsWebhookController } from './payments-webhook.controller';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.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 { 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({
|
||||
imports: [
|
||||
@@ -24,18 +37,30 @@ import { Booking } from './entities/booking.entity';
|
||||
BookingCargoModifier,
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
]),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
CustomersModule,
|
||||
CompaniesModule,
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
controllers: [BookingsController, PaymentsWebhookController],
|
||||
providers: [
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
ConsolidationService,
|
||||
BookingReferenceDataService,
|
||||
BookingPricingService,
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
ContractSignerRole,
|
||||
} from './entities/booking-contract-signature.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||
|
||||
@@ -56,7 +61,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
// .leftJoinAndSelect('booking.customer', 'customer')
|
||||
.leftJoinAndSelect('booking.train', 'train')
|
||||
.leftJoinAndSelect('booking.serviceType', 'st')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargo')
|
||||
@@ -66,6 +72,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'steps')
|
||||
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
|
||||
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
|
||||
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
|
||||
.where('booking.id = :id', { id })
|
||||
.leftJoinAndMapMany(
|
||||
'booking.files',
|
||||
@@ -216,15 +223,35 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
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(
|
||||
bookingId: string,
|
||||
requiredRole: string,
|
||||
): Promise<BookingApprovalStep | null> {
|
||||
return this.dataSource.getRepository(BookingApprovalStep).findOne({
|
||||
where: { bookingId, requiredRole, status: 'PENDING' },
|
||||
order: { stepOrder: 'ASC' },
|
||||
});
|
||||
const next = await this.findNextPendingApprovalStep(bookingId);
|
||||
if (!next || next.requiredRole !== requiredRole) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Mark an approval step complete. */
|
||||
@@ -277,4 +304,121 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
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';
|
||||
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 { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -16,10 +17,11 @@ import {
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { assertFreightShape } from './booking-freight.util';
|
||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { FilterBookingDto } from './dto/filter-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 { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
@@ -29,7 +31,8 @@ export class BookingsService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly customersService: CustomersService,
|
||||
// private readonly customersService: CustomersService,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
@@ -42,22 +45,23 @@ export class BookingsService {
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
/** Build evaluation input from DTO containers. */
|
||||
private async buildEvalInput(
|
||||
dto: Pick<
|
||||
CreateBookingDto,
|
||||
| 'cargoTypeId'
|
||||
| 'serviceTypeId'
|
||||
| 'paymentCurrency'
|
||||
| 'tradeDirection'
|
||||
| 'isHazardous'
|
||||
| 'allowConsolidation'
|
||||
| 'shippingLineId'
|
||||
| 'containers'
|
||||
>,
|
||||
): Promise<BookingEvaluationInput> {
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
private async buildEvalInput(dto: {
|
||||
freightType: FreightType;
|
||||
cargoTypeId?: string | null;
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: CreateBookingContainerDto[];
|
||||
}): Promise<BookingEvaluationInput> {
|
||||
const containerLines =
|
||||
dto.freightType === 'CONTAINER' ? dto.containers : [];
|
||||
|
||||
const containers = await Promise.all(
|
||||
dto.containers.map(async (c) => {
|
||||
containerLines.map(async (c) => {
|
||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
|
||||
return {
|
||||
@@ -69,13 +73,16 @@ export class BookingsService {
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.cargoTypeId ?? null,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
allowConsolidation: dto.allowConsolidation,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
};
|
||||
@@ -149,24 +156,52 @@ export class BookingsService {
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
let customerId = dto.customerId;
|
||||
if (!customerId) {
|
||||
// let customerId = dto.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) {
|
||||
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);
|
||||
customerId = customer.id;
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
const allowConsolidation = await this.resolveConsolidation(
|
||||
dto.containers,
|
||||
dto.allowConsolidation,
|
||||
);
|
||||
const containers = dto.containers ?? [];
|
||||
assertFreightShape({
|
||||
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);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
|
||||
@@ -174,7 +209,7 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
customerId,
|
||||
companyId,
|
||||
trainId: dto.trainId,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
@@ -185,7 +220,8 @@ export class BookingsService {
|
||||
originYardId: dto.originYardId,
|
||||
destinationYardId: dto.destinationYardId,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
cargoTypeId: dto.cargoTypeId,
|
||||
freightType: dto.freightType,
|
||||
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
|
||||
cargoFreeText: dto.cargoFreeText,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
|
||||
@@ -203,18 +239,19 @@ export class BookingsService {
|
||||
paymentStatus: 'PENDING',
|
||||
});
|
||||
|
||||
await this.bookingsRepository.createContainers(
|
||||
booking.id,
|
||||
dto.containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
if (dto.freightType === 'CONTAINER') {
|
||||
await this.bookingsRepository.createContainers(
|
||||
booking.id,
|
||||
containers.map((c, i) => ({
|
||||
containerTypeId: c.containerTypeId,
|
||||
quantity: c.quantity,
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
weightResult: ruleResult.containerWeightResults[i],
|
||||
})),
|
||||
);
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
@@ -242,24 +279,51 @@ export class BookingsService {
|
||||
files: Express.Multer.File[],
|
||||
): Promise<{ booking: Booking; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
if (existing.status !== 'DRAFT') {
|
||||
throw new BadRequestException('Only DRAFT bookings can be updated');
|
||||
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||
throw new BadRequestException(
|
||||
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ?? [];
|
||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||
let containers =
|
||||
dto.containers ??
|
||||
existing.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ??
|
||||
[];
|
||||
|
||||
const allowConsolidation = await this.resolveConsolidation(
|
||||
containers,
|
||||
dto.allowConsolidation ?? existing.allowConsolidation,
|
||||
);
|
||||
let cargoTypeId =
|
||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||
|
||||
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({
|
||||
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
|
||||
freightType,
|
||||
cargoTypeId,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
@@ -275,6 +339,8 @@ export class BookingsService {
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
};
|
||||
@@ -285,7 +351,7 @@ export class BookingsService {
|
||||
|
||||
await this.bookingsRepository.update(id, updates);
|
||||
|
||||
if (dto.containers) {
|
||||
if (freightType === 'CONTAINER' && dto.containers) {
|
||||
await this.bookingsRepository.deleteContainers(id);
|
||||
await this.bookingsRepository.createContainers(
|
||||
id,
|
||||
@@ -322,10 +388,12 @@ export class BookingsService {
|
||||
|
||||
const where: Record<string, unknown> = {};
|
||||
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.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.freightType) where.freightType = filter.freightType;
|
||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.allowConsolidation !== undefined) {
|
||||
@@ -345,6 +413,8 @@ export class BookingsService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
order: { [sortField]: sortDir },
|
||||
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
|
||||
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
||||
});
|
||||
return { items, total };
|
||||
}
|
||||
@@ -390,201 +460,32 @@ export class BookingsService {
|
||||
await this.bookingsRepository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Unified status transition handler. */
|
||||
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
const { action, actorId, reason, requiredRole } = dto;
|
||||
async findQueue(
|
||||
queue: string,
|
||||
filter: FilterBookingDto,
|
||||
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) {
|
||||
case 'SUBMIT':
|
||||
return this.handleSubmit(booking);
|
||||
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 status = statusMap[queue];
|
||||
if (!status) {
|
||||
throw new BadRequestException(`Unknown queue: ${queue}`);
|
||||
}
|
||||
|
||||
const step = await this.bookingsRepository.findPendingApprovalStep(
|
||||
booking.id,
|
||||
requiredRole,
|
||||
);
|
||||
if (!step) {
|
||||
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
|
||||
}
|
||||
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
return this.bookingsRepository.findQueue({
|
||||
status,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
excludeBulk: options?.excludeBulk ?? queue === 'approval',
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
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 { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -11,9 +12,12 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} 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 EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
|
||||
@@ -24,6 +28,7 @@ export {
|
||||
BOOKING_STATUSES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
TRADE_DIRECTIONS,
|
||||
PAYMENT_CURRENCIES,
|
||||
};
|
||||
@@ -47,16 +52,24 @@ export class CreateBookingContainerDto {
|
||||
}
|
||||
|
||||
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)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
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()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@@ -107,9 +120,17 @@ export class CreateBookingDto {
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
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()
|
||||
cargoTypeId!: string;
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200 })
|
||||
@IsOptional()
|
||||
@@ -157,11 +178,16 @@ export class CreateBookingDto {
|
||||
@IsString()
|
||||
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()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingContainerDto)
|
||||
containers!: CreateBookingContainerDto[];
|
||||
containers?: CreateBookingContainerDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
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 {
|
||||
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
|
||||
@@ -9,10 +14,15 @@ export class FilterBookingDto {
|
||||
@IsIn([...BOOKING_STATUSES])
|
||||
status?: string;
|
||||
|
||||
// @ApiPropertyOptional({ format: 'uuid' })
|
||||
// @IsOptional()
|
||||
// @IsUUID()
|
||||
// customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
companyId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@@ -28,6 +38,11 @@ export class FilterBookingDto {
|
||||
@IsUUID()
|
||||
cargoTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@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 { 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 { ServiceType } from '../../rule-engine/entities/service-type.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 { BookingContainer } from './booking-container.entity';
|
||||
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote } from './booking-review-note.entity';
|
||||
|
||||
export const BOOKING_STATUSES = [
|
||||
'DRAFT',
|
||||
'RFQ_SUBMITTED',
|
||||
'QUOTATION_SENT',
|
||||
'QUOTATION_APPROVED',
|
||||
'QUOTATION_REJECTED',
|
||||
'SUBMITTED',
|
||||
'CHANGES_REQUESTED',
|
||||
'PENDING_APPROVAL',
|
||||
'APPROVED_PENDING_SIGNATURE',
|
||||
'APPROVED',
|
||||
'CONTRACT_READY',
|
||||
'SIGNED_CUSTOMER',
|
||||
'FULLY_EXECUTED',
|
||||
'PNR_GENERATED',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'PAID',
|
||||
'IN_TRANSIT',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CONSOLIDATED',
|
||||
] 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' })
|
||||
export class Booking extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid' })
|
||||
customerId!: string;
|
||||
// Legacy — superseded by companyId (column kept in DB)
|
||||
// @Column({ name: 'customer_id', type: 'uuid' })
|
||||
// customerId!: string;
|
||||
// @ManyToOne(() => Customer)
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@ManyToOne(() => Customer)
|
||||
@JoinColumn({ name: 'customer_id' })
|
||||
customer?: Customer;
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company;
|
||||
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
@@ -104,8 +137,11 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
|
||||
tradeDirection!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid' })
|
||||
cargoTypeId!: string;
|
||||
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
|
||||
freightType!: string;
|
||||
|
||||
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => CargoType)
|
||||
@JoinColumn({ name: 'cargo_type_id' })
|
||||
@@ -169,6 +205,27 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
|
||||
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 })
|
||||
priorityScore!: number;
|
||||
|
||||
@@ -194,6 +251,9 @@ export class Booking extends BaseEntity {
|
||||
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
|
||||
rateSnapshots?: BookingRateSnapshot[];
|
||||
|
||||
@OneToMany(() => BookingReviewNote, (n) => n.booking)
|
||||
reviewNotes?: BookingReviewNote[];
|
||||
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
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);
|
||||
}
|
||||
|
||||
@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()
|
||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||
|
||||
@@ -21,6 +21,15 @@ export class FileUploadSettingsRepository
|
||||
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. */
|
||||
findByCode(code: string): Promise<FileUploadSetting | null> {
|
||||
return this.repository.findOne({
|
||||
|
||||
@@ -33,6 +33,10 @@ export class FileUploadSettingsService {
|
||||
return setting;
|
||||
}
|
||||
|
||||
getByEntity(entity: string): Promise<FileUploadSetting[]> {
|
||||
return this.repository.findByEntity(entity);
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface IFileUploadSettingsRepository {
|
||||
findAll(): Promise<FileUploadSetting[]>;
|
||||
findById(id: string): Promise<FileUploadSetting | null>;
|
||||
findByCode(code: string): Promise<FileUploadSetting | null>;
|
||||
findByEntity(entity: string): Promise<FileUploadSetting[]>;
|
||||
|
||||
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
|
||||
update(
|
||||
|
||||
@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
): Promise<FileRecord | null> {
|
||||
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(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
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({
|
||||
providers: [NotificationsService],
|
||||
imports: [HttpModule],
|
||||
controllers: [],
|
||||
providers: [EmailNotificationStrategy, SmsNotificationStrategy, 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()
|
||||
export class NotificationsService {
|
||||
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.
|
||||
* 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 {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 { RatesService } from '../services/rates.service';
|
||||
|
||||
@@ -38,9 +44,13 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
|
||||
create(@Body() dto: CreateRateDto) {
|
||||
return this.service.create(dto);
|
||||
create(
|
||||
@Body() dto: CreateRateDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.service.create(dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -56,9 +66,13 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'CEO approves a rate' })
|
||||
approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
|
||||
return this.service.approve(id, dto);
|
||||
approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.service.approve(id, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -35,10 +35,6 @@ export class CreateRateDto {
|
||||
@IsIn([...RATE_UNITS])
|
||||
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' })
|
||||
@IsDateString()
|
||||
effectiveFrom!: string;
|
||||
@@ -49,12 +45,6 @@ export class CreateRateDto {
|
||||
effectiveTo?: string;
|
||||
}
|
||||
|
||||
export class ApproveRateDto {
|
||||
@ApiProperty({ description: 'ID of the CEO approving this rate' })
|
||||
@IsUUID()
|
||||
approvedByCeoId!: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
|
||||
@IsOptional()
|
||||
|
||||
@@ -47,7 +47,8 @@ export interface BookingContainerEvalInput {
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
cargoTypeId: string;
|
||||
cargoTypeId?: string | null;
|
||||
freightType?: 'CONTAINER' | 'BULK';
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
@@ -115,13 +116,19 @@ export class RuleEngineService {
|
||||
let priorityScore = 0;
|
||||
let requiresDirectorApproval = false;
|
||||
|
||||
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
|
||||
if (!cargoType) {
|
||||
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
|
||||
} else if (cargoType.requiresDirectorApproval) {
|
||||
if (input.freightType === 'BULK') {
|
||||
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) {
|
||||
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
|
||||
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[]> {
|
||||
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
|
||||
if (!cargoType) {
|
||||
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
|
||||
async instantiateApprovalSteps(
|
||||
bookingId: string,
|
||||
options: {
|
||||
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(
|
||||
cargoType.requiresDirectorApproval,
|
||||
requiresDirectorApproval,
|
||||
);
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Rate } from '../entities/rate.entity';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
@@ -46,7 +46,7 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/** Create a rate in DRAFT status. */
|
||||
async create(dto: CreateRateDto): Promise<Rate> {
|
||||
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
||||
return this.repository.create({
|
||||
rateType: dto.rateType as Rate['rateType'],
|
||||
containerTypeId: dto.containerTypeId,
|
||||
@@ -55,7 +55,7 @@ export class RatesService {
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit: dto.rateUnit as Rate['rateUnit'],
|
||||
status: 'DRAFT',
|
||||
proposedByStaffId: dto.proposedByStaffId,
|
||||
proposedByStaffId,
|
||||
effectiveFrom: new Date(dto.effectiveFrom),
|
||||
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
|
||||
});
|
||||
@@ -74,7 +74,6 @@ export class RatesService {
|
||||
if (dto.currency) updates.currency = dto.currency;
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
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.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
|
||||
const updated = await this.repository.update(id, updates);
|
||||
@@ -93,14 +92,14 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/** 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);
|
||||
if (rate.status !== 'PENDING_APPROVAL') {
|
||||
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
||||
}
|
||||
const updated = await this.repository.update(id, {
|
||||
status: 'LIVE',
|
||||
approvedByCeoId: dto.approvedByCeoId,
|
||||
approvedByCeoId: approverUserId,
|
||||
approvedAt: new Date(),
|
||||
});
|
||||
return updated!;
|
||||
|
||||
@@ -28,6 +28,7 @@ export class SurchargeTypesService {
|
||||
|
||||
const [data, total] = await this.repository.findAndCount({
|
||||
where,
|
||||
relations: { rate: true },
|
||||
order: { label: 'ASC' },
|
||||
skip: (page - 1) * 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
Boxes,
|
||||
FileText,
|
||||
@@ -8,12 +7,17 @@ import {
|
||||
Paperclip,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Container,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
|
||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||
import LoadingScreen from "./components/LoadingScreen";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
import LoginPage from "./pages/auth/LoginPage";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -30,16 +34,11 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import WagonsPage from "./pages/wagons/WagonsPage";
|
||||
import ContainersPage from "./pages/containers_management/ContainersPage";
|
||||
import CargoesPage from "./pages/cargoes/CargoesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -59,6 +58,31 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
...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",
|
||||
items: [
|
||||
@@ -188,18 +212,15 @@ const App = () => {
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Routes>
|
||||
<Route path="/" 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/: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/users" element={<UsersPage />} />
|
||||
@@ -251,8 +282,7 @@ const App = () => {
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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