Merge freight/develop into feature/trains-management

This commit is contained in:
hagiye
2026-06-05 10:21:39 +03:00
179 changed files with 12700 additions and 3160 deletions

View File

@@ -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
}
}

View File

@@ -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"
}
}
}

View File

@@ -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();
}
}

View 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;
}

View 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;
}

View 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');
}
}

View File

@@ -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),
})),
};
}
}

View File

@@ -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');
});
});

View File

@@ -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;
}
}

View File

@@ -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);
});
});

View 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 AbabaDjibouti 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);
}

View File

@@ -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');
});
});

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
<div class="article">
<h2>Article 6: Contract Documents</h2>
<ol>
{{#each template.contractDocuments}}
<li>{{this}}</li>
{{/each}}
</ol>
</div>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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;`);
}
}

View File

@@ -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');
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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;
}
}

View File

@@ -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');
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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(', ')}`,
);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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],
})

View File

@@ -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));
}
}

View File

@@ -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<{

View File

@@ -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>;
}

View File

@@ -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()

View File

@@ -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])

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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';
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
})

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCompanyDto } from './create-company.dto';
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateExternalProfileDto } from './create-external-profile.dto';
export class UpdateExternalProfileDto extends PartialType(CreateExternalProfileDto) {}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateFFClientDto } from './create-ff-client.dto';
export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {}

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 });
}
}

View File

@@ -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,
});
}
}

View File

@@ -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) {

View File

@@ -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({

View File

@@ -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`);

View File

@@ -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(

View File

@@ -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 });
}
}

View File

@@ -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,

View File

@@ -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 { }

View File

@@ -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}`)
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,6 @@
import { Injectable } from "@nestjs/common";
@Injectable()
export abstract class NotificationStrategy {
abstract send(recipient: string, message: string): Promise<boolean>
}

View File

@@ -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')

View File

@@ -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()

View File

@@ -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);

View File

@@ -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!;

View File

@@ -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,

View 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",
);
}
}

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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
}
/>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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