diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 5f2d2b8ee..90aa517ff 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -23,6 +23,10 @@ SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= DEFAULT_PASSWORD=password@tria +# Freight org + staff (bookings / rule-engine IAM) +SEED_EDR_ORG=true +SEED_FREIGHT_STAFF=true + # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost MINIO_PORT=9000 diff --git a/apps/edr-freight-api/nest-cli.json b/apps/edr-freight-api/nest-cli.json index 6c524a8a1..f4a3b488d 100644 --- a/apps/edr-freight-api/nest-cli.json +++ b/apps/edr-freight-api/nest-cli.json @@ -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 } } diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 41c570010..e086598b8 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -32,11 +32,12 @@ "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", - "typeorm": "^0.3.20" + "rxjs": "^7.8.1" }, "devDependencies": { "@edr/api-common": "workspace:*", @@ -58,6 +59,7 @@ "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "typeorm": "^1.0.0", "typescript": "^5.5.4" }, "jest": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1f9e7a5ce..75826364c 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,13 @@ import databaseConfig from "./config/database.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; -import { TrainsModule } from "./modules/trains/trains.module"; + +//import { TrainsModule } from "./modules/trains/trains.module"; +import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; +import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; +import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; +import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; +import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; @@ -24,13 +30,23 @@ import { OtpModule } from './modules/otp/otp.module'; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; +import { FreightAuthModule } from "./modules/auth/freight-auth.module"; import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_PERMISSIONS, } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; +import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; +import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +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: [ @@ -60,6 +76,11 @@ import { PaymentModule } from "./modules/payment/payment.module"; FilesModule, ConsignmentsModule, TrainsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + TrainSchedulingModule, CustomersModule, CompaniesModule, TrackingModule, @@ -71,20 +92,34 @@ import { PaymentModule } from "./modules/payment/payment.module"; RuleEngineModule, BackofficeModule, DemoPermissionsModule, - PaymentModule + FreightAuthModule, + PaymentModule, + //New Modules + TrainsModule, + WagonsModule, + ContainersModule, + CargoesModule, ], - providers: [EdrOrgSeeder, DemoUsersSeeder], + providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], }) export class AppModule implements OnApplicationBootstrap { constructor( private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly demoUsersSeeder: DemoUsersSeeder, + private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + private readonly demoBookingsSeeder: DemoBookingsSeeder, + private readonly pricingDataSeeder: PricingDataSeeder, + private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, ) { } async onApplicationBootstrap() { await this.seeder.run(); await this.edrOrgSeeder.run(); await this.demoUsersSeeder.run(); + await this.freightStaffUsersSeeder.run(); + await this.demoBookingsSeeder.run(); + await this.pricingDataSeeder.run(); + await this.fileUploadSettingsSeeder.run(); } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts new file mode 100644 index 000000000..2ebae8175 --- /dev/null +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -0,0 +1,17 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +export const BookingStaff = (permission: string | string[]) => + applyDecorators( + UseGuards( + JwtGuard, + FreightPermissionGuard( + Array.isArray(permission) ? permission : [permission], + ), + ), + ); + +export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); diff --git a/apps/edr-freight-api/src/common/freight-permission.guard.ts b/apps/edr-freight-api/src/common/freight-permission.guard.ts new file mode 100644 index 000000000..68def6440 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.guard.ts @@ -0,0 +1,38 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Type, + UnauthorizedException, +} from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { hasFreightPermission } from './freight-permission.util'; + +export function FreightPermissionGuard( + permissions: string[], +): Type { + @Injectable() + class FreightPermissionsGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: TCurrentUser }>(); + const user = request.user; + + if (!permissions?.length) return true; + if (!user) { + throw new UnauthorizedException('Authentication required'); + } + + if (permissions.some((p) => hasFreightPermission(user, p))) { + return true; + } + + throw new ForbiddenException( + `Missing permission. Required one of: ${permissions.join(', ')}`, + ); + } + } + + return FreightPermissionsGuard; +} diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts new file mode 100644 index 000000000..69596c21d --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -0,0 +1,109 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +const SUPER_ADMIN_ROLE = 'super_admin'; +const ORGANIZATION_ADMIN_ROLE = 'organization_admin'; + +type PermissionLike = { key?: string }; +type MeLikeUser = { + roles?: { key?: string }[]; + permissions?: PermissionLike[]; + employee?: + | { + position?: { permissions?: PermissionLike[] }; + delegatedPositions?: { permissions?: PermissionLike[] }[]; + } + | { + positions?: { permissions?: PermissionLike[] }[]; + }[] + | null; +}; + +export function isSuperAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === SUPER_ADMIN_ROLE); +} + +export function isOrganizationAdmin(user: MeLikeUser | null | undefined): boolean { + if (!user?.roles?.length) return false; + return user.roles.some((r) => r.key === ORGANIZATION_ADMIN_ROLE); +} + +export function isFreightApprovalAdmin(user: MeLikeUser | null | undefined): boolean { + return isSuperAdmin(user) || isOrganizationAdmin(user); +} + +/** Flat permission keys from JWT / session user (roles + position permissions). */ +export function collectPermissionKeys(user: MeLikeUser | null | undefined): string[] { + if (!user) return []; + + const keys = new Set(); + + for (const p of user.permissions ?? []) { + if (p.key) keys.add(p.key); + } + + const employee = user.employee; + if (!employee) { + return [...keys]; + } + + if (Array.isArray(employee)) { + for (const emp of employee) { + for (const pos of emp.positions ?? []) { + for (const p of pos.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + } + return [...keys]; + } + + for (const p of employee.position?.permissions ?? []) { + if (p.key) keys.add(p.key); + } + for (const delegated of employee.delegatedPositions ?? []) { + for (const p of delegated.permissions ?? []) { + if (p.key) keys.add(p.key); + } + } + + return [...keys]; +} + +export function hasFreightPermission( + user: MeLikeUser | null | undefined, + permissionKey: string, +): boolean { + if (!user) return false; + if (isSuperAdmin(user)) return true; + return collectPermissionKeys(user).includes(permissionKey); +} + +export function assertFreightPermission( + user: TCurrentUser | MeLikeUser | null | undefined, + permissionKey: string, +): void { + if (hasFreightPermission(user, permissionKey)) return; + throw new ForbiddenException(`Missing permission: ${permissionKey}`); +} + +const APPROVE_ROLE_PERMISSION: Record = { + LINE_STAFF: FREIGHT_PERMS.bookings.approveLineStaff, + DIRECTOR: FREIGHT_PERMS.bookings.approveDirector, + CEO: FREIGHT_PERMS.bookings.approveCeo, +}; + +export function assertCanApproveBookingStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): void { + if (isFreightApprovalAdmin(user)) return; + const perm = APPROVE_ROLE_PERMISSION[requiredRole]; + if (!perm) { + throw new ForbiddenException(`Unknown approval role: ${requiredRole}`); + } + assertFreightPermission(user, perm); +} diff --git a/apps/edr-freight-api/src/common/resolve-auth-user-id.ts b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts new file mode 100644 index 000000000..cab29b671 --- /dev/null +++ b/apps/edr-freight-api/src/common/resolve-auth-user-id.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/common/rule-engine-guards.ts b/apps/edr-freight-api/src/common/rule-engine-guards.ts new file mode 100644 index 000000000..12ba30e11 --- /dev/null +++ b/apps/edr-freight-api/src/common/rule-engine-guards.ts @@ -0,0 +1,18 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; + +import { FreightPermissionGuard } from './freight-permission.guard'; +import { + FREIGHT_PERMS, + type RuleEngineResourceSlug, +} from '../seed/freight-permissions.registry'; + +export const RuleEngineView = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.view(slug)])), + ); + +export const RuleEngineManage = (slug: RuleEngineResourceSlug) => + applyDecorators( + UseGuards(JwtGuard, FreightPermissionGuard([FREIGHT_PERMS.ruleEngine.manage(slug)])), + ); diff --git a/apps/edr-freight-api/src/contracts/contract-clause-packs.ts b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts new file mode 100644 index 000000000..2d73cd50d --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-clause-packs.ts @@ -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 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; +} diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts new file mode 100644 index 000000000..9e0acc6fb --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -0,0 +1,116 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +@Injectable() +export class ContractPdfService { + private readonly logger = new Logger(ContractPdfService.name); + + async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import('puppeteer'); + const launchOptions: import('puppeteer').LaunchOptions = { + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + ], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { + waitUntil: 'load', + timeout: 60_000, + }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 400)); + + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '', + footerTemplate: + '
Page of
', + margin: { top: '18mm', bottom: '22mm', left: '14mm', right: '14mm' }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error( + `Puppeteer produced invalid PDF (${buffer.length} bytes)`, + ); + } + this.logger.log( + `Contract PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (err) { + this.logger.error( + `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, + ); + throw new InternalServerErrorException( + 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes('contract-pdf-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${PDF_PRINT_STYLES}`); + } + return `${PDF_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((p) => existsSync(p)); + } + + private isValidPdf(buffer: Buffer): boolean { + return ( + buffer.length >= MIN_VALID_PDF_BYTES && + buffer.subarray(0, 5).toString('ascii') === '%PDF-' + ); + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts new file mode 100644 index 000000000..dd961a14b --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-pricing-schedule.builder.ts @@ -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 { + 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 ?? '—', + 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), + })), + }; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts new file mode 100644 index 000000000..a66a516c0 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.spec.ts @@ -0,0 +1,82 @@ +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', + vatNumber: 'VAT-001', + fanNumber: 'FAN-001', + businessLicense: 'BL-001', + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: { + originLabel: 'SGTD', + destinationLabel: 'Modjo', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + serviceType: 'Rail transport', + scheduledDate: '1 January 2026', + contractType: 'NEW', + cargoDescription: 'Container cargo', + totalWeightVgm: '24 tons', + equipmentReturn: 'RETURN', + hazardousLabel: 'No', + firstMilePickupAddress: '—', + lastMileDeliveryAddress: '—', + }, + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-renderer.service.ts b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts new file mode 100644 index 000000000..dd539df25 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-renderer.service.ts @@ -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(); + + 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; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts new file mode 100644 index 000000000..2c921889a --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.registry.ts b/apps/edr-freight-api/src/contracts/contract-template.registry.ts new file mode 100644 index 000000000..3e91e1b4f --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.registry.ts @@ -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 = { + IMP: 'Import', + EXP: 'Export', + DOM: 'Domestic', +}; + +const FREIGHT_LABELS: Record = { + CON: 'Container', + BULK: 'Bulk', +}; + +const DIRECTIONS: ContractDirection[] = ['IMP', 'EXP', 'DOM']; +const FREIGHTS: ContractFreight[] = ['CON', 'BULK']; +const CURRENCIES = ['ETB', 'USD'] as const; +const SERVICES: ContractServiceScope[] = ['TRANSPORT_ONLY', 'FORWARDING']; + +const KEY_PATTERN = + /^(IMP|EXP|DOM)_(CON|BULK)_(ETB|USD)_(TRANSPORT_ONLY|FORWARDING)$/; + +function buildMeta( + dir: ContractDirection, + freight: ContractFreight, + currency: string, + service: ContractServiceScope, +): ContractTemplateMeta { + const key = `${dir}_${freight}_${currency}_${service}`; + const dirLabel = DIRECTION_LABELS[dir]; + const freightLabel = FREIGHT_LABELS[freight]; + const serviceLabel = + service === 'FORWARDING' ? 'Rail and Forwarding' : 'Transport Only'; + + const corridor = + dir === 'IMP' + ? 'from SGTD railway freight station at Djibouti to Ethiopian dry ports and return of empty containers as applicable' + : dir === 'EXP' + ? 'from Ethiopian dry ports to SGTD and related export corridors' + : 'between designated Ethiopian rail terminals'; + + const clauses = buildClausePack(dir, freight, service); + + return { + key, + direction: dir, + freight, + currency, + serviceScope: service, + title: `${dirLabel} ${freightLabel} Transport Service by Railway (${serviceLabel})`, + directionLabel: dirLabel, + freightLabel, + whereas: `The Client has requested transportation of ${freightLabel.toLowerCase()} cargo ${corridor} using the Addis Ababa–Djibouti Railway line. The Service Provider has agreed to provide services per this contract.`, + article1Objective: article1ObjectiveFromClause(clauses.article1), + article1: clauses.article1, + clientObligations: clauses.clientObligations, + providerObligations: clauses.providerObligations, + contractDocuments: clauses.contractDocuments, + }; +} + +/** Full template matrix (24 keys). */ +export const CONTRACT_TEMPLATE_REGISTRY: Record = + {}; + +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); +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts new file mode 100644 index 000000000..f4cb1c4ee --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.spec.ts @@ -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 { + 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'); + }); +}); diff --git a/apps/edr-freight-api/src/contracts/contract-template.resolver.ts b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts new file mode 100644 index 000000000..daa48a4e7 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.resolver.ts @@ -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'; + } +} diff --git a/apps/edr-freight-api/src/contracts/contract-template.types.ts b/apps/edr-freight-api/src/contracts/contract-template.types.ts new file mode 100644 index 000000000..b056b4c69 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-template.types.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts new file mode 100644 index 000000000..676e4f4ba --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -0,0 +1,207 @@ +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; + vatNumber: string; + fanNumber: string; + businessLicense: string; + }; + provider: { + name: string; + address: string; + phone: string; + email: string; + tinNumber: string; + }; + schedule: { + originLabel: string; + destinationLabel: string; + tradeDirection: string; + freightType: string; + serviceType: string; + scheduledDate: string; + contractType: string; + cargoDescription: string; + totalWeightVgm: string; + equipmentReturn: string; + hazardousLabel: string; + firstMilePickupAddress: string; + lastMileDeliveryAddress: 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.company?.name ?? 'Client', + companyAddress: this.valueOrDash(booking.company?.address), + companyLocation: this.valueOrDash(booking.company?.country), + phone: this.valueOrDash(booking.company?.phone), + email: this.valueOrDash(booking.company?.email), + tinNumber: this.valueOrDash(booking.company?.tin), + vatNumber: this.valueOrDash(booking.company?.vatNumber), + fanNumber: this.valueOrDash(booking.company?.fanNumber), + businessLicense: this.valueOrDash(booking.company?.businessLicense), + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: this.buildSchedule(booking), + 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 { + 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: this.formatDate(row.signedAt), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } + + private buildSchedule(booking: Booking): ContractViewModel['schedule'] { + const cargoName = + booking.freightType === 'BULK' + ? booking.cargoFreeText || + booking.cargoType?.cargoTypeName || + 'Bulk commodity' + : booking.cargoType?.cargoTypeName || 'Container cargo'; + const totalWeight = Number(booking.cargoTotalWeightVgm || 0); + + return { + originLabel: this.yardLabel(booking.originYard), + destinationLabel: this.yardLabel(booking.destinationYard), + tradeDirection: this.valueOrDash(booking.tradeDirection), + freightType: this.valueOrDash(booking.freightType), + serviceType: this.valueOrDash( + booking.serviceType?.serviceName ?? booking.serviceType?.code, + ), + scheduledDate: this.formatDate(booking.scheduledDate), + contractType: this.valueOrDash(booking.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: + totalWeight > 0 ? `${totalWeight.toLocaleString()} tons` : '—', + equipmentReturn: this.valueOrDash(booking.equipmentReturn), + hazardousLabel: booking.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash( + booking.firstMilePickupAddress, + ), + lastMileDeliveryAddress: this.valueOrDash( + booking.lastMileDeliveryAddress, + ), + }; + } + + private yardLabel(yard?: { label?: string; code?: string } | null): string { + return this.valueOrDash(yard?.label ?? yard?.code); + } + + private formatDate(value?: Date | string | null): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + private valueOrDash(value?: string | number | null): string { + if (value === undefined || value === null || value === '') return '—'; + return String(value); + } +} diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs new file mode 100644 index 000000000..d8b3d3fe1 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article1.hbs @@ -0,0 +1,15 @@ +
+

Article 1: Objective and Scope of Services

+

+ 1.1 Objective. + {{template.article1.objective}} +

+ {{#if template.article1.scope.length}} +

1.2 Scope of Services.

+
    + {{#each template.article1.scope}} +
  1. {{this}}
  2. + {{/each}} +
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs new file mode 100644 index 000000000..cb3440739 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -0,0 +1,66 @@ +

Article 5: Contract Price and Terms of Payment

+
+

Contract Price

+

+ The contract price is calculated based on the agreed railway corridor, cargo details, applicable rate + schedule, and any approved operational surcharges. +

+ + + + + + + + + + + + + + + +
Corridor{{pricing.originLabel}} → {{pricing.destinationLabel}}Currency{{pricing.currency}}
Payment currency{{paymentArticle}}Equipment return{{pricing.equipmentReturn}}
+ {{#if pricing.equipmentReturn}} +

Equipment return: {{pricing.equipmentReturn}}

+ {{/if}} + +

Charges

+ + + + + + {{#each pricing.lineItems}} + + + + + + {{/each}} + {{#if pricing.surcharges.length}} + + + + {{#each pricing.surcharges}} + + + + + + {{/each}} + {{/if}} + + + + + +
ItemDescriptionAmount
{{label}}{{description}}{{currency}} {{amount}}
Surcharges and Adjustments
{{label}}{{description}}{{currency}} {{amount}}
Total contract value{{pricing.currency}} {{pricing.totalAmount}}
+

Terms of payment

+

+ Unless otherwise agreed in writing, the Client shall settle the contract value in + {{paymentArticle}} before the service is performed and in accordance with EDR payment + instructions. Bank charges, penalties, demurrage, storage, and third-party charges remain the + responsibility of the Client where applicable. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs new file mode 100644 index 000000000..8a82c72d4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/articles_obligations.hbs @@ -0,0 +1,19 @@ +
+

Article 2: Obligations of the Client

+

The Client shall perform the following obligations in good faith and within the operational timelines communicated by EDR:

+
    + {{#each template.clientObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
+ +
+

Article 3: Obligations of the Service Provider

+

EDR shall provide the agreed railway freight services in accordance with this Agreement and applicable operational rules:

+
    + {{#each template.providerObligations}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs new file mode 100644 index 000000000..adb1ac797 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_documents.hbs @@ -0,0 +1,9 @@ +
+

Article 6: Contract Documents

+

The following documents form part of this Agreement and shall be read together with the signed contract:

+
    + {{#each template.contractDocuments}} +
  1. {{this}}
  2. + {{/each}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs new file mode 100644 index 000000000..9531ac4ae --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/contract_schedule.hbs @@ -0,0 +1,65 @@ +
+

Booking Schedule and Commercial Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Route{{schedule.originLabel}} → {{schedule.destinationLabel}}Trade direction{{schedule.tradeDirection}}
Freight type{{schedule.freightType}}Service type{{schedule.serviceType}}
Scheduled date{{schedule.scheduledDate}}Contract type{{schedule.contractType}}
Cargo{{schedule.cargoDescription}}Total VGM{{schedule.totalWeightVgm}}
Equipment return{{schedule.equipmentReturn}}Hazardous cargo{{schedule.hazardousLabel}}
First mile{{schedule.firstMilePickupAddress}}Last mile{{schedule.lastMileDeliveryAddress}}
+ + {{#if pricing.containerLines.length}} +

Container Details

+ + + + + + + + + + {{#each pricing.containerLines}} + + + + + + {{/each}} + +
Container typeQuantityVGM / unit (tons)
{{label}}{{quantity}}{{vgmPerUnitTons}}
+ {{/if}} +
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs new file mode 100644 index 000000000..d8c1d1287 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/force_majeure.hbs @@ -0,0 +1,12 @@ +
+

Article 4: Force Majeure

+

+ Neither party shall be liable for delay or non-performance caused by events beyond its reasonable control, + including natural disaster, war, civil unrest, government restriction, railway interruption, port closure, + or other force majeure events interpreted under the Ethiopian Civil Code. +

+

+ The affected party shall notify the other party promptly and shall use reasonable efforts to reduce the + effect of the force majeure event on the performance of this Agreement. +

+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs new file mode 100644 index 000000000..05dd450e4 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/signatures_block.hbs @@ -0,0 +1,44 @@ +
+
+

For the Service Provider

+

{{provider.name}}

+ {{#if hasStaffSignature}} + {{#each signatures}} + {{#if (eq role "STAFF")}} +
+ {{#if signatureImageUrl}}Staff signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized EDR representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Authorized representative

+

Role: EDR representative

+

Date:

+ {{/if}} +
+
+

For the Client

+

{{client.companyName}}

+ {{#if hasCustomerSignature}} + {{#each signatures}} + {{#if (eq role "CUSTOMER")}} +
+ {{#if signatureImageUrl}}Customer signature{{/if}} +
+

Name: {{signerDisplayName}}

+

Role: Authorized client representative

+

Date: {{signedAt}}

+ {{/if}} + {{/each}} + {{else}} +
Signature pending
+

Name: Client representative

+

Role: Authorized client representative

+

Date:

+ {{/if}} +
+
diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs new file mode 100644 index 000000000..43a5495ec --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -0,0 +1,258 @@ + diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs new file mode 100644 index 000000000..75f795bce --- /dev/null +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -0,0 +1,91 @@ + + + + + {{template.title}} — {{reference}} + {{> styles}} + + +
+
+
+
EDR
+
+

Ethio-Djibouti Standard Gauge Railway Share Company

+

Freight Transport Contract

+
+
+ +
+

Contract Agreement

+

{{template.title}}

+

{{template.directionLabel}} • {{template.freightLabel}} • {{template.currency}} • {{template.serviceScope}}

+
+ + + + + + + + + + + + + + +
Contract Ref No.{{reference}}Contract Year{{contractYear}}
Contract Date{{contractDate}}Status{{status}}
+
+ +
+

Parties to the Agreement

+

+ This Contract Agreement is made on {{contractDate}} between the Service Provider and the Client named below. +

+ +
+
+

Service Provider

+

{{provider.name}}

+
+
Address
{{provider.address}}
+
Phone
{{provider.phone}}
+
Email
{{provider.email}}
+
TIN
{{provider.tinNumber}}
+
+
+
+

Client

+

{{client.companyName}}

+
+
Address
{{client.companyAddress}}
+
Location
{{client.companyLocation}}
+
Phone
{{client.phone}}
+
Email
{{client.email}}
+
TIN
{{client.tinNumber}}
+
VAT
{{client.vatNumber}}
+
FAN
{{client.fanNumber}}
+
Business license
{{client.businessLicense}}
+
+
+
+
+ + {{> contract_schedule}} + +
+

Whereas

+

{{template.whereas}}

+

Now therefore, the parties agree as follows:

+
+ + {{> article1}} + {{> articles_obligations}} + {{> force_majeure}} + {{> article5_pricing}} + {{> contract_documents}} + {{> signatures_block}} +
+ + diff --git a/apps/edr-freight-api/src/data-source.ts b/apps/edr-freight-api/src/data-source.ts new file mode 100644 index 000000000..b35d79932 --- /dev/null +++ b/apps/edr-freight-api/src/data-source.ts @@ -0,0 +1,20 @@ +// apps/edr-freight-api/src/data-source.ts +import { DataSource } from 'typeorm'; +//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed + +export const AppDataSource = new DataSource({ + type: 'postgres', + host: 'localhost', + port: 5432, + username: 'postgres', + password: '', // Laragon default: empty + database: 'edr_freight', + schema: 'freight', // default schema for entities without an explicit schema + entities: [__dirname + '/**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], + synchronize: false, + logging: true, +}); + +// Optional: call ensurePostgresSchemas before initializing +// But you can also run it separately. \ No newline at end of file diff --git a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts index 43bb0eb02..36e848e64 100644 --- a/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts +++ b/apps/edr-freight-api/src/migrations/1748900000000-MoveCustomersToFreightSchema.ts @@ -43,10 +43,10 @@ export class MoveCustomersToFreightSchema1748900000000 implements MigrationInter CREATE INDEX IF NOT EXISTS "IDX_freight_customers_email" ON freight.customers (email); `); - await queryRunner.query(` - CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id" - ON freight.customers (user_id); - `); + // await queryRunner.query(` + // CREATE INDEX IF NOT EXISTS "IDX_freight_customers_user_id" + // ON freight.customers (user_id); + //`); // Copy rows from public.customers when that legacy table exists await queryRunner.query(` diff --git a/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts new file mode 100644 index 000000000..162672727 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749200000000-BookingFlowRefactor.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingFlowRefactor1749200000000 implements MigrationInterface { + name = 'BookingFlowRefactor1749200000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts new file mode 100644 index 000000000..795d93fc3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddBookingFreightType.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingFreightType1749300000000 implements MigrationInterface { + name = 'AddBookingFreightType1749300000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts index 38620c33f..4df7ea4ce 100644 --- a/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts +++ b/apps/edr-freight-api/src/migrations/1749300000000-AddFanNumberToCompanies.ts @@ -1,19 +1,20 @@ -// import { MigrationInterface, QueryRunner } from 'typeorm'; +import { MigrationInterface, QueryRunner } from 'typeorm'; -// export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { -// name = 'AddFanNumberToCompanies1749300000000'; +export class AddFanNumberToCompanies1749300000000 implements MigrationInterface { + name = 'AddFanNumberToCompanies1749300000000'; -// public async up(queryRunner: QueryRunner): Promise { -// await queryRunner.query(` -// ALTER TABLE freight.companies -// ADD COLUMN fan_number varchar(16) NULL; -// `); -// } + public async up(queryRunner: QueryRunner): Promise { + // 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 { -// await queryRunner.query(` -// ALTER TABLE freight.companies -// DROP COLUMN fan_number; -// `); -// } -// } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS fan_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts new file mode 100644 index 000000000..8126b91ca --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddContractSignatures.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddContractSignatures1749400000000 implements MigrationInterface { + name = 'AddContractSignatures1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts new file mode 100644 index 000000000..5e61797da --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749400000000-AddTrainScheduling.ts @@ -0,0 +1,153 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTrainScheduling1749400000000 implements MigrationInterface { + name = 'AddTrainScheduling1749400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + max_wagons_per_train INT NULL, + supported_load_types TEXT[] NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.locomotives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(32) NOT NULL UNIQUE, + name VARCHAR(100) NULL, + max_pull_weight_tons NUMERIC(10,3) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE', + available_from TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + locomotive_id UUID NOT NULL, + total_weight_tons NUMERIC(10,3) NOT NULL, + total_length_meters NUMERIC(10,3) NOT NULL, + wagon_count INT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_sets_locomotive FOREIGN KEY (locomotive_id) + REFERENCES freight.locomotives(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_set_wagons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL, + wagon_type_id UUID NOT NULL, + sequence_no INT NOT NULL, + capacity_tons NUMERIC(10,3) NOT NULL, + length_meters NUMERIC(10,3) NOT NULL, + assigned_weight_tons NUMERIC(10,3) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_set_wagons_sequence UNIQUE (train_set_id, sequence_no), + CONSTRAINT fk_train_set_wagons_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id) ON DELETE CASCADE, + CONSTRAINT fk_train_set_wagons_wagon_type FOREIGN KEY (wagon_type_id) + REFERENCES freight.wagon_types(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_id UUID NOT NULL UNIQUE, + origin_station_id UUID NOT NULL, + destination_station_id UUID NOT NULL, + scheduled_departure_date TIMESTAMPTZ NOT NULL, + scheduled_arrival_date TIMESTAMPTZ NULL, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_train_schedules_train_set FOREIGN KEY (train_set_id) + REFERENCES freight.train_sets(id), + CONSTRAINT fk_train_schedules_origin FOREIGN KEY (origin_station_id) + REFERENCES freight.yards(id), + CONSTRAINT fk_train_schedules_destination FOREIGN KEY (destination_station_id) + REFERENCES freight.yards(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.train_schedule_bookings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_schedule_id UUID NOT NULL, + booking_id UUID NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT uq_train_schedule_booking UNIQUE (train_schedule_id, booking_id), + CONSTRAINT fk_train_schedule_bookings_schedule FOREIGN KEY (train_schedule_id) + REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + CONSTRAINT fk_train_schedule_bookings_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.wagon_booking_allocations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + train_set_wagon_id UUID NOT NULL, + booking_id UUID NOT NULL, + allocated_weight_tons NUMERIC(10,3) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT fk_wagon_booking_allocations_wagon FOREIGN KEY (train_set_wagon_id) + REFERENCES freight.train_set_wagons(id) ON DELETE CASCADE, + CONSTRAINT fk_wagon_booking_allocations_booking FOREIGN KEY (booking_id) + REFERENCES freight.bookings(id) + ); + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_locomotives_status + ON freight.locomotives(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_sets_status + ON freight.train_sets(status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_train_schedules_departure_status + ON freight.train_schedules(scheduled_departure_date, status); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wagon_booking_allocations_booking + ON freight.wagon_booking_allocations(booking_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_booking_allocations;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedule_bookings;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_schedules;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_set_wagons;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.train_sets;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.locomotives;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_types;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts new file mode 100644 index 000000000..25fbe1806 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749500000000-AddCompanyIdToBookings.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyIdToBookings1749500000000 implements MigrationInterface { + name = 'AddCompanyIdToBookings1749500000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts new file mode 100644 index 000000000..a1830c370 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749600000000-AddBlocksRoleToApprovalStep.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBlocksRoleToApprovalStep1749600000000 implements MigrationInterface { + name = 'AddBlocksRoleToApprovalStep1749600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + ADD COLUMN IF NOT EXISTS blocks_role VARCHAR(30) NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_approval_step + DROP COLUMN IF EXISTS blocks_role; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts new file mode 100644 index 000000000..f972f50c2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749700000000-SeedDefaultApprovalRules.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seed ITMLS US-06 approval chains if missing (standard + bulk). + */ +export class SeedDefaultApprovalRules1749700000000 implements MigrationInterface { + name = 'SeedDefaultApprovalRules1749700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 1, 'LINE_STAFF', 'Review & Approve', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), false, 2, 'DIRECTOR', 'Final Signature', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = false AND step_order = 2 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 1, 'DIRECTOR', 'Review & Approve', 'LINE_STAFF', now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 1 AND deleted_at IS NULL + ); + + INSERT INTO freight.approval_rules + (id, requires_director_approval, step_order, required_role, action_label, blocks_role, created_at, updated_at) + SELECT uuid_generate_v4(), true, 2, 'CEO', 'Final Signature', NULL, now(), now() + WHERE NOT EXISTS ( + SELECT 1 FROM freight.approval_rules + WHERE requires_director_approval = true AND step_order = 2 AND deleted_at IS NULL + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Keep seeded rules on rollback to avoid breaking in-flight bookings. + } +} diff --git a/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts new file mode 100644 index 000000000..374fea724 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749800000000-AddShippingLinesCodeUniqueIndex.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner, TableIndex } from 'typeorm'; + +/** + * shipping_lines was created without a unique index on code; seeder upserts require it. + */ +export class AddShippingLinesCodeUniqueIndex1749800000000 implements MigrationInterface { + name = 'AddShippingLinesCodeUniqueIndex1749800000000'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT 1 FROM pg_indexes WHERE schemaname = 'freight' AND tablename = 'shipping_lines' AND indexdef ILIKE '%UNIQUE%code%' LIMIT 1`, + ); + if (existing.length === 0) { + await queryRunner.createIndex( + 'freight.shipping_lines', + new TableIndex({ + name: 'UQ_shipping_lines_code', + columnNames: ['code'], + isUnique: true, + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('freight.shipping_lines', 'UQ_shipping_lines_code'); + } +} diff --git a/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts new file mode 100644 index 000000000..cbbf914d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1749900000000-CreateFileUploadSettingsTables.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * file_upload_settings / file_upload_fields entities had no migration; seeder requires both tables. + */ +export class CreateFileUploadSettingsTables1749900000000 implements MigrationInterface { + name = 'CreateFileUploadSettingsTables1749900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code VARCHAR(128) NOT NULL, + label VARCHAR(256) NOT NULL, + description TEXT, + entity VARCHAR(32) NOT NULL DEFAULT 'other', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_settings_code" + ON freight.file_upload_settings (code); + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.file_upload_fields ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + setting_id UUID NOT NULL, + file_key VARCHAR(128) NOT NULL, + file_label VARCHAR(256) NOT NULL, + help_text TEXT, + is_required BOOLEAN NOT NULL DEFAULT false, + is_multiple BOOLEAN NOT NULL DEFAULT false, + max_files INTEGER NOT NULL DEFAULT 1, + allowed_extensions TEXT[] NOT NULL DEFAULT '{}'::text[], + max_size_mb INTEGER NOT NULL DEFAULT 10, + display_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT "CHK_file_upload_fields_max_files" CHECK (max_files > 0), + CONSTRAINT "CHK_file_upload_fields_max_size_mb" CHECK (max_size_mb > 0), + CONSTRAINT "FK_file_upload_fields_setting" + FOREIGN KEY (setting_id) + REFERENCES freight.file_upload_settings(id) + ON DELETE CASCADE + ); + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_file_upload_fields_setting_file_key" + ON freight.file_upload_fields (setting_id, file_key); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_fields CASCADE`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.file_upload_settings CASCADE`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts new file mode 100644 index 000000000..b249bd198 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000000-AddTrainExtendedColumns.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Train entity gained extended fields; baseline trains table only had code/capacity/status/notes. + */ +export class AddTrainExtendedColumns1750000000000 implements MigrationInterface { + name = 'AddTrainExtendedColumns1750000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS train_number VARCHAR(20), + ADD COLUMN IF NOT EXISTS train_name VARCHAR(100), + ADD COLUMN IF NOT EXISTS route_id UUID, + ADD COLUMN IF NOT EXISTS origin_station_id UUID, + ADD COLUMN IF NOT EXISTS destination_station_id UUID, + ADD COLUMN IF NOT EXISTS departure_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS arrival_time TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS locomotive_number VARCHAR(50), + ADD COLUMN IF NOT EXISTS remarks TEXT; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_train_number" + ON freight.trains (train_number) + WHERE train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_train_number"`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS remarks, + DROP COLUMN IF EXISTS locomotive_number, + DROP COLUMN IF EXISTS arrival_time, + DROP COLUMN IF EXISTS departure_time, + DROP COLUMN IF EXISTS destination_station_id, + DROP COLUMN IF EXISTS origin_station_id, + DROP COLUMN IF EXISTS route_id, + DROP COLUMN IF EXISTS train_name, + DROP COLUMN IF EXISTS train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts new file mode 100644 index 000000000..a689ba24e --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { FreightMeController } from './freight-me.controller'; +import { FreightMeService } from './freight-me.service'; + +@Module({ + controllers: [FreightMeController], + providers: [FreightMeService], +}) +export class FreightAuthModule {} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts new file mode 100644 index 000000000..b85ecea84 --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { FreightMeService } from './freight-me.service'; + +@ApiTags('auth') +@Controller('me') +@ApiBearerAuth() +export class FreightMeController { + constructor(private readonly freightMeService: FreightMeService) {} + + @Get() + @UseGuards(JwtGuard) + @ApiOperation({ + summary: 'Current user with flat permissionKeys for backoffice gating', + }) + getMe(@CurrentUser() user: TCurrentUser) { + return this.freightMeService.getEnrichedProfile(user); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts new file mode 100644 index 000000000..50c90213b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -0,0 +1,57 @@ +import { Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { + collectPermissionKeys, + isSuperAdmin, +} from '../../common/freight-permission.util'; +import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; + +@Injectable() +export class FreightMeService { + getEnrichedProfile(user: TCurrentUser) { + const employee = user.employee + ? [ + { + id: user.employee.id, + organizationId: user.employee.organizationId, + unitId: user.employee.unitId, + name: user.employee.name, + positions: user.employee.position + ? [ + { + id: user.employee.position.id, + key: user.employee.position.key, + employeePositionId: user.employee.position.employeePositionId, + name: user.employee.position.name, + isDelegate: user.employee.position.isDelegate, + parentPositionId: user.employee.position.parentPositionId, + permissions: user.employee.position.permissions ?? [], + }, + ] + : [], + }, + ] + : []; + + const permissionKeys = collectPermissionKeys(user); + + return { + id: user.id, + email: user.email, + name: user.name, + username: user.username, + phoneNumber: user.phoneNumber, + userType: user.userType, + status: user.status, + hasFinishedRegistration: user.hasFinishedRegistration, + hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding, + roles: user.roles, + permissions: user.permissions, + employee, + permissionKeys, + isSuperAdmin: isSuperAdmin(user), + permissionsCatalog: PERMISSIONS_CATALOG, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts new file mode 100644 index 000000000..ab8a8dfa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -0,0 +1,284 @@ +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 { FileRecord } from '../files/entities/file.entity'; +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 { + const { view } = await this.viewModelBuilder.build(bookingId); + await this.inlineSignatureImages(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, + }; + } + + async generateContract(bookingId: string): Promise { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['APPROVED']); + + const templateKey = this.templateResolver.resolve(booking); + const summary = this.buildContractSummary(booking); + await this.upsertContractPdf(bookingId, booking.reference, templateKey); + + 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) { + const booking = await this.requireBooking(bookingId); + const templateKey = + booking.contractTemplateKey ?? this.templateResolver.resolve(booking); + const record = await this.upsertContractPdf( + bookingId, + booking.reference, + templateKey, + ); + return this.filesService.streamById(record.id); + } + + async signContract( + bookingId: string, + dto: SignContractDto, + options: { signerUserId?: string; ipAddress?: string }, + ): Promise { + 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 = {}; + + 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); + await this.upsertContractPdf( + bookingId, + booking.reference, + booking.contractTemplateKey ?? this.templateResolver.resolve(booking), + ); + return updated!; + } + + async getSignatures(bookingId: string) { + const rows = await this.bookingsRepository.findContractSignatures(bookingId); + const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r)); + await this.inlineSignatureImages(views); + return { signatures: views }; + } + + private async upsertContractPdf( + bookingId: string, + reference: string, + templateKey: string, + ): Promise { + const { view } = await this.viewModelBuilder.build(bookingId); + view.templateKey = templateKey; + view.template = getTemplateMeta(templateKey); + await this.inlineSignatureImages(view.signatures); + + const html = this.renderer.render(view); + const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html); + const file: Express.Multer.File = { + fieldname: 'contract', + originalname: `contract-${reference}.pdf`, + encoding: '7bit', + mimetype: 'application/pdf', + size: pdfBuffer.length, + buffer: pdfBuffer, + stream: Readable.from(pdfBuffer), + destination: '', + filename: '', + path: '', + }; + + return this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'contract', + file, + }); + } + + private async inlineSignatureImages( + signatures: Array<{ signatureImageUrl?: string | null }>, + ): Promise { + for (const sig of signatures) { + if (!sig.signatureImageUrl) continue; + try { + if (sig.signatureImageUrl.startsWith('data:')) continue; + const objectName = this.minioService.getObjectNameFromUrl( + sig.signatureImageUrl, + ); + const stream = await this.minioService.getFileStream(objectName); + const buffer = await this.streamToBuffer(stream); + sig.signatureImageUrl = `data:image/png;base64,${buffer.toString( + 'base64', + )}`; + } catch { + /* keep original url */ + } + } + } + + private streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + stream.on('error', reject); + stream.on('end', () => resolve(Buffer.concat(chunks))); + }); + } + + private decodeSignatureImage(base64: string): Buffer { + const raw = base64.includes(',') ? base64.split(',')[1]! : base64; + return Buffer.from(raw, 'base64'); + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findByIdWithFiles(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts new file mode 100644 index 000000000..e3e97f301 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-freight.util.ts @@ -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'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts new file mode 100644 index 000000000..ce15125c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-list-tabs.config.ts @@ -0,0 +1,54 @@ +export const BOOKING_LIST_TAB_KEYS = [ + 'all', + 'intake', + 'in_approval', + 'approved_contract', + 'payment', + 'operations', + 'completed', + 'closed', +] as const; + +export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number]; + +export const BOOKING_LIST_TABS: ReadonlyArray<{ + key: BookingListTabKey; + statuses: readonly string[] | null; +}> = [ + { key: 'all', statuses: null }, + { key: 'intake', statuses: ['SUBMITTED'] }, + { + key: 'in_approval', + statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + }, + { + key: 'approved_contract', + statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + }, + { key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, + { + key: 'operations', + statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], + }, + { key: 'completed', statuses: ['COMPLETED'] }, + { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, +]; + +export function mapStatusCountsToTabs( + statusCounts: Record, +): Record { + const result = {} as Record; + + for (const tab of BOOKING_LIST_TABS) { + if (!tab.statuses?.length) { + result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0); + continue; + } + result[tab.key] = tab.statuses.reduce( + (sum, status) => sum + (statusCounts[status] ?? 0), + 0, + ); + } + + return result; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts new file mode 100644 index 000000000..c0a865949 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-next-step.util.ts @@ -0,0 +1,68 @@ +import { BookingApprovalStep } from './entities/booking-approval-step.entity'; +import { Booking } from './entities/booking.entity'; + +export interface BookingNextStep { + action: string; + description: string; + requiredRole?: string; +} + +export function computeNextStep( + booking: Pick, + nextPendingStep?: Pick | null, +): BookingNextStep | null { + const { status } = booking; + + switch (status) { + case 'SUBMITTED': + return { + action: 'ACCEPT_INTAKE', + description: 'Line Staff must accept the submission to begin approval', + }; + case 'PENDING_APPROVAL': + case 'APPROVED_PENDING_SIGNATURE': + if (nextPendingStep) { + return { + action: 'APPROVE_STEP', + requiredRole: nextPendingStep.requiredRole, + description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`, + }; + } + return { + action: 'APPROVE_STEP', + description: 'Complete the pending approval step in sequence', + }; + case 'APPROVED': + return { + action: 'GENERATE_CONTRACT', + description: 'Generate the contract document', + }; + case 'CONTRACT_READY': + return { + action: 'CUSTOMER_SIGN', + description: 'Customer must sign the contract', + }; + case 'SIGNED_CUSTOMER': + return { + action: 'STAFF_SIGN', + description: 'Internal staff must counter-sign the contract', + }; + case 'FULLY_EXECUTED': + return { + action: 'PAY', + description: 'Complete in-app payment', + }; + case 'PAID': + return { + action: 'START_TRANSIT', + description: 'Mark shipment as in transit', + }; + case 'IN_TRANSIT': + return { + action: 'COMPLETE', + description: 'Mark shipment complete', + }; + default: + return null; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts new file mode 100644 index 000000000..4697728e7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -0,0 +1,50 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { BookingsRepository } from './bookings.repository'; +import { Booking } from './entities/booking.entity'; +import { assertBookingStatus } from './booking-status.util'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; + +export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {} + +@Injectable() +export class BookingPaymentService { + constructor(private readonly bookingsRepository: BookingsRepository) {} + + async pay( + bookingId: string, + ): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> { + const booking = await this.requireBooking(bookingId); + assertBookingStatus(booking, ['FULLY_EXECUTED']); + + const receipt = this.buildMockReceipt(booking); + + const updated = await this.bookingsRepository.update(bookingId, { + status: 'PAID', + paymentStatus: 'PAID', + } as never); + + return { booking: updated!, receipt }; + } + + private buildMockReceipt(booking: Booking): InAppPaymentReceipt { + const timestamp = Date.now(); + const isEtb = booking.paymentCurrency === 'ETB'; + const prefix = isEtb ? 'TB' : 'CARD'; + const provider = isEtb ? 'TELEBIRR' : 'CARD'; + + return { + success: true, + provider, + providerRef: `${prefix}-${booking.reference}-${timestamp}`, + amount: booking.totalAmount, + currency: booking.paymentCurrency, + paidAt: new Date().toISOString(), + }; + } + + private async requireBooking(id: string): Promise { + const booking = await this.bookingsRepository.findById(id); + if (!booking) throw new NotFoundException(`Booking ${id} not found`); + return booking; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts new file mode 100644 index 000000000..4e47456e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 => r !== null); + + if (rows.length > 0) { + await this.bookingsRepository.createCargoModifiers(rows); + } + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts new file mode 100644 index 000000000..fe9152149 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-status.util.ts @@ -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(', ')}`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts new file mode 100644 index 000000000..56ab6394b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -0,0 +1,319 @@ +import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +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 { computeNextStep, type BookingNextStep } from './booking-next-step.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 { + 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 { + 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); + } + + /** Auto-create booking approval steps from system rules when none exist yet. */ + private async ensureBookingApprovalSteps(booking: Booking): Promise { + if ((booking.approvalSteps?.length ?? 0) > 0) return; + + await this.ruleEngineService.instantiateApprovalSteps(booking.id, { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId, + }); + } + + async acceptIntake(bookingId: string, actorId: string): Promise { + 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 { + 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, + authUser?: TCurrentUser, + ): Promise { + if (authUser) { + assertCanApproveBookingStep(authUser, requiredRole); + } + + let booking = await this.bookingsService.findById(bookingId); + assertBookingStatus(booking, [ + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + ]); + + if ((booking.approvalSteps?.length ?? 0) === 0) { + await this.ensureBookingApprovalSteps(booking); + booking = await this.bookingsService.findById(bookingId); + } + + 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.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 = {}; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const note = await this.bookingsRepository.findLatestReviewNote( + booking.id, + 'CHANGES_REQUESTED', + ); + const summary = + booking.contractSummary ?? + this.contractService.buildContractSummary(booking); + const nextPending = + booking.status === 'PENDING_APPROVAL' || + booking.status === 'APPROVED_PENDING_SIGNATURE' + ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) + : null; + const nextStep = computeNextStep(booking, nextPending); + return { + ...booking, + latestChangeRequestNote: note?.note ?? null, + contractSummary: summary, + nextStep, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 41a1a09c4..ccd598577 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -10,10 +10,15 @@ import { Post, Query, Request, + Res, UploadedFiles, UseInterceptors, -} from "@nestjs/common"; -import { AnyFilesInterceptor } from "@nestjs/platform-express"; +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -21,181 +26,392 @@ 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 { 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 { BookingListSummaryDto } from './dto/booking-list-summary.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, ) {} - // ── 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('list-summary') + @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @ApiOkResponse({ type: BookingListSummaryDto }) + findListSummary(@Query() filter: FilterBookingDto) { + return this.bookingsService.getListSummary(filter); + } + + @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 { 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, + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + async uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], ) { - return this.bookingsService.updateStatus(id, dto); + const booking = await this.bookingsService.uploadDocuments(id, files ?? []); + return this.transitionService.enrichBookingResponse(booking); } - // ── 8. Request or auto-pair consolidation ───────────────────────────── - @Post(":id/consolidation") + @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); + } + + @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') + @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) + @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') + @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) + @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') + @BookingStaff(FREIGHT_PERMS.bookings.reject) + @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') + @BookingStaff([ + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.approveCeo, + ]) + @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: TCurrentUser, + ) { + const booking = await this.transitionService.approveStep( + id, + stepId, + resolveAuthUserId(user), + dto.requiredRole, + user, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/approval-steps/:stepId/reject') + @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) + @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') + @BookingStaff(FREIGHT_PERMS.bookings.generateContract) + @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() res: Response, + ): Promise { + const { stream, record } = await this.contractService.streamContract(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + + @Get(':id/contract') + @ApiOperation({ summary: 'Download contract file (alias)' }) + async downloadContract( + @Param('id', ParseUUIDPipe) id: string, + @Res() res: Response, + ): Promise { + 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') + @BookingStaff(FREIGHT_PERMS.bookings.signStaff) + @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/operations/start-transit') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @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') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @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') + @BookingStaff(FREIGHT_PERMS.bookings.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); } - } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 9785ec270..8fdaff7d1 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,12 +1,18 @@ 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 { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; @@ -14,7 +20,14 @@ 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,19 +37,31 @@ import { Booking } from './entities/booking.entity'; BookingCargoModifier, BookingApprovalStep, BookingRateSnapshot, + BookingReviewNote, + BookingContractSignature, ]), FilesModule, MinioModule, - CustomersModule, + CompaniesModule, + // CustomersModule, RuleEngineModule, ], - controllers: [BookingsController], + controllers: [BookingsController, PayController], providers: [ BookingsService, BookingsRepository, ConsolidationService, BookingReferenceDataService, + BookingPricingService, + BookingTransitionService, + BookingContractService, + BookingPaymentService, + ContractTemplateResolver, + ContractViewModelBuilder, + ContractPricingScheduleBuilder, + ContractRendererService, + ContractPdfService, ], - exports: [BookingsService], + exports: [BookingsService, BookingsRepository], }) export class BookingsModule {} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index f329c942f..9c17eff3e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -1,17 +1,36 @@ 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, SelectQueryBuilder } 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'; +export interface BookingListFilterOptions { + statuses?: string[]; + status?: string; + companyId?: string; + contractType?: string; + serviceTypeId?: string; + cargoTypeId?: string; + freightType?: string; + tradeDirection?: string; + paymentCurrency?: string; + allowConsolidation?: boolean; + consolidationPaired?: string; +} + @Injectable() export class BookingsRepository extends BaseRepository { constructor( @@ -56,7 +75,8 @@ export class BookingsRepository extends BaseRepository { .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 +86,7 @@ export class BookingsRepository extends BaseRepository { .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 +237,33 @@ export class BookingsRepository extends BaseRepository { 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 { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + }); + } + + async findApprovalStepById( + bookingId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(BookingApprovalStep).findOne({ + where: { bookingId, id: stepId }, + }); + } + + /** Get pending approval step for a role (must match next in sequence). */ async findPendingApprovalStep( bookingId: string, requiredRole: string, ): Promise { - 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 +316,273 @@ export class BookingsRepository extends BaseRepository { where: { bookingId, rateId }, }); } + + async createReviewNote( + bookingId: string, + note: string, + type: ReviewNoteType, + authorId?: string, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.save( + repo.create({ bookingId, note, type, authorId: authorId ?? null }), + ); + } + + async findLatestReviewNote( + bookingId: string, + type?: ReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(BookingReviewNote); + return repo.findOne({ + where: type ? { bookingId, type } : { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + + async clearPricingArtifacts(bookingId: string): Promise { + await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId }); + await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId }); + } + + /** 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.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.cargoType', 'cargo') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.status IN (:...statuses)', { statuses }); + + if (options.excludeBulk) { + qb.andWhere("booking.freight_type = 'CONTAINER'"); + } + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + /** Paginated list with optional multi-status filter (API tab queues). */ + async findAllPaginated(options: BookingListFilterOptions & { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }): Promise<{ items: Booking[]; total: number }> { + const page = options.page; + const pageSize = options.pageSize; + + const qb = this.repository + .createQueryBuilder('booking') + .leftJoinAndSelect('booking.company', 'company') + .leftJoinAndSelect('booking.originYard', 'originYard') + .leftJoinAndSelect('booking.destinationYard', 'destinationYard') + .leftJoinAndSelect('booking.serviceType', 'serviceType') + .leftJoinAndSelect('booking.approvalSteps', 'approvalSteps') + .where('booking.deleted_at IS NULL'); + + this.applyListFilters(qb, options); + + const sortField = + options.sortBy === 'priorityScore' + ? 'booking.priorityScore' + : 'booking.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + return { items, total }; + } + + async getStatusCounts(): Promise> { + const rows = await this.repository + .createQueryBuilder('booking') + .select('booking.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('booking.deleted_at IS NULL') + .groupBy('booking.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries( + rows.map((row) => [row.status, Number(row.count)]), + ); + } + + async getListSummaryMetrics( + options: BookingListFilterOptions & { + page: number; + pageSize: number; + needsActionStatuses: readonly string[]; + urgentPriorityThreshold: number; + }, + ): Promise<{ + inQueue: number; + onThisPage: number; + needsAction: number; + urgent: number; + }> { + const baseQb = () => { + const qb = this.repository + .createQueryBuilder('booking') + .where('booking.deleted_at IS NULL'); + this.applyListFilters(qb, options); + return qb; + }; + + const inQueue = await baseQb().getCount(); + + const needsAction = await baseQb() + .andWhere('booking.status IN (:...needsActionStatuses)', { + needsActionStatuses: [...options.needsActionStatuses], + }) + .getCount(); + + const urgent = await baseQb() + .andWhere('booking.priority_score >= :urgentPriorityThreshold', { + urgentPriorityThreshold: options.urgentPriorityThreshold, + }) + .getCount(); + + const offset = (options.page - 1) * options.pageSize; + const onThisPage = Math.min( + options.pageSize, + Math.max(0, inQueue - offset), + ); + + return { inQueue, onThisPage, needsAction, urgent }; + } + + private applyListFilters( + qb: SelectQueryBuilder, + options: BookingListFilterOptions, + ): void { + if (options.statuses?.length) { + qb.andWhere('booking.status IN (:...statuses)', { + statuses: options.statuses, + }); + } else if (options.status) { + qb.andWhere('booking.status = :status', { status: options.status }); + } + + if (options.companyId) { + qb.andWhere('booking.company_id = :companyId', { + companyId: options.companyId, + }); + } + if (options.contractType) { + qb.andWhere('booking.contract_type = :contractType', { + contractType: options.contractType, + }); + } + if (options.serviceTypeId) { + qb.andWhere('booking.service_type_id = :serviceTypeId', { + serviceTypeId: options.serviceTypeId, + }); + } + if (options.cargoTypeId) { + qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + cargoTypeId: options.cargoTypeId, + }); + } + if (options.freightType) { + qb.andWhere('booking.freight_type = :freightType', { + freightType: options.freightType, + }); + } + if (options.tradeDirection) { + qb.andWhere('booking.trade_direction = :tradeDirection', { + tradeDirection: options.tradeDirection, + }); + } + if (options.paymentCurrency) { + qb.andWhere('booking.payment_currency = :paymentCurrency', { + paymentCurrency: options.paymentCurrency, + }); + } + if (options.allowConsolidation !== undefined) { + qb.andWhere('booking.allow_consolidation = :allowConsolidation', { + allowConsolidation: options.allowConsolidation, + }); + } + if (options.consolidationPaired === 'true') { + qb.andWhere('booking.consolidation_partner_id IS NOT NULL'); + } else if (options.consolidationPaired === 'false') { + qb.andWhere('booking.consolidation_partner_id IS NULL'); + } + } + + async findAndCountFiltered(where: FindOptionsWhere, options: { + skip: number; + take: number; + order: Record; + }): Promise<[Booking[], number]> { + return this.repository.findAndCount({ + where, + skip: options.skip, + take: options.take, + order: options.order, + }); + } + + findContractSignatures(bookingId: string): Promise { + return this.dataSource.getRepository(BookingContractSignature).find({ + where: { bookingId }, + relations: ['signatureFile'], + order: { signedAt: 'ASC' }, + }); + } + + findContractSignature( + bookingId: string, + role: ContractSignerRole, + ): Promise { + return this.dataSource.getRepository(BookingContractSignature).findOne({ + where: { bookingId, signerRole: role }, + relations: ['signatureFile'], + }); + } + + async saveContractSignature( + data: Partial, + ): Promise { + 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)); + } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3aaa12a70..87e9298ff 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -4,9 +4,8 @@ import { Injectable, NotFoundException, } 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,20 +15,35 @@ 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 { mapStatusCountsToTabs } from './booking-list-tabs.config'; +import { BookingListSummaryDto } from './dto/booking-list-summary.dto'; import { FilterBookingDto } from './dto/filter-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; -import { UpdateStatusDto } from './dto/update-status.dto'; +import { + BOOKING_STATUSES, + CUSTOMER_EDITABLE_STATUSES, + FreightType, +} from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; import { FileRecord } from '../files/entities/file.entity'; +const URGENT_PRIORITY_THRESHOLD = 1000; +const NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', +] as const; + @Injectable() export class BookingsService { constructor( 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 +56,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 { + /** 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 { + 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 +84,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 +167,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 +220,7 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - customerId, + companyId, trainId: dto.trainId, contractType: dto.contractType, previousContractId: dto.previousContractId, @@ -185,7 +231,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 +250,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 +290,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 +350,8 @@ export class BookingsService { const updates: Record = { ...dto, + freightType, + cargoTypeId: freightType === 'BULK' ? cargoTypeId : null, allowConsolidation, priorityScore: ruleResult.priorityScore, }; @@ -285,7 +362,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, @@ -313,40 +390,88 @@ export class BookingsService { return { booking, warnings }; } + /** Parse comma-separated or repeated status query values. */ + private parseStatusFilter(filter: FilterBookingDto): { + statuses?: string[]; + status?: string; + } { + const allowed = new Set(BOOKING_STATUSES); + const raw = filter.statuses; + const statusList = raw + ? raw + .split(',') + .map((s) => s.trim()) + .filter((s) => allowed.has(s)) + : []; + + if (statusList.length > 0) { + return { statuses: statusList }; + } + if (filter.status && allowed.has(filter.status)) { + return { status: filter.status }; + } + return {}; + } + /** Return a paginated list of bookings matching the filter. */ async findAll( filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); - const where: Record = {}; - if (filter.status) where.status = filter.status; - if (filter.customerId) where.customerId = filter.customerId; - if (filter.contractType) where.contractType = filter.contractType; - if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId; - if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId; - if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection; - if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency; - if (filter.allowConsolidation !== undefined) { - where.allowConsolidation = filter.allowConsolidation; - } - if (filter.consolidationPaired === 'true') { - where.consolidationPartnerId = Not(IsNull()); - } else if (filter.consolidationPaired === 'false') { - where.consolidationPartnerId = IsNull(); - } - - const sortField = filter.sortBy ?? 'createdAt'; - const sortDir = filter.sortOrder ?? 'DESC'; - - const [items, total] = await this.bookingsRepository.findAndCount({ - where, - skip: (page - 1) * pageSize, - take: pageSize, - order: { [sortField]: sortDir }, + return this.bookingsRepository.findAllPaginated({ + page, + pageSize, + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, }); - return { items, total }; + } + + /** Aggregate metrics and tab counts for the backoffice booking list. */ + async getListSummary(filter: FilterBookingDto): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + const listFilter = { + ...statusFilter, + companyId: filter.companyId, + contractType: filter.contractType, + serviceTypeId: filter.serviceTypeId, + cargoTypeId: filter.cargoTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + allowConsolidation: filter.allowConsolidation, + consolidationPaired: filter.consolidationPaired, + }; + + const [statusCounts, metrics] = await Promise.all([ + this.bookingsRepository.getStatusCounts(), + this.bookingsRepository.getListSummaryMetrics({ + ...listFilter, + page, + pageSize, + needsActionStatuses: NEEDS_ACTION_STATUSES, + urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD, + }), + ]); + + return { + metrics, + tabs: mapStatusCountsToTabs(statusCounts), + }; } /** Get a single booking by ID with files. */ @@ -359,7 +484,7 @@ export class BookingsService { if (booking.files && booking.files.length > 0) { booking.files = await Promise.all( booking.files.map(async (file: FileRecord) => { - const objectName = this.extractObjectName(file.url); + const objectName = this.minioService.getObjectNameFromUrl(file.url); const signedUrl = await this.minioService.getSignedUrl(objectName, 300); return { ...file, signedUrl }; }), @@ -369,11 +494,6 @@ export class BookingsService { return booking; } - private extractObjectName(url: string): string { - const parts = url.split('/'); - return parts.slice(4).join('/'); - } - async findByReference(reference: string): Promise { const booking = await this.bookingsRepository.findByReferenceWithFiles(reference); if (!booking) { @@ -382,6 +502,21 @@ export class BookingsService { return this.findById(booking.id); } + /** Upload documents for a DRAFT booking. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const booking = await this.findById(id); + if (booking.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT bookings', + ); + } + await this.filesService.uploadMany(id, 'bookings', files); + return this.findById(id); + } + async remove(id: string): Promise { const booking = await this.findById(id); if (booking.status !== 'DRAFT') { @@ -390,201 +525,33 @@ export class BookingsService { await this.bookingsRepository.softDelete(id); } - /** Unified status transition handler. */ - async updateStatus(id: string, dto: UpdateStatusDto): Promise { - 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 = { + intake: 'SUBMITTED', + approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'], + signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'], + contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], + marketing: 'SIGNED_CUSTOMER', + finance: 'FULLY_EXECUTED', + }; - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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<{ diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts new file mode 100644 index 000000000..30ca4bdb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-list-summary.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class BookingListSummaryMetricsDto { + @ApiProperty({ example: 42 }) + inQueue!: number; + + @ApiProperty({ example: 10 }) + onThisPage!: number; + + @ApiProperty({ example: 8 }) + needsAction!: number; + + @ApiProperty({ example: 3 }) + urgent!: number; +} + +export class BookingListSummaryTabsDto { + @ApiProperty() all!: number; + @ApiProperty() intake!: number; + @ApiProperty() in_approval!: number; + @ApiProperty() approved_contract!: number; + @ApiProperty() payment!: number; + @ApiProperty() operations!: number; + @ApiProperty() completed!: number; + @ApiProperty() closed!: number; +} + +export class BookingListSummaryDto { + @ApiProperty({ type: BookingListSummaryMetricsDto }) + metrics!: BookingListSummaryMetricsDto; + + @ApiProperty({ type: BookingListSummaryTabsDto }) + tabs!: BookingListSummaryTabsDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts new file mode 100644 index 000000000..4af9e535d --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/contract-view.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 0d61752f4..8089d0307 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 912064905..03fe73683 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -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,27 @@ export class FilterBookingDto { @IsIn([...BOOKING_STATUSES]) status?: string; + @ApiPropertyOptional({ + description: + 'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.', + }) + @IsOptional() + @Transform(({ value }) => { + if (value === undefined || value === null || value === '') return undefined; + if (Array.isArray(value)) return value.map(String).join(','); + return String(value); + }) + statuses?: string; + + // @ApiPropertyOptional({ format: 'uuid' }) + // @IsOptional() + // @IsUUID() + // customerId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() - customerId?: string; + companyId?: string; @ApiPropertyOptional() @IsOptional() @@ -28,6 +50,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]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts new file mode 100644 index 000000000..3474bec74 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -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[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts new file mode 100644 index 000000000..50db3864b --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/pay-booking.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InAppPaymentReceiptDto { + @ApiProperty({ example: true }) + success!: boolean; + + @ApiProperty({ example: 'TELEBIRR' }) + provider!: string; + + @ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' }) + providerRef!: string; + + @ApiProperty({ example: 15000 }) + amount!: number; + + @ApiProperty({ example: 'ETB' }) + currency!: string; + + @ApiProperty({ example: '2026-06-05T12:00:00.000Z' }) + paidAt!: string; +} + +export class PayBookingResponseDto { + @ApiProperty({ type: InAppPaymentReceiptDto }) + paymentReceipt!: InAppPaymentReceiptDto; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts new file mode 100644 index 000000000..99855d49f --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/request-changes.dto.ts @@ -0,0 +1,36 @@ +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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts new file mode 100644 index 000000000..0b176ebd5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/sign-contract.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts index 2b97debc6..328e71180 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/update-booking.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts deleted file mode 100644 index a2d6c192d..000000000 --- a/apps/edr-freight-api/src/modules/bookings/dto/update-status.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts new file mode 100644 index 000000000..1365158b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/validators/booking-freight.validator.ts @@ -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'; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts index 4f3032b73..68018e883 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-approval-step.entity.ts @@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity { @Column({ name: 'required_role', type: 'varchar', length: 30 }) requiredRole!: string; + @Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true }) + blocksRole?: string | null; + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) status!: ApprovalStepStatus; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts new file mode 100644 index 000000000..6370c97c2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-contract-signature.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts new file mode 100644 index 000000000..af39a469c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-review-note.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 7875babbf..fb3679a60 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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 | 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, }) diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts new file mode 100644 index 000000000..de66afac5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { BookingPaymentService } from './booking-payment.service'; +import { BookingTransitionService } from './booking-transition.service'; +import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; +import { Booking } from './entities/booking.entity'; +import { BookingNextStep } from './booking-next-step.util'; + +@ApiTags('payments') +@ApiBearerAuth() +@Controller('bookings') +export class PayController { + constructor( + private readonly paymentService: BookingPaymentService, + private readonly transitionService: BookingTransitionService, + ) {} + + @Post(':id/payment/pay') + @ApiOperation({ summary: 'Complete in-app payment (mock)' }) + @ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' }) + async pay(@Param('id', ParseUUIDPipe) id: string): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + paymentReceipt: InAppPaymentReceiptDto; + } + > { + const { booking, receipt } = await this.paymentService.pay(id); + const abstract = await this.transitionService.enrichBookingResponse(booking); + return { ...abstract, paymentReceipt: receipt }; + } +} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts new file mode 100644 index 000000000..5d86a579b --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { CargoesService } from './cargoes.service'; + +@ApiTags('cargoes') +@Controller('cargoes') +export class CargoesController { + constructor(private readonly cargoesService: CargoesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new cargo' }) + create(@Body() dto: CreateCargoDto) { + return this.cargoesService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all cargoes' }) + findAll() { + return this.cargoesService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a cargo by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a cargo' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { + return this.cargoesService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a cargo' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.remove(id); + } + + @Post(':id/load') + @ApiOperation({ summary: 'Load cargo into a container' }) + load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { + return this.cargoesService.loadCargo(id, dto); + } + + @Post(':id/unload') + @ApiOperation({ summary: 'Unload cargo from container' }) + unload(@Param('id', ParseUUIDPipe) id: string) { + return this.cargoesService.unloadCargo(id); + } + + @Post(':id/deliver') + @ApiOperation({ summary: 'Mark cargo as delivered' }) + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { + return this.cargoesService.deliverCargo(id, dto); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts new file mode 100644 index 000000000..8a60d2600 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoesController } from './cargoes.controller'; +import { CargoesService } from './cargoes.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Cargo, Container])], + controllers: [CargoesController], + providers: [CargoesService], + exports: [CargoesService], +}) +export class CargoesModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts new file mode 100644 index 000000000..cedb217da --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Cargo } from './entities/cargoes.entity'; + +@Injectable() +export class CargoesRepository extends BaseRepository { + constructor( + @InjectRepository(Cargo) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts new file mode 100644 index 000000000..c1a6e213e --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateCargoDto } from './dto/create-cargo.dto'; +import { UpdateCargoDto } from './dto/update-cargo.dto'; +import { LoadCargoDto } from './dto/load-cargo.dto'; +import { DeliverCargoDto } from './dto/deliver-cargo.dto'; +import { Cargo } from './entities/cargoes.entity'; +import { Container } from '../container-management/entities/container.entity'; + +@Injectable() +export class CargoesService { + constructor( + @InjectRepository(Cargo) + private readonly cargoRepo: Repository, + @InjectRepository(Container) + private readonly containerRepo: Repository, + ) {} + + async create(dto: CreateCargoDto): Promise { + const cargo = this.cargoRepo.create(dto); + return this.cargoRepo.save(cargo); + } + + async findAll(): Promise { + return this.cargoRepo.find({ order: { cargoReference: 'ASC' } }); + } + + async findById(id: string): Promise { + const cargo = await this.cargoRepo.findOne({ where: { id } }); + if (!cargo) throw new NotFoundException(`Cargo ${id} not found`); + return cargo; + } + + async update(id: string, dto: UpdateCargoDto): Promise { + const cargo = await this.findById(id); + Object.assign(cargo, dto); + return this.cargoRepo.save(cargo); + } + + async remove(id: string): Promise { + const cargo = await this.findById(id); + await this.cargoRepo.remove(cargo); + } + + async loadCargo(id: string, dto: LoadCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'PENDING') { + throw new ConflictException('Cargo already loaded or delivered'); + } + + cargo.status = 'LOADED'; + cargo.loadedAt = new Date(); + cargo.quantity = dto.quantity; + cargo.weight = dto.weight; + cargo.volume = dto.volume ?? null; + if (dto.description) cargo.description = dto.description; + + if (cargo.container) { + cargo.container.status = 'LOADED'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } + + async unloadCargo(id: string): Promise { + const cargo = await this.findById(id); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Cargo is not loaded'); + } + cargo.status = 'UNLOADED'; + cargo.unloadedAt = new Date(); + return this.cargoRepo.save(cargo); + } + + async deliverCargo(id: string, dto?: DeliverCargoDto): Promise { + const cargo = await this.cargoRepo.findOne({ + where: { id }, + relations: { container: true }, // ✅ fixed + }); + if (!cargo) throw new NotFoundException('Cargo not found'); + if (cargo.status !== 'LOADED') { + throw new ConflictException('Only loaded cargo can be delivered'); + } + + cargo.status = 'DELIVERED'; + if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks; + + const remaining = await this.cargoRepo.count({ + where: { containerId: cargo.containerId, status: 'LOADED' }, + }); + if (remaining === 0 && cargo.container) { + cargo.container.status = 'AVAILABLE'; + await this.containerRepo.save(cargo.container); + } + + return this.cargoRepo.save(cargo); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts new file mode 100644 index 000000000..8373f5a4b --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/create-cargo.dto.ts @@ -0,0 +1,45 @@ +import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator'; + +export class CreateCargoDto { + @IsString() + cargoReference!: string; + + @IsUUID() + shipmentId!: string; + + @IsUUID() + containerId!: string; + + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED']) + status?: string; + + @IsOptional() + @IsDateString() + loadedAt?: string; + + @IsOptional() + @IsDateString() + unloadedAt?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts new file mode 100644 index 000000000..020e4d630 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/deliver-cargo.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class DeliverCargoDto { + @IsOptional() + @IsString() + deliveryRemarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts new file mode 100644 index 000000000..9e8573751 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/load-cargo.dto.ts @@ -0,0 +1,20 @@ +import { IsNumber, Min, IsOptional, IsString } from 'class-validator'; + +export class LoadCargoDto { + @IsNumber() + @Min(0.001) + quantity!: number; + + @IsNumber() + @Min(0) + weight!: number; + + @IsOptional() + @IsNumber() + @Min(0) + volume?: number; + + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/unload-cargo.dto.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts new file mode 100644 index 000000000..7596b7dbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/dto/update-cargo.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCargoDto } from './create-cargo.dto'; + +export class UpdateCargoDto extends PartialType(CreateCargoDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts new file mode 100644 index 000000000..7c2f752e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/cargoes/entities/cargoes.entity.ts @@ -0,0 +1,45 @@ +// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Container } from '../../container-management/entities/container.entity'; + +@Entity({ name: 'cargoes', schema: 'freight' }) +export class Cargo extends BaseEntity { + @Column({ unique: true, name: 'cargo_reference' }) + cargoReference!: string; + + @Column({ name: 'shipment_id', type: 'uuid' }) + shipmentId!: string; + + @Column({ name: 'container_id', type: 'uuid' }) + containerId!: string; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId!: string | null; // optional link to cargo_types table + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 3 }) + quantity!: number; + + @Column({ type: 'decimal', precision: 10, scale: 2 }) + weight!: number; // kg + + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + volume!: number | null; // m³ + + @Column({ type: 'varchar', default: 'PENDING' }) + status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED + + @Column({ name: 'loaded_at', type: 'timestamp', nullable: true }) + loadedAt!: Date | null; + + @Column({ name: 'unloaded_at', type: 'timestamp', nullable: true }) + unloadedAt!: Date | null; + + // Relationship to Container + @ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'container_id' }) + container!: Container; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5c646279e..b1481d0d4 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; +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'; @@ -11,6 +13,8 @@ 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; @@ -22,7 +26,10 @@ interface CurrentIamUser { @ApiTags('Companies') @Controller('companies') export class CompaniesController { - constructor(private readonly companiesService: CompaniesService) {} + constructor( + private readonly companiesService: CompaniesService, + private readonly filesService: FilesService, + ) {} @Get('getInfo') @ApiOperation({ summary: 'Get company info for the current user' }) @@ -31,6 +38,22 @@ export class CompaniesController { return new CompanyInfoResponseDto(profile, company); } + @Get('profile') + @ApiOperation({ summary: 'Get flattened profile for the settings page' }) + async getProfile(@CurrentUser() user: CurrentIamUser): Promise { + 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 { + return this.companiesService.updateProfile(user.id, dto); + } + @Post('create') @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) async createWithProfile( @@ -105,6 +128,17 @@ export class CompaniesController { 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, + ) { + return this.filesService.uploadMany(companyId, 'companies', files); + } + @Post(':companyId/profiles') @ApiOperation({ summary: 'Add a profile (employee) to a company' }) async createProfile( diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 8fac2f901..d18573460 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,5 +1,6 @@ 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'; @@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity'; import { FFClient } from './entities/ff-client.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])], + imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule], controllers: [CompaniesController], providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository], exports: [CompaniesService], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 03c104798..f383b9e55 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -7,6 +7,8 @@ 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'; @@ -103,6 +105,42 @@ export class CompaniesService { return updated; } + async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + + const companyUpdates: Record = {}; + const attrUpdates: Record = { ...(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 { await this.findCompanyById(id); await this.companiesRepo.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts new file mode 100644 index 000000000..ee8ede34f --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts new file mode 100644 index 000000000..0acdf60a1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts new file mode 100644 index 000000000..78759d9b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { ContainersService } from './containers.service'; + +@ApiTags('containers') +@Controller('containers') +export class ContainersController { + constructor(private readonly containersService: ContainersService) {} + + @Post() + @ApiOperation({ summary: 'Create a new container' }) + create(@Body() dto: CreateContainerDto) { + return this.containersService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all containers' }) + findAll() { + return this.containersService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a container by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a container' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { + return this.containersService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a container' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.remove(id); + } + + @Post(':id/assign-wagon') + @ApiOperation({ summary: 'Assign container to a wagon' }) + assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { + return this.containersService.assignToWagon(id, dto); + } + + @Post(':id/unassign-wagon') + @ApiOperation({ summary: 'Unassign container from wagon' }) + unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { + return this.containersService.unassignFromWagon(id); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.module.ts b/apps/edr-freight-api/src/modules/container-management/containers.module.ts new file mode 100644 index 000000000..eea118187 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.module.ts @@ -0,0 +1,14 @@ +// apps/edr-freight-api/src/modules/container-management/containers.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; +import { ContainersController } from './containers.controller'; +import { ContainersService } from './containers.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon + controllers: [ContainersController], + providers: [ContainersService], +}) +export class ContainersModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.repository.ts b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts new file mode 100644 index 000000000..c6842cdea --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Container } from './entities/container.entity'; + +@Injectable() +export class ContainersRepository extends BaseRepository { + constructor( + @InjectRepository(Container) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts new file mode 100644 index 000000000..f6094a497 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service copy.ts @@ -0,0 +1,86 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +//import { ContainersRepository } from './containers.repository'; +import { WagonsRepository } from '../wagons/wagons.repository'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + private readonly wagonsRepository: WagonsRepository, + ) {} + + async create(dto: CreateContainerDto): Promise { + const container = this.containerRepo.create(dto); + // Convert undefined to null for optional fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(): Promise { + return this.containerRepo.find({ order: { containerNumber: 'ASC' } }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + Object.assign(container, dto); + // Convert undefined to null for nullable fields + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonsRepository.findById(dto.wagonId); + if (!wagon) throw new NotFoundException('Wagon not found'); + + let position: number | null = dto.position ?? null; // convert undefined to null + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; // now position is number | null, safe + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/containers.service.ts b/apps/edr-freight-api/src/modules/container-management/containers.service.ts new file mode 100644 index 000000000..39f2d8274 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/containers.service.ts @@ -0,0 +1,85 @@ +// apps/edr-freight-api/src/modules/container-management/containers.service.ts +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateContainerDto } from './dto/create-container.dto'; +import { UpdateContainerDto } from './dto/update-container.dto'; +import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; +import { Container } from './entities/container.entity'; +import { Wagon } from '../wagons/entities/wagon.entity'; + +@Injectable() +export class ContainersService { + constructor( + @InjectRepository(Container) + private readonly containerRepo: Repository, + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, // ✅ use raw repository + ) {} + + async create(dto: CreateContainerDto): Promise { + const container = this.containerRepo.create(dto); + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async findAll(): Promise { + return this.containerRepo.find({ order: { containerNumber: 'ASC' } }); + } + + async findById(id: string): Promise { + const container = await this.containerRepo.findOne({ where: { id } }); + if (!container) throw new NotFoundException(`Container ${id} not found`); + return container; + } + + async update(id: string, dto: UpdateContainerDto): Promise { + const container = await this.findById(id); + Object.assign(container, dto); + if (dto.wagonId === undefined) container.wagonId = null; + if (dto.position === undefined) container.position = null; + return this.containerRepo.save(container); + } + + async remove(id: string): Promise { + const container = await this.findById(id); + await this.containerRepo.remove(container); + } + + async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot reassign a loaded container'); + } + + const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } }); + if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`); + + let position: number | null = dto.position ?? null; + if (position === null) { + const maxPos = await this.containerRepo + .createQueryBuilder('c') + .select('MAX(c.position)', 'max') + .where('c.wagonId = :wagonId', { wagonId: wagon.id }) + .getRawOne(); + position = (maxPos?.max ?? 0) + 1; + } + + container.wagonId = wagon.id; + container.position = position; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } + + async unassignFromWagon(containerId: string): Promise { + const container = await this.findById(containerId); + if (container.status === 'LOADED') { + throw new ConflictException('Cannot unassign a loaded container'); + } + container.wagonId = null; + container.position = null; + container.status = 'AVAILABLE'; + return this.containerRepo.save(container); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts new file mode 100644 index 000000000..3b7be1d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/assign-container-to-wagon.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignContainerToWagonDto { + @IsUUID() + wagonId!: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts new file mode 100644 index 000000000..1efed1cc9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/create-container.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; + +export class CreateContainerDto { + @IsString() + containerNumber!: string; + + @IsUUID() + containerTypeId!: string; + + @IsOptional() + @IsUUID() + wagonId?: string; + + @IsOptional() + @IsInt() + @Min(1) + position?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxGrossWeight!: number; + + @IsOptional() + @IsString() + sealNumber?: string; + + @IsOptional() + @IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED']) + status?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts new file mode 100644 index 000000000..7391bc642 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/dto/update-container.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateContainerDto } from './create-container.dto'; + +export class UpdateContainerDto extends PartialType(CreateContainerDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts new file mode 100644 index 000000000..a5c7ee9c1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -0,0 +1,45 @@ +// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { Cargo } from '../../cargoes/entities/cargoes.entity'; + +@Entity({ name: 'containers', schema: 'freight' }) +export class Container extends BaseEntity { + @Column({ unique: true, name: 'container_number' }) + containerNumber!: string; + + @Column({ name: 'container_type_id', type: 'uuid' }) + containerTypeId!: string; + + @Column({ name: 'wagon_id', type: 'uuid', nullable: true }) + wagonId!: string | null; + + @Column({ type: 'int', nullable: true }) + position!: number | null; // position on the wagon (1..N) + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 }) + maxGrossWeight!: number; + + @Column({ + name: 'seal_number', + type: 'varchar', + nullable: true, +}) +sealNumber!: string | null; + + @Column({ type: 'varchar', default: 'AVAILABLE' }) + status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED + + // Relationship to Wagon + @ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'wagon_id' }) + wagon!: Wagon | null; + + // Relationship to Cargo + @OneToMany(() => Cargo, (cargo) => cargo.container) + cargoes!: Cargo[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/files/files.repository.ts b/apps/edr-freight-api/src/modules/files/files.repository.ts index 2ec2b94a2..ea4bfd19e 100644 --- a/apps/edr-freight-api/src/modules/files/files.repository.ts +++ b/apps/edr-freight-api/src/modules/files/files.repository.ts @@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository { ): Promise { return this.repository.findOne({ where: { resourceId, resource, code } }); } + + async deleteByCode( + resourceId: string, + resource: string, + code: string, + ): Promise { + await this.repository.delete({ resourceId, resource, code }); + } } diff --git a/apps/edr-freight-api/src/modules/files/files.service.ts b/apps/edr-freight-api/src/modules/files/files.service.ts index e08fce72b..97a5e9e34 100644 --- a/apps/edr-freight-api/src/modules/files/files.service.ts +++ b/apps/edr-freight-api/src/modules/files/files.service.ts @@ -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 { + const { resourceId, resource, code } = input; + await this.filesRepository.deleteByCode(resourceId, resource, code); + return this.upload(input); + } + async uploadMany( resourceId: string, resource: string, @@ -72,13 +79,8 @@ export class FilesService { async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> { const record = await this.findById(id); - const objectName = this.extractObjectName(record.url); + const objectName = this.minioService.getObjectNameFromUrl(record.url); const stream = await this.minioService.getFileStream(objectName); return { stream, record }; } - - private extractObjectName(url: string): string { - const parts = url.split("/"); - return parts.slice(4).join("/"); - } } diff --git a/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts new file mode 100644 index 000000000..3ee26beb9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/dto/filter-locomotives.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional } from 'class-validator'; + +import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity'; + +export class FilterLocomotivesDto { + @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) + @IsOptional() + @IsIn([...LOCOMOTIVE_STATUSES]) + status?: string; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts new file mode 100644 index 000000000..1676674b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/entities/locomotive.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSet } from '../../train-sets/entities/train-set.entity'; + +export const LOCOMOTIVE_STATUSES = [ + 'AVAILABLE', + 'ASSIGNED', + 'MAINTENANCE', + 'INACTIVE', +] as const; + +export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'locomotives' }) +@Index(['code']) +@Index(['status']) +export class Locomotive extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) + name?: string | null; + + @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + maxPullWeightTons!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) + status!: LocomotiveStatus; + + @Column({ name: 'available_from', type: 'timestamptz', nullable: true }) + availableFrom?: Date | null; + + @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) + trainSets?: TrainSet[]; +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts new file mode 100644 index 000000000..9e64c1e2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { LocomotivesService } from './locomotives.service'; + +@ApiTags('locomotives') +@ApiBearerAuth() +@Controller('locomotives') +export class LocomotivesController { + constructor(private readonly locomotivesService: LocomotivesService) {} + + @Get() + @ApiOperation({ summary: 'List locomotives' }) + findAll(@Query() filter: FilterLocomotivesDto) { + return this.locomotivesService.findAll(filter); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts new file mode 100644 index 000000000..264b9cb44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LocomotivesController } from './locomotives.controller'; +import { Locomotive } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; +import { LocomotivesService } from './locomotives.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Locomotive])], + controllers: [LocomotivesController], + providers: [LocomotivesRepository, LocomotivesService], + exports: [LocomotivesRepository, LocomotivesService], +}) +export class LocomotivesModule {} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts new file mode 100644 index 000000000..af2a40f50 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Locomotive } from './entities/locomotive.entity'; + +@Injectable() +export class LocomotivesRepository extends BaseRepository { + constructor( + @InjectRepository(Locomotive) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts new file mode 100644 index 000000000..946a77d48 --- /dev/null +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.service.ts @@ -0,0 +1,29 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; +import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity'; +import { LocomotivesRepository } from './locomotives.repository'; + +@Injectable() +export class LocomotivesService { + constructor(private readonly locomotivesRepository: LocomotivesRepository) {} + + findAll(filter: FilterLocomotivesDto): Promise { + return this.locomotivesRepository.findAll({ + where: filter.status + ? { status: filter.status as LocomotiveStatus } + : undefined, + order: { code: 'ASC' }, + }); + } + + async findById(id: string): Promise { + const locomotive = await this.locomotivesRepository.findById(id); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${id} not found`); + } + + return locomotive; + } +} diff --git a/apps/edr-freight-api/src/modules/minio/minio.service.ts b/apps/edr-freight-api/src/modules/minio/minio.service.ts index 4dba9c66e..9f7a5e65d 100644 --- a/apps/edr-freight-api/src/modules/minio/minio.service.ts +++ b/apps/edr-freight-api/src/modules/minio/minio.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { ConfigType } from "@nestjs/config"; import { Client } from "minio"; import { Readable } from "stream"; @@ -54,6 +54,30 @@ export class MinioService { return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`; } + getObjectNameFromUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new NotFoundException("File object path is empty"); + } + + if (!/^https?:\/\//i.test(trimmed)) { + return trimmed.replace(/^\/+/, ""); + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] === this.bucket) { + parts.shift(); + } + + const objectName = parts.join("/"); + if (!objectName) { + throw new NotFoundException("File object path is empty"); + } + + return objectName; + } + async deleteFile(objectName: string): Promise { try { await this.client.removeObject(this.bucket, objectName); diff --git a/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts new file mode 100644 index 000000000..19fd8c267 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/approval-rules.defaults.ts @@ -0,0 +1,31 @@ +/** ITMLS US-06 default approval chains — seeded automatically when missing. */ +export const DEFAULT_APPROVAL_RULE_ROWS = [ + { + requiresDirectorApproval: false, + stepOrder: 1, + requiredRole: 'LINE_STAFF', + actionLabel: 'Review & Approve', + blocksRole: null as string | null, + }, + { + requiresDirectorApproval: false, + stepOrder: 2, + requiredRole: 'DIRECTOR', + actionLabel: 'Final Signature', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 1, + requiredRole: 'DIRECTOR', + actionLabel: 'Review & Approve', + blocksRole: 'LINE_STAFF', + }, + { + requiresDirectorApproval: true, + stepOrder: 2, + requiredRole: 'CEO', + actionLabel: 'Final Signature', + blocksRole: null as string | null, + }, +] as const; diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts index 3c34cc71b..72e35b296 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/approval-rules.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto'; import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto'; @@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service'; @ApiTags('approval-rules') @Controller('approval-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ApprovalRulesController { constructor(private readonly service: ApprovalRulesService) {} @Get() + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'List approval rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -28,30 +29,35 @@ export class ApprovalRulesController { } @Get('chain') + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get approval chain for cargo routing flag' }) findChain(@Query('requiresDirectorApproval') flag: string) { return this.service.findChain(flag === 'true'); } @Get(':id') + @RuleEngineView('approval-rules') @ApiOperation({ summary: 'Get an approval rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('approval-rules') @ApiOperation({ summary: 'Create an approval rule step' }) create(@Body() dto: CreateApprovalRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('approval-rules') @ApiOperation({ summary: 'Update an approval rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('approval-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete an approval rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index de855c7ef..4941a5ebb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,18 +3,19 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto'; import { CargoTypesService } from '../services/cargo-types.service'; @ApiTags('cargo-types') @Controller('cargo-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class CargoTypesController { constructor(private readonly service: CargoTypesService) {} @Get() + @RuleEngineView('cargo-types') @ApiOperation({ summary: 'List cargo types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -32,24 +33,28 @@ export class CargoTypesController { } @Get(':id') + @RuleEngineView('cargo-types') @ApiOperation({ summary: 'Get a cargo type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('cargo-types') @ApiOperation({ summary: 'Create a cargo type' }) create(@Body() dto: CreateCargoTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('cargo-types') @ApiOperation({ summary: 'Update a cargo type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('cargo-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a cargo type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 380688309..43dfcec33 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,18 +3,19 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { UpdateContainerTypeDto } from '../dto/update-container-type.dto'; import { ContainerTypesService } from '../services/container-types.service'; @ApiTags('container-types') @Controller('container-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ContainerTypesController { constructor(private readonly service: ContainerTypesService) {} @Get() + @RuleEngineView('container-types') @ApiOperation({ summary: 'List container types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class ContainerTypesController { } @Get(':id') + @RuleEngineView('container-types') @ApiOperation({ summary: 'Get a container type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('container-types') @ApiOperation({ summary: 'Create a container type' }) create(@Body() dto: CreateContainerTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('container-types') @ApiOperation({ summary: 'Update a container type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('container-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a container type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts index d268fc577..bee5cf85b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rules.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto'; import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto'; @@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service'; @ApiTags('priority-rules') @Controller('priority-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class PriorityRulesController { constructor(private readonly service: PriorityRulesService) {} @Get() + @RuleEngineView('priority-rules') @ApiOperation({ summary: 'List priority rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class PriorityRulesController { } @Get(':id') + @RuleEngineView('priority-rules') @ApiOperation({ summary: 'Get a priority rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('priority-rules') @ApiOperation({ summary: 'Create a priority rule' }) create(@Body() dto: CreatePriorityRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('priority-rules') @ApiOperation({ summary: 'Update a priority rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('priority-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a priority rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts index 02408e53d..3c7776a27 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/rates.controller.ts @@ -3,18 +3,24 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } 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 { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +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'; @ApiTags('rates') @Controller('rates') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class RatesController { constructor(private readonly service: RatesService) {} @Get() + @RuleEngineView('rates') @ApiOperation({ summary: 'List rates' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -26,42 +32,55 @@ export class RatesController { } @Get('live') + @RuleEngineView('rates') @ApiOperation({ summary: 'List all LIVE rates effective now' }) findLive() { return this.service.findLiveRates(); } @Get(':id') + @RuleEngineView('rates') @ApiOperation({ summary: 'Get a rate by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('rates') @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') + @RuleEngineManage('rates') @ApiOperation({ summary: 'Update a DRAFT rate' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) { return this.service.update(id, dto); } @Post(':id/submit') + @RuleEngineManage('rates') @ApiOperation({ summary: 'Submit rate for CEO approval' }) submit(@Param('id', ParseUUIDPipe) id: string) { return this.service.submitForApproval(id); } @Post(':id/approve') + @RuleEngineManage('rates') @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') + @RuleEngineManage('rates') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a rate' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index 263178a92..3044515fb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { UpdateServiceTypeDto } from '../dto/update-service-type.dto'; @@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service'; @ApiTags('service-types') @Controller('service-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ServiceTypesController { constructor(private readonly service: ServiceTypesService) {} @Get() + @RuleEngineView('service-types') @ApiOperation({ summary: 'List service types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -29,24 +30,28 @@ export class ServiceTypesController { } @Get(':id') + @RuleEngineView('service-types') @ApiOperation({ summary: 'Get a service type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('service-types') @ApiOperation({ summary: 'Create a service type' }) create(@Body() dto: CreateServiceTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('service-types') @ApiOperation({ summary: 'Update a service type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('service-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a service type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index fc022725c..40a67c7f5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto'; @@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service'; @ApiTags('shipping-lines') @Controller('shipping-lines') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class ShippingLinesController { constructor(private readonly service: ShippingLinesService) {} @Get() + @RuleEngineView('shipping-lines') @ApiOperation({ summary: 'List shipping lines' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class ShippingLinesController { } @Get(':id') + @RuleEngineView('shipping-lines') @ApiOperation({ summary: 'Get a shipping line by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('shipping-lines') @ApiOperation({ summary: 'Create a shipping line' }) create(@Body() dto: CreateShippingLineDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('shipping-lines') @ApiOperation({ summary: 'Update a shipping line' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('shipping-lines') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a shipping line' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts index 24213a28f..be4c3011a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/surcharge-types.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto'; import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto'; @@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service'; @ApiTags('surcharge-types') @Controller('surcharge-types') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class SurchargeTypesController { constructor(private readonly service: SurchargeTypesService) {} @Get() + @RuleEngineView('surcharge-types') @ApiOperation({ summary: 'List surcharge types' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -25,24 +26,28 @@ export class SurchargeTypesController { } @Get(':id') + @RuleEngineView('surcharge-types') @ApiOperation({ summary: 'Get a surcharge type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('surcharge-types') @ApiOperation({ summary: 'Create a surcharge type' }) create(@Body() dto: CreateSurchargeTypeDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('surcharge-types') @ApiOperation({ summary: 'Update a surcharge type' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('surcharge-types') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a surcharge type' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts index 2d4372a76..c3f0c1472 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/weight-limit-rules.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; @@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service' @ApiTags('weight-limit-rules') @Controller('weight-limit-rules') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class WeightLimitRulesController { constructor(private readonly service: WeightLimitRulesService) {} @Get() + @RuleEngineView('weight-limit-rules') @ApiOperation({ summary: 'List weight limit rules' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -26,24 +27,28 @@ export class WeightLimitRulesController { } @Get(':id') + @RuleEngineView('weight-limit-rules') @ApiOperation({ summary: 'Get a weight limit rule by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('weight-limit-rules') @ApiOperation({ summary: 'Create a weight limit rule' }) create(@Body() dto: CreateWeightLimitRuleDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('weight-limit-rules') @ApiOperation({ summary: 'Update a weight limit rule' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('weight-limit-rules') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a weight limit rule' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 89fa002a5..88523967e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; import { UpdateYardDto } from '../dto/update-yard.dto'; @@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service'; @ApiTags('yards') @Controller('yards') -// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated @ApiBearerAuth() export class YardsController { constructor(private readonly service: YardsService) {} @Get() + @RuleEngineView('yards') @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: Record) { return this.service.findAll({ @@ -26,24 +27,28 @@ export class YardsController { } @Get(':id') + @RuleEngineView('yards') @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); } @Post() + @RuleEngineManage('yards') @ApiOperation({ summary: 'Create a yard' }) create(@Body() dto: CreateYardDto) { return this.service.create(dto); } @Patch(':id') + @RuleEngineManage('yards') @ApiOperation({ summary: 'Update a yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) { return this.service.update(id, dto); } @Delete(':id') + @RuleEngineManage('yards') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Soft-delete a yard' }) remove(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index fe9084395..2f73780d9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 5fc27ecbd..98aeaffbf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -35,6 +35,7 @@ import { IShippingLinesRepository, SHIPPING_LINES_REPOSITORY, } from './interfaces/shipping-lines.repository.interface'; +import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults'; export interface BookingContainerEvalInput { containerTypeId: string; @@ -47,7 +48,8 @@ export interface BookingContainerEvalInput { } export interface BookingEvaluationInput { - cargoTypeId: string; + cargoTypeId?: string | null; + freightType?: 'CONTAINER' | 'BULK'; serviceTypeId: string; paymentCurrency: string; tradeDirection: string; @@ -115,13 +117,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,18 +240,62 @@ export class RuleEngineService { } /** - * Instantiate booking_approval_step rows from approval_rules for a cargo type. + * Ensure ITMLS default approval chains exist (container + bulk). Idempotent. */ - async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise { - const cargoType = await this.cargoTypesRepo.findById(cargoTypeId); - if (!cargoType) { - throw new BadRequestException(`Cargo type ${cargoTypeId} not found`); + async ensureDefaultApprovalRules(): Promise { + for (const flag of [false, true] as const) { + const existing = await this.approvalRulesRepo.findChainForCargo(flag); + if (existing.length > 0) continue; + + const rows = DEFAULT_APPROVAL_RULE_ROWS.filter( + (r) => r.requiresDirectorApproval === flag, + ); + for (const row of rows) { + await this.approvalRulesRepo.create({ + requiresDirectorApproval: row.requiresDirectorApproval, + stepOrder: row.stepOrder, + requiredRole: row.requiredRole, + actionLabel: row.actionLabel, + blocksRole: row.blocksRole, + }); + } + } + } + + /** + * Instantiate booking_approval_step rows from approval_rules by freight type. + */ + async instantiateApprovalSteps( + bookingId: string, + options: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + }, + ): Promise { + await this.ensureDefaultApprovalRules(); + + 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, ); + if (chain.length === 0) { + throw new BadRequestException( + `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, + ); + } + const stepRepo = this.dataSource.getRepository(BookingApprovalStep); const steps: BookingApprovalStep[] = []; @@ -253,6 +305,7 @@ export class RuleEngineService { approvalRuleId: rule.id, stepOrder: rule.stepOrder, requiredRole: rule.requiredRole, + blocksRole: rule.blocksRole ?? null, status: 'PENDING', }); steps.push(await stepRepo.save(step)); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 656802e3f..0202ef44a 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -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 { + async create(dto: CreateRateDto, proposedByStaffId: string): Promise { 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 { + async approve(id: string, approverUserId: string): Promise { 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!; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts index 98e5b1642..387e26ba9 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/surcharge-types.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts new file mode 100644 index 000000000..4ffecea26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from './train-schedule.entity'; + +@Entity({ schema: 'freight', name: 'train_schedule_bookings' }) +@Index(['trainScheduleId', 'bookingId'], { unique: true }) +@Index(['bookingId'], { unique: true }) +export class TrainScheduleBooking extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts new file mode 100644 index 000000000..965723f6c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { TrainScheduleBooking } from './train-schedule-booking.entity'; + +export const TRAIN_SCHEDULE_STATUSES = [ + 'DRAFT', + 'SCHEDULED', + 'DISPATCHED', + 'ARRIVED', + 'CANCELLED', +] as const; + +export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_schedules' }) +@Index(['scheduledDepartureDate']) +@Index(['status']) +export class TrainSchedule extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid', unique: true }) + trainSetId!: string; + + @OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'origin_station_id', type: 'uuid' }) + originStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_station_id' }) + originStation?: Yard; + + @Column({ name: 'destination_station_id', type: 'uuid' }) + destinationStationId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_station_id' }) + destinationStation?: Yard; + + @Column({ name: 'scheduled_departure_date', type: 'timestamptz' }) + scheduledDepartureDate!: Date; + + @Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true }) + scheduledArrivalDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainScheduleStatus; + + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) + scheduleBookings?: TrainScheduleBooking[]; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts new file mode 100644 index 000000000..4c78256fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/wagon-booking-allocation.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_booking_allocations' }) +@Index(['trainSetWagonId', 'bookingId']) +export class WagonBookingAllocation extends BaseEntity { + @Column({ name: 'train_set_wagon_id', type: 'uuid' }) + trainSetWagonId!: string; + + @ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_wagon_id' }) + trainSetWagon?: TrainSetWagon; + + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + allocatedWeightTons!: number; +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts new file mode 100644 index 000000000..d7360226f --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; + +@Injectable() +export class TrainScheduleBookingsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainScheduleBooking) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts new file mode 100644 index 000000000..9fa40897a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainScheduleBooking } from './entities/train-schedule-booking.entity'; +import { TrainSchedule } from './entities/train-schedule.entity'; +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from './train-schedules.repository'; +import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])], + providers: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], + exports: [ + TrainSchedulesRepository, + TrainScheduleBookingsRepository, + WagonBookingAllocationsRepository, + ], +}) +export class TrainSchedulesModule {} diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts new file mode 100644 index 000000000..b6f18eaf2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSchedule } from './entities/train-schedule.entity'; + +@Injectable() +export class TrainSchedulesRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSchedule) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts new file mode 100644 index 000000000..067dddaf7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/wagon-booking-allocations.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity'; + +@Injectable() +export class WagonBookingAllocationsRepository extends BaseRepository { + constructor( + @InjectRepository(WagonBookingAllocation) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts new file mode 100644 index 000000000..173b0a6b3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/create-container-train-schedule.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto'; + +export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto { + @ApiProperty({ format: 'uuid' }) + @IsUUID() + locomotiveId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts new file mode 100644 index 000000000..8712b2a1d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/get-eligible-container-bookings.dto.ts @@ -0,0 +1,23 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsUUID } from 'class-validator'; + +export class GetEligibleContainerBookingsDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + originStationId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' }) + @IsOptional() + @IsDateString() + scheduleDate?: string; + + @ApiPropertyOptional() + @IsOptional() + status?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts new file mode 100644 index 000000000..e3e142a61 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/preview-container-train-schedule.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator'; + +export class PreviewContainerTrainScheduleDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ example: '2026-06-20T08:00:00.000Z' }) + @IsDateString() + scheduleDate!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + originStationId!: string; + + @ApiProperty({ format: 'uuid' }) + @IsUUID() + destinationStationId!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts new file mode 100644 index 000000000..1dd01ffba --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; +import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@ApiTags('train-scheduling') +@ApiBearerAuth() +@Controller('train-scheduling') +export class TrainSchedulingController { + constructor(private readonly trainSchedulingService: TrainSchedulingService) {} + + @Get('container/eligible-bookings') + @ApiOperation({ summary: 'List eligible container bookings' }) + getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) { + return this.trainSchedulingService.getEligibleContainerBookings(query); + } + + @Post('container/preview') + @ApiOperation({ summary: 'Preview a container train schedule' }) + previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) { + return this.trainSchedulingService.previewContainerTrainSchedule(dto); + } + + @Post('container/schedules') + @ApiOperation({ summary: 'Create a container train schedule' }) + createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) { + return this.trainSchedulingService.createContainerTrainSchedule(dto); + } + + @Get('container/schedules') + @ApiOperation({ summary: 'List container train schedules' }) + getContainerTrainSchedules() { + return this.trainSchedulingService.getContainerTrainSchedules(); + } + + @Get('container/schedules/:id') + @ApiOperation({ summary: 'Get container train schedule detail' }) + getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Post('container/schedules/:id/cancel') + @ApiOperation({ summary: 'Cancel container train schedule' }) + cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) { + return this.trainSchedulingService.cancelTrainSchedule(id); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts new file mode 100644 index 000000000..dbf79e403 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { BookingsModule } from '../bookings/bookings.module'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { LocomotivesModule } from '../locomotives/locomotives.module'; +import { Locomotive } from '../locomotives/entities/locomotive.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonTypesModule } from '../wagon-types/wagon-types.module'; +import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; +import { TrainSetsModule } from '../train-sets/train-sets.module'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainSchedulesModule } from '../train-schedules/train-schedules.module'; +import { Yard } from '../rule-engine/entities/yard.entity'; +import { TrainSchedulingController } from './train-scheduling.controller'; +import { TrainSchedulingService } from './train-scheduling.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Booking, + BookingContainer, + Locomotive, + WagonType, + TrainSet, + TrainSetWagon, + TrainSchedule, + TrainScheduleBooking, + WagonBookingAllocation, + Yard, + ]), + BookingsModule, + LocomotivesModule, + WagonTypesModule, + TrainSetsModule, + TrainSchedulesModule, + ], + controllers: [TrainSchedulingController], + providers: [TrainSchedulingService], + exports: [TrainSchedulingService], +}) +export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts new file mode 100644 index 000000000..659a4d6eb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -0,0 +1,328 @@ +import { ConflictException } from '@nestjs/common'; + +import { TrainSchedulingService } from './train-scheduling.service'; + +const nw5 = { + id: 'wagon-type-1', + code: 'NW5', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ['CONTAINER'], + isActive: true, +}; + +const locomotive = { + id: 'loc-1', + code: 'LOC-001', + maxPullWeightTons: 3500, + status: 'AVAILABLE', +}; + +const makeBooking = ( + id: string, + reference: string, + weight: number, + quantity: number, + containerCode: string, + scheduledDate = '2026-06-20T08:00:00.000Z', + originYardId = 'yard-origin', + destinationYardId = 'yard-destination', +) => ({ + id, + reference, + freightType: 'CONTAINER', + cargoTotalWeightVgm: weight, + scheduledDate: new Date(scheduledDate), + originYardId, + destinationYardId, + status: 'APPROVED', + customer: { companyName: 'Demo Customer' }, + originYard: { label: 'Djibouti', code: 'DJIBOUTI' }, + destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' }, + bookingContainers: [ + { + quantity, + containerType: { code: containerCode, label: containerCode }, + }, + ], +}); + +describe('TrainSchedulingService', () => { + let service: TrainSchedulingService; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + }; + let locomotivesRepository: { + findById: jest.Mock; + }; + let wagonTypesRepository: { + findAll: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + getRepository: jest.fn(), + transaction: jest.fn(), + }; + locomotivesRepository = { + findById: jest.fn(), + }; + wagonTypesRepository = { + findAll: jest.fn(), + }; + + service = new TrainSchedulingService( + dataSource as never, + locomotivesRepository as never, + wagonTypesRepository as never, + ); + }); + + it('computes the expected valid preview for Group A', async () => { + const bookings = [ + makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'), + makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'), + makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'), + ]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(2), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: bookings.map((booking) => booking.id), + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(true); + expect(result.violations).toEqual([]); + expect(result.summary).toEqual({ + totalBookings: 3, + totalWeightTons: 1250, + wagonType: 'NW5', + wagonsNeeded: 18, + totalLengthMeters: 252, + }); + expect(result.wagonPlan).toHaveLength(18); + expect(result.wagonPlan[0]?.allocations[0]).toEqual({ + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }); + }); + + it('flags the overweight booking as invalid', async () => { + const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')]; + + wagonTypesRepository.findAll.mockResolvedValue([nw5]); + dataSource.getRepository.mockImplementation((entity: { name?: string }) => { + if (entity?.name === 'Booking') { + return { find: jest.fn().mockResolvedValue(bookings) }; + } + if (entity?.name === 'TrainScheduleBooking') { + return { find: jest.fn().mockResolvedValue([]) }; + } + if (entity?.name === 'Locomotive') { + return { + count: jest.fn().mockResolvedValue(1), + find: jest.fn().mockResolvedValue([locomotive]), + }; + } + throw new Error(`Unexpected repository ${entity?.name}`); + }); + + const result = await service.previewContainerTrainSchedule({ + bookingIds: ['b6'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + }); + + expect(result.valid).toBe(false); + expect(result.summary.totalWeightTons).toBe(3600); + expect(result.violations).toContain( + 'Total booking weight 3600T exceeds max train weight 3500T', + ); + }); + + it('creates a schedule transactionally when validation passes', async () => { + const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; + const validation = { + valid: true, + violations: [], + bookings, + wagonType: nw5, + summary: { + totalBookings: 1, + totalWeightTons: 140, + wagonType: 'NW5', + wagonsNeeded: 2, + totalLengthMeters: 28, + }, + wagonPlan: [ + { + sequenceNo: 1, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [ + { + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }, + ], + }, + { + sequenceNo: 2, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [ + { + bookingId: 'b1', + bookingReference: 'BKG-CONT-001', + allocatedWeightTons: 70, + }, + ], + }, + ], + }; + + const lockedLocomotiveRepo = { + findOne: jest.fn().mockResolvedValue(locomotive), + update: jest.fn().mockResolvedValue(undefined), + }; + const trainScheduleRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + }; + const trainScheduleBookingRepo = { + count: jest.fn().mockResolvedValue(0), + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + }; + const trainSetWagonRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + find: jest.fn().mockResolvedValue([ + { id: 'wagon-1', sequenceNo: 1 }, + { id: 'wagon-2', sequenceNo: 2 }, + ]), + }; + const wagonAllocRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue(undefined), + }; + const trainSetRepo = { + create: jest.fn().mockImplementation((value) => value), + save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), + }; + const manager = { + getRepository: jest.fn((entity: { name?: string }) => { + switch (entity?.name) { + case 'Locomotive': + return lockedLocomotiveRepo; + case 'TrainSchedule': + return trainScheduleRepo; + case 'TrainScheduleBooking': + return trainScheduleBookingRepo; + case 'TrainSetWagon': + return trainSetWagonRepo; + case 'WagonBookingAllocation': + return wagonAllocRepo; + case 'TrainSet': + return trainSetRepo; + default: + throw new Error(`Unexpected transaction repository ${entity?.name}`); + } + }), + }; + + jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + const result = await service.createContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + locomotiveId: 'loc-1', + }); + + expect(trainSetRepo.save).toHaveBeenCalled(); + expect(trainScheduleRepo.save).toHaveBeenCalled(); + expect(trainSetWagonRepo.save).toHaveBeenCalled(); + expect(wagonAllocRepo.save).toHaveBeenCalled(); + expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); + expect(result).toEqual({ id: 'schedule-1' }); + }); + + it('rejects create when the locked locomotive is no longer available', async () => { + const validation = { + valid: true, + violations: [], + bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')], + wagonType: nw5, + summary: { + totalBookings: 1, + totalWeightTons: 70, + wagonType: 'NW5', + wagonsNeeded: 1, + totalLengthMeters: 14, + }, + wagonPlan: [ + { + sequenceNo: 1, + capacityTons: 70, + lengthMeters: 14, + assignedWeightTons: 70, + allocations: [], + }, + ], + }; + const manager = { + getRepository: jest.fn(() => ({ + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + })), + }; + + jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never); + jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); + dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise) => + callback(manager), + ); + + await expect( + service.createContainerTrainSchedule({ + bookingIds: ['b1'], + scheduleDate: '2026-06-20T08:00:00.000Z', + originStationId: 'yard-origin', + destinationStationId: 'yard-destination', + locomotiveId: 'loc-1', + }), + ).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts new file mode 100644 index 000000000..34c4a7843 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -0,0 +1,814 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { InjectDataSource } from "@nestjs/typeorm"; +import { DataSource, EntityManager, In } from "typeorm"; + +import { Booking } from "../bookings/entities/booking.entity"; +import { + Locomotive, + type LocomotiveStatus, +} from "../locomotives/entities/locomotive.entity"; +import { LocomotivesRepository } from "../locomotives/locomotives.repository"; +import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; +import { TrainSet } from "../train-sets/entities/train-set.entity"; +import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; +import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; +import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity"; +import { WagonType } from "../wagon-types/entities/wagon-type.entity"; +import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; +import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; +import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; +import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; + +const DEFAULT_WAGON_TYPE_CODE = "NW5"; +const MAX_TRAIN_WEIGHT_TONS = 3500; +const MAX_TRAIN_LENGTH_METERS = 760; + +type EligibleBookingItem = { + id: string; + reference: string; + customer: string; + containerType: string; + quantity: number; + weightTons: number; + origin: string; + destination: string; + preferredDepartureDate: string; + status: string; +}; + +type WagonAllocationRecord = { + bookingId: string; + bookingReference: string; + allocatedWeightTons: number; +}; + +type WagonPlanRecord = { + sequenceNo: number; + capacityTons: number; + lengthMeters: number; + assignedWeightTons: number; + allocations: WagonAllocationRecord[]; +}; + +type ValidationResult = { + valid: boolean; + violations: string[]; + bookings: Booking[]; + wagonType: WagonType; + summary: { + totalBookings: number; + totalWeightTons: number; + wagonType: string; + wagonsNeeded: number; + totalLengthMeters: number; + }; + wagonPlan: WagonPlanRecord[]; +}; + +@Injectable() +export class TrainSchedulingService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly locomotivesRepository: LocomotivesRepository, + private readonly wagonTypesRepository: WagonTypesRepository, + ) { } + + async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) { + const bookingRepository = this.dataSource.getRepository(Booking); + const queryBuilder = bookingRepository + .createQueryBuilder("booking") + .leftJoinAndSelect("booking.customer", "customer") + .leftJoinAndSelect("booking.originYard", "originYard") + .leftJoinAndSelect("booking.destinationYard", "destinationYard") + .leftJoinAndSelect("booking.bookingContainers", "bookingContainer") + .leftJoinAndSelect("bookingContainer.containerType", "containerType") + .leftJoin( + TrainScheduleBooking, + "scheduleBooking", + "scheduleBooking.booking_id = booking.id", + ) + .where("booking.freightType = :freightType", { freightType: "CONTAINER" }) + .andWhere("scheduleBooking.id IS NULL"); + + if (query.originStationId) { + queryBuilder.andWhere("booking.originYardId = :originStationId", { + originStationId: query.originStationId, + }); + } + + if (query.destinationStationId) { + queryBuilder.andWhere( + "booking.destinationYardId = :destinationStationId", + { + destinationStationId: query.destinationStationId, + }, + ); + } + + if (query.scheduleDate) { + queryBuilder.andWhere( + `DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`, + { scheduleDate: this.toUtcDateKey(query.scheduleDate) }, + ); + } + + if (query.status) { + queryBuilder.andWhere("booking.status = :status", { + status: query.status, + }); + } + + const bookings = await queryBuilder + .orderBy("booking.scheduled_date", "ASC") + .addOrderBy("booking.created_at", "ASC") + .getMany(); + + const items: EligibleBookingItem[] = bookings.map((booking) => ({ + id: booking.id, + reference: booking.reference, + customer: + booking.company?.name ?? booking.company?.email ?? "Unknown customer", + containerType: + booking.bookingContainers + ?.map( + (container) => + container.containerType?.label ?? + container.containerType?.code ?? + "Container", + ) + .join(", ") ?? "Container", + quantity: + booking.bookingContainers?.reduce( + (sum, container) => sum + Number(container.quantity ?? 0), + 0, + ) ?? 0, + weightTons: this.roundTons(booking.cargoTotalWeightVgm), + origin: + booking.originYard?.label ?? + booking.originYard?.code ?? + "Unknown origin", + destination: + booking.destinationYard?.label ?? + booking.destinationYard?.code ?? + "Unknown destination", + preferredDepartureDate: booking.scheduledDate.toISOString(), + status: booking.status, + })); + + return { + count: items.length, + items, + }; + } + + async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) { + const validation = await this.validateContainerBookingsForScheduling(dto); + + return { + valid: validation.valid, + violations: validation.violations, + summary: validation.summary, + bookingIds: validation.bookings.map((booking) => booking.id), + wagonPlan: validation.wagonPlan, + }; + } + + async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { + const validation = await this.validateContainerBookingsForScheduling(dto); + + if (!validation.valid) { + throw new BadRequestException({ + message: "train_schedule_invalid", + violations: validation.violations, + }); + } + + const locomotive = await this.selectOrValidateLocomotive( + dto.locomotiveId, + validation.summary.totalWeightTons, + ); + + const createdSchedule = await this.dataSource.transaction( + async (manager) => { + const locomotiveRepository = manager.getRepository(Locomotive); + const lockedLocomotive = await locomotiveRepository.findOne({ + where: { id: locomotive.id }, + lock: { mode: "pessimistic_write" }, + }); + + if (!lockedLocomotive) { + throw new NotFoundException(`Locomotive ${locomotive.id} not found`); + } + + if (lockedLocomotive.status !== "AVAILABLE") { + throw new ConflictException( + `Locomotive ${lockedLocomotive.code} is not available`, + ); + } + + if ( + Number(lockedLocomotive.maxPullWeightTons) < + validation.summary.totalWeightTons + ) { + throw new BadRequestException( + `Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`, + ); + } + + const existingScheduleCount = await manager + .getRepository(TrainScheduleBooking) + .count({ + where: { + bookingId: In(validation.bookings.map((booking) => booking.id)), + }, + }); + + if (existingScheduleCount > 0) { + throw new BadRequestException( + "One or more bookings are already scheduled", + ); + } + + const trainSet = await this.buildTrainSet( + manager, + lockedLocomotive, + validation.wagonType, + validation.summary.totalWeightTons, + validation.summary.totalLengthMeters, + validation.wagonPlan, + ); + + const schedule = manager.getRepository(TrainSchedule).create({ + trainSetId: trainSet.id, + originStationId: dto.originStationId, + destinationStationId: dto.destinationStationId, + scheduledDepartureDate: new Date(dto.scheduleDate), + status: "SCHEDULED", + }); + + const savedSchedule = await manager + .getRepository(TrainSchedule) + .save(schedule); + + const scheduleBookings = validation.bookings.map((booking) => + manager.getRepository(TrainScheduleBooking).create({ + trainScheduleId: savedSchedule.id, + bookingId: booking.id, + }), + ); + await manager + .getRepository(TrainScheduleBooking) + .save(scheduleBookings); + + const savedWagons = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId: trainSet.id }, + order: { sequenceNo: "ASC" }, + }); + + const wagonBySequence = new Map( + savedWagons.map((wagon) => [wagon.sequenceNo, wagon]), + ); + const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => { + const wagon = wagonBySequence.get(wagonPlan.sequenceNo); + + if (!wagon) { + throw new BadRequestException( + `Missing wagon sequence ${wagonPlan.sequenceNo}`, + ); + } + + return wagonPlan.allocations.map((allocation) => + manager.getRepository(WagonBookingAllocation).create({ + trainSetWagonId: wagon.id, + bookingId: allocation.bookingId, + allocatedWeightTons: allocation.allocatedWeightTons, + }), + ); + }); + + await manager + .getRepository(WagonBookingAllocation) + .save(allocationRows); + + await locomotiveRepository.update(lockedLocomotive.id, { + status: "ASSIGNED", + }); + + return savedSchedule.id; + }, + ); + + return this.getContainerTrainScheduleById(createdSchedule); + } + + async validateContainerBookingsForScheduling( + dto: PreviewContainerTrainScheduleDto, + ): Promise { + const bookingIds = [...new Set(dto.bookingIds)]; + + if (!bookingIds.length) { + throw new BadRequestException("At least one booking is required"); + } + + const [wagonType] = await this.wagonTypesRepository.findAll({ + where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true }, + }); + + if (!wagonType) { + throw new NotFoundException( + `Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`, + ); + } + + const bookings = await this.loadBookingsForScheduling(bookingIds); + const violations: string[] = []; + + if (bookings.length !== bookingIds.length) { + const foundIds = new Set(bookings.map((booking) => booking.id)); + const missing = bookingIds.filter((id) => !foundIds.has(id)); + violations.push(`Bookings not found: ${missing.join(", ")}`); + } + + const scheduledLinks = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ + where: { bookingId: In(bookingIds) }, + select: { bookingId: true }, + }); + + if (scheduledLinks.length > 0) { + violations.push( + "One or more selected bookings are already assigned to a train schedule", + ); + } + + const nonContainerBookings = bookings.filter( + (booking) => booking.freightType !== "CONTAINER", + ); + if (nonContainerBookings.length > 0) { + violations.push( + "Only CONTAINER bookings are supported for train scheduling", + ); + } + + const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate); + const routeMismatch = bookings.some( + (booking) => + booking.originYardId !== dto.originStationId || + booking.destinationYardId !== dto.destinationStationId, + ); + if (routeMismatch) { + violations.push( + "Selected bookings must share the same origin and destination as the schedule", + ); + } + + const dateMismatch = bookings.some( + (booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey, + ); + if (dateMismatch) { + violations.push("Selected bookings must share the same schedule date"); + } + + const uniqueOriginCount = new Set( + bookings.map((booking) => booking.originYardId), + ).size; + if (uniqueOriginCount > 1) { + violations.push("Selected bookings must share the same origin station"); + } + + const uniqueDestinationCount = new Set( + bookings.map((booking) => booking.destinationYardId), + ).size; + if (uniqueDestinationCount > 1) { + violations.push( + "Selected bookings must share the same destination station", + ); + } + + const uniqueDateCount = new Set( + bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)), + ).size; + if (uniqueDateCount > 1) { + violations.push( + "Selected bookings must share the same preferred departure date", + ); + } + + const totalWeightTons = this.roundTons( + bookings.reduce( + (sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0), + 0, + ), + ); + + const wagonPlan = this.allocateBookingsToWagons( + bookings, + this.calculateNW5WagonPlan(totalWeightTons, wagonType), + ); + const totalLengthMeters = this.roundTons( + wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0), + ); + + if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) { + violations.push( + `Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`, + ); + } + + if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) { + violations.push( + `Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`, + ); + } + + if ( + wagonType.maxWagonsPerTrain != null && + wagonPlan.length > Number(wagonType.maxWagonsPerTrain) + ) { + violations.push( + `Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`, + ); + } + + const availableLocomotiveCount = await this.dataSource + .getRepository(Locomotive) + .count({ + where: { status: "AVAILABLE" as LocomotiveStatus }, + }); + + if (availableLocomotiveCount === 0) { + violations.push("No available locomotive exists for scheduling"); + } else { + const capableLocomotives = await this.dataSource + .getRepository(Locomotive) + .find({ + where: { status: "AVAILABLE" }, + }); + const canPull = capableLocomotives.some( + (locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, + ); + if (!canPull) { + violations.push("No available locomotive can pull the total weight"); + } + } + + return { + valid: violations.length === 0, + violations, + bookings, + wagonType, + summary: { + totalBookings: bookings.length, + totalWeightTons, + wagonType: wagonType.code, + wagonsNeeded: wagonPlan.length, + totalLengthMeters, + }, + wagonPlan, + }; + } + + calculateNW5WagonPlan( + totalBookingWeightTons: number, + wagonType: WagonType, + ): WagonPlanRecord[] { + const wagonCapacityTons = Number(wagonType.capacityTons); + const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons); + let remainingWeight = this.roundTons(totalBookingWeightTons); + + return Array.from({ length: wagonsNeeded }, (_, index) => { + const assignedWeightTons = this.roundTons( + Math.min(wagonCapacityTons, remainingWeight), + ); + remainingWeight = this.roundTons( + Math.max(0, remainingWeight - assignedWeightTons), + ); + + return { + sequenceNo: index + 1, + capacityTons: wagonCapacityTons, + lengthMeters: this.roundTons(Number(wagonType.lengthMeters)), + assignedWeightTons, + allocations: [], + }; + }); + } + + async selectOrValidateLocomotive( + locomotiveId: string, + totalWeightTons: number, + ) { + const locomotive = await this.locomotivesRepository.findById(locomotiveId); + + if (!locomotive) { + throw new NotFoundException(`Locomotive ${locomotiveId} not found`); + } + + if (locomotive.status !== "AVAILABLE") { + throw new BadRequestException( + `Locomotive ${locomotive.code} is not available`, + ); + } + + if (Number(locomotive.maxPullWeightTons) < totalWeightTons) { + throw new BadRequestException( + `Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`, + ); + } + + return locomotive; + } + + async buildTrainSet( + manager: EntityManager, + locomotive: Locomotive, + wagonType: WagonType, + totalWeightTons: number, + totalLengthMeters: number, + wagonPlan: WagonPlanRecord[], + ) { + const trainSet = manager.getRepository(TrainSet).create({ + locomotiveId: locomotive.id, + totalWeightTons, + totalLengthMeters, + wagonCount: wagonPlan.length, + status: "ASSIGNED", + }); + const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet); + + const wagons = wagonPlan.map((wagon) => + manager.getRepository(TrainSetWagon).create({ + trainSetId: savedTrainSet.id, + wagonTypeId: wagonType.id, + sequenceNo: wagon.sequenceNo, + capacityTons: wagon.capacityTons, + lengthMeters: wagon.lengthMeters, + assignedWeightTons: wagon.assignedWeightTons, + }), + ); + + await manager.getRepository(TrainSetWagon).save(wagons); + + return savedTrainSet; + } + + allocateBookingsToWagons( + bookings: Booking[], + baseWagonPlan: WagonPlanRecord[], + ): WagonPlanRecord[] { + const remaining = bookings.map((booking) => ({ + bookingId: booking.id, + bookingReference: booking.reference, + remainingWeightTons: this.roundTons( + Number(booking.cargoTotalWeightVgm ?? 0), + ), + })); + let bookingIndex = 0; + + return baseWagonPlan.map((wagon) => { + let wagonRemaining = this.roundTons(wagon.capacityTons); + const allocations: WagonAllocationRecord[] = []; + let assignedWeightTons = 0; + + while (wagonRemaining > 0 && bookingIndex < remaining.length) { + const booking = remaining[bookingIndex]; + const allocatedWeightTons = this.roundTons( + Math.min(wagonRemaining, booking.remainingWeightTons), + ); + + if (allocatedWeightTons <= 0) { + bookingIndex += 1; + continue; + } + + allocations.push({ + bookingId: booking.bookingId, + bookingReference: booking.bookingReference, + allocatedWeightTons, + }); + booking.remainingWeightTons = this.roundTons( + booking.remainingWeightTons - allocatedWeightTons, + ); + wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons); + assignedWeightTons = this.roundTons( + assignedWeightTons + allocatedWeightTons, + ); + + if (booking.remainingWeightTons <= 0) { + bookingIndex += 1; + } + } + + return { + ...wagon, + assignedWeightTons, + allocations, + }; + }); + } + + async getContainerTrainSchedules() { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + trainSet: { locomotive: true }, + originStation: true, + destinationStation: true, + scheduleBookings: true, + }, + order: { scheduledDepartureDate: "DESC", createdAt: "DESC" }, + }); + + return schedules.map((schedule) => ({ + id: schedule.id, + scheduleDate: schedule.scheduledDepartureDate, + origin: + schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + null, + locomotive: schedule.trainSet?.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name ?? null, + } + : null, + wagonCount: schedule.trainSet?.wagonCount ?? 0, + totalWeightTons: this.roundTons( + Number(schedule.trainSet?.totalWeightTons ?? 0), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet?.totalLengthMeters ?? 0), + ), + bookingsCount: schedule.scheduleBookings?.length ?? 0, + status: schedule.status, + })); + } + + async getContainerTrainScheduleById(id: string) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { + trainSet: { + locomotive: true, + wagons: { wagonType: true, allocations: { booking: true } }, + }, + originStation: true, + destinationStation: true, + scheduleBookings: { + booking: { company: true, originYard: true, destinationYard: true }, + }, + }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + return { + id: schedule.id, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate, + scheduledArrivalDate: schedule.scheduledArrivalDate, + originStation: schedule.originStation, + destinationStation: schedule.destinationStation, + trainSet: schedule.trainSet + ? { + id: schedule.trainSet.id, + status: schedule.trainSet.status, + wagonCount: schedule.trainSet.wagonCount, + totalWeightTons: this.roundTons( + Number(schedule.trainSet.totalWeightTons), + ), + totalLengthMeters: this.roundTons( + Number(schedule.trainSet.totalLengthMeters), + ), + locomotive: schedule.trainSet.locomotive + ? { + id: schedule.trainSet.locomotive.id, + code: schedule.trainSet.locomotive.code, + name: schedule.trainSet.locomotive.name, + status: schedule.trainSet.locomotive.status, + maxPullWeightTons: this.roundTons( + Number(schedule.trainSet.locomotive.maxPullWeightTons), + ), + } + : null, + wagons: [...(schedule.trainSet.wagons ?? [])] + .sort((left, right) => left.sequenceNo - right.sequenceNo) + .map((wagon) => ({ + id: wagon.id, + sequenceNo: wagon.sequenceNo, + capacityTons: this.roundTons(Number(wagon.capacityTons)), + lengthMeters: this.roundTons(Number(wagon.lengthMeters)), + assignedWeightTons: this.roundTons( + Number(wagon.assignedWeightTons), + ), + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + name: wagon.wagonType.name, + } + : null, + allocations: + wagon.allocations?.map((allocation) => ({ + id: allocation.id, + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + allocatedWeightTons: this.roundTons( + Number(allocation.allocatedWeightTons), + ), + })) ?? [], + })), + } + : null, + bookings: + schedule.scheduleBookings?.map((scheduleBooking) => ({ + id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId, + reference: scheduleBooking.booking?.reference ?? null, + customer: + scheduleBooking.booking?.company?.name ?? + scheduleBooking.booking?.company?.email ?? + null, + weightTons: this.roundTons( + Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0), + ), + status: scheduleBooking.booking?.status ?? null, + })) ?? [], + }; + } + + async cancelTrainSchedule(id: string) { + const schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ + where: { id }, + relations: { trainSet: { locomotive: true } }, + }); + + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(TrainSchedule).update(schedule.id, { + status: "CANCELLED", + }); + + if (schedule.trainSetId) { + await manager.getRepository(TrainSet).update(schedule.trainSetId, { + status: "CANCELLED", + }); + } + + if (schedule.trainSet?.locomotiveId) { + await manager + .getRepository(Locomotive) + .update(schedule.trainSet.locomotiveId, { + status: "AVAILABLE", + }); + } + }); + + return this.getContainerTrainScheduleById(id); + } + + private async loadBookingsForScheduling(bookingIds: string[]) { + return this.dataSource.getRepository(Booking).find({ + where: { id: In(bookingIds) }, + relations: { + company: true, + originYard: true, + destinationYard: true, + bookingContainers: { containerType: true }, + }, + order: { createdAt: "ASC" }, + }); + } + + private toUtcDateKey(value: Date | string) { + const date = value instanceof Date ? value : new Date(value); + return date.toISOString().slice(0, 10); + } + + private roundTons(value: number | string | null | undefined) { + const numericValue = typeof value === "number" ? value : Number(value ?? 0); + + if (!Number.isFinite(numericValue)) { + return 0; + } + + return Number(numericValue.toFixed(3)); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts new file mode 100644 index 000000000..780bc1977 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set-wagon.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSet } from './train-set.entity'; + +@Entity({ schema: 'freight', name: 'train_set_wagons' }) +@Index(['trainSetId', 'sequenceNo'], { unique: true }) +export class TrainSetWagon extends BaseEntity { + @Column({ name: 'train_set_id', type: 'uuid' }) + trainSetId!: string; + + @ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_set_id' }) + trainSet?: TrainSet; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons) + @JoinColumn({ name: 'wagon_type_id' }) + wagonType?: WagonType; + + @Column({ name: 'sequence_no', type: 'int' }) + sequenceNo!: number; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 }) + assignedWeightTons!: number; + + @OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon) + allocations?: WagonBookingAllocation[]; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts new file mode 100644 index 000000000..9099824d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/entities/train-set.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; + +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { TrainSetWagon } from './train-set-wagon.entity'; + +export const TRAIN_SET_STATUSES = [ + 'DRAFT', + 'ASSIGNED', + 'DISPATCHED', + 'COMPLETED', + 'CANCELLED', +] as const; + +export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'train_sets' }) +@Index(['locomotiveId']) +@Index(['status']) +export class TrainSet extends BaseEntity { + @Column({ name: 'locomotive_id', type: 'uuid' }) + locomotiveId!: string; + + @ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets) + @JoinColumn({ name: 'locomotive_id' }) + locomotive?: Locomotive; + + @Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 }) + totalWeightTons!: number; + + @Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 }) + totalLengthMeters!: number; + + @Column({ name: 'wagon_count', type: 'int' }) + wagonCount!: number; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' }) + status!: TrainSetStatus; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet) + wagons?: TrainSetWagon[]; + + @OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet) + trainSchedule?: TrainSchedule; +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts new file mode 100644 index 000000000..5296b04a6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-set-wagons.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSetWagon } from './entities/train-set-wagon.entity'; + +@Injectable() +export class TrainSetWagonsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSetWagon) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts new file mode 100644 index 000000000..f11052727 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { TrainSet } from './entities/train-set.entity'; +import { TrainSetWagon } from './entities/train-set-wagon.entity'; +import { TrainSetWagonsRepository } from './train-set-wagons.repository'; +import { TrainSetsRepository } from './train-sets.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])], + providers: [TrainSetsRepository, TrainSetWagonsRepository], + exports: [TrainSetsRepository, TrainSetWagonsRepository], +}) +export class TrainSetsModule {} diff --git a/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts new file mode 100644 index 000000000..a6cadbf2d --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-sets/train-sets.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { TrainSet } from './entities/train-set.entity'; + +@Injectable() +export class TrainSetsRepository extends BaseRepository { + constructor( + @InjectRepository(TrainSet) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts index f41dfa275..f166254a9 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/create-train.dto.ts @@ -1,5 +1,5 @@ -import { Freight } from "@edr/types"; -import { IsEnum, IsNumber, IsOptional, IsString, Min } from "class-validator"; +import { IsString, IsNumber, IsOptional, IsUUID, IsDateString, Min, IsEnum } from 'class-validator'; +import { Freight } from '@edr/types'; export class CreateTrainDto { @IsString() @@ -11,9 +11,45 @@ export class CreateTrainDto { @IsOptional() @IsEnum(Freight.TrainStatus) - status?: Freight.TrainStatus; + status?: Freight.TrainStatus; // ✅ uses enum, not string @IsOptional() @IsString() notes?: string; -} + + @IsOptional() + @IsString() + trainNumber?: string; + + @IsOptional() + @IsString() + trainName?: string; + + @IsOptional() + @IsUUID() + routeId?: string; + + @IsOptional() + @IsUUID() + originStationId?: string; + + @IsOptional() + @IsUUID() + destinationStationId?: string; + + @IsOptional() + @IsDateString() + departureTime?: string; + + @IsOptional() + @IsDateString() + arrivalTime?: string; + + @IsOptional() + @IsString() + locomotiveNumber?: string; + + @IsOptional() + @IsString() + remarks?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts new file mode 100644 index 000000000..cbd36eed9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTrainDto } from './create-train.dto'; + +export class UpdateTrainDto extends PartialType(CreateTrainDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index c12478ec2..184b9c88d 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -1,23 +1,58 @@ -import { BaseEntity } from "@edr/api-common"; -import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +// apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +import { BaseEntity } from '@edr/api-common'; +import { Freight } from '@edr/types'; +import { Column, Entity, OneToMany } from 'typeorm'; +import { Wagon } from '../../wagons/entities/wagon.entity'; -@Entity({ schema:"freight",name: "trains" }) +@Entity({ schema: 'freight', name: 'trains' }) export class Train extends BaseEntity { - @Column({ name: "code", type: "varchar", length: 32, unique: true }) + // --- existing fields (keep for backward compatibility) --- + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) code!: string; - @Column({ name: "capacity_tons", type: "numeric", precision: 10, scale: 2 }) + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 2 }) capacityTons!: number; @Column({ - name: "status", - type: "enum", + name: 'status', + type: 'enum', enum: Freight.TrainStatus, default: Freight.TrainStatus.Available, }) status!: Freight.TrainStatus; - @Column({ name: "notes", type: "text", nullable: true }) + @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; -} + + // --- new required fields --- + @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) + trainNumber?: string; + + @Column({ name: 'train_name', type: 'varchar', length: 100, nullable: true }) + trainName?: string; + + @Column({ name: 'route_id', type: 'uuid', nullable: true }) + routeId?: string; + + @Column({ name: 'origin_station_id', type: 'uuid', nullable: true }) + originStationId?: string; + + @Column({ name: 'destination_station_id', type: 'uuid', nullable: true }) + destinationStationId?: string; + + @Column({ name: 'departure_time', type: 'timestamp', nullable: true }) + departureTime?: Date; + + @Column({ name: 'arrival_time', type: 'timestamp', nullable: true }) + arrivalTime?: Date; + + @Column({ name: 'locomotive_number', type: 'varchar', length: 50, nullable: true }) + locomotiveNumber?: string; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string; + + // --- relationships --- + @OneToMany(() => Wagon, (wagon) => wagon.train) + wagons!: Wagon[]; // fixed typo: was 'wagens' +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.module.ts b/apps/edr-freight-api/src/modules/trains/trains.module.ts index 094120f33..61098ff40 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.module.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.module.ts @@ -1,15 +1,14 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; - -import { Train } from "./entities/train.entity"; -import { TrainsController } from "./trains.controller"; -import { TrainsRepository } from "./trains.repository"; -import { TrainsService } from "./trains.service"; +// apps/edr-freight-api/src/modules/trains/trains.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Train } from './entities/train.entity'; +import { TrainsController } from './trains.controller'; +import { TrainsService } from './trains.service'; @Module({ imports: [TypeOrmModule.forFeature([Train])], controllers: [TrainsController], - providers: [TrainsService, TrainsRepository], - exports: [TrainsService], + providers: [TrainsService], + exports: [TrainsService], // if other modules need it }) -export class TrainsModule {} +export class TrainsModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/trains/trains.service.ts b/apps/edr-freight-api/src/modules/trains/trains.service.ts index db689a19a..12aa65afe 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.service.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.service.ts @@ -1,29 +1,41 @@ -import { Injectable, NotFoundException } from "@nestjs/common"; - -import { CreateTrainDto } from "./dto/create-train.dto"; -import { Train } from "./entities/train.entity"; -import { TrainsRepository } from "./trains.repository"; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateTrainDto } from './dto/create-train.dto'; +import { UpdateTrainDto } from './dto/update-train.dto'; +import { Train } from './entities/train.entity'; @Injectable() export class TrainsService { - constructor(private readonly trainsRepository: TrainsRepository) {} + constructor( + @InjectRepository(Train) + private readonly trainRepo: Repository, + ) {} - /** Register a new train in the fleet. */ create(dto: CreateTrainDto): Promise { - return this.trainsRepository.create(dto); + const train = this.trainRepo.create(dto); + return this.trainRepo.save(train); } - /** List every active train. */ findAll(): Promise { - return this.trainsRepository.findAll({ order: { code: "ASC" } }); + return this.trainRepo.find({ order: { code: 'ASC' } }); } - /** Get a single train by ID. */ async findById(id: string): Promise { - const train = await this.trainsRepository.findById(id); - if (!train) { - throw new NotFoundException(`Train ${id} not found`); - } + const train = await this.trainRepo.findOne({ where: { id } }); + if (!train) throw new NotFoundException(`Train ${id} not found`); return train; } -} + + async update(id: string, dto: UpdateTrainDto): Promise { + const train = await this.findById(id); + Object.assign(train, dto); + // Convert undefined to null for optional fields if needed + return this.trainRepo.save(train); + } + + async remove(id: string): Promise { + const train = await this.findById(id); + await this.trainRepo.remove(train); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts new file mode 100644 index 000000000..f1bfeedea --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/entities/wagon-type.entity.ts @@ -0,0 +1,33 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, OneToMany } from 'typeorm'; + +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; + +@Entity({ schema: 'freight', name: 'wagon_types' }) +@Index(['code']) +@Index(['isActive']) +export class WagonType extends BaseEntity { + @Column({ name: 'code', type: 'varchar', length: 32, unique: true }) + code!: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name!: string; + + @Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 }) + capacityTons!: number; + + @Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 }) + lengthMeters!: number; + + @Column({ name: 'max_wagons_per_train', type: 'int', nullable: true }) + maxWagonsPerTrain?: number | null; + + @Column({ name: 'supported_load_types', type: 'text', array: true, default: '{}' }) + supportedLoadTypes!: string[]; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; + + @OneToMany(() => TrainSetWagon, (wagon) => wagon.wagonType) + trainSetWagons?: TrainSetWagon[]; +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts new file mode 100644 index 000000000..3cb23cc3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; +import { WagonTypesService } from './wagon-types.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([WagonType])], + providers: [WagonTypesRepository, WagonTypesService], + exports: [WagonTypesRepository, WagonTypesService], +}) +export class WagonTypesModule {} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts new file mode 100644 index 000000000..001ff0212 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.repository.ts @@ -0,0 +1,16 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WagonType } from './entities/wagon-type.entity'; + +@Injectable() +export class WagonTypesRepository extends BaseRepository { + constructor( + @InjectRepository(WagonType) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts new file mode 100644 index 000000000..4e15937e0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagon-types/wagon-types.service.ts @@ -0,0 +1,19 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { WagonType } from './entities/wagon-type.entity'; +import { WagonTypesRepository } from './wagon-types.repository'; + +@Injectable() +export class WagonTypesService { + constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} + + async findByCode(code: string): Promise { + const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); + + if (!wagonType) { + throw new NotFoundException(`Wagon type ${code} not found`); + } + + return wagonType; + } +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts new file mode 100644 index 000000000..66a837e68 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/assign-wagon-to-train.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID, IsOptional, IsInt, Min } from 'class-validator'; + +export class AssignWagonToTrainDto { + @IsUUID() + trainId!: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts new file mode 100644 index 000000000..c3108d68b --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-wagon.dto.ts @@ -0,0 +1,34 @@ +import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator'; + +export class CreateWagonDto { + @IsString() + wagonNumber!: string; + + @IsUUID() + wagonTypeId!: string; + + @IsOptional() + @IsUUID() + trainId?: string; + + @IsOptional() + @IsInt() + @Min(1) + sequenceNumber?: number; + + @IsNumber() + @Min(0) + tareWeight!: number; + + @IsNumber() + @Min(0) + maxPayloadWeight!: number; + + @IsOptional() + @IsIn(['AVAILABLE', 'ASSIGNED', 'MAINTENANCE', 'RETIRED']) + status?: string; + + @IsOptional() + @IsString() + notes?: string; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts new file mode 100644 index 000000000..0395adb8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/reorder-wagons.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class ReorderWagonsDto { + @IsArray() + @IsUUID(4, { each: true }) + wagonIds!: string[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts new file mode 100644 index 000000000..3414d1f2c --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/update-wagon.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateWagonDto } from './create-wagon.dto'; + +export class UpdateWagonDto extends PartialType(CreateWagonDto) {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts new file mode 100644 index 000000000..cdff14330 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -0,0 +1,41 @@ +// apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { BaseEntity } from '@edr/api-common'; +import { Train } from '../../trains/entities/train.entity'; +import { Container } from '../../container-management/entities/container.entity'; + +@Entity({ name: 'wagons', schema: 'freight' }) +export class Wagon extends BaseEntity { + @Column({ unique: true, name: 'wagon_number' }) + wagonNumber!: string; + + @Column({ name: 'wagon_type_id', type: 'uuid' }) + wagonTypeId!: string; + + @Column({ name: 'train_id', type: 'uuid', nullable: true }) + trainId!: string | null; + + @Column({ name: 'sequence_number', type: 'int', nullable: true }) + sequenceNumber!: number | null; + + @Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 }) + tareWeight!: number; + + @Column({ name: 'max_payload_weight', type: 'decimal', precision: 10, scale: 2 }) + maxPayloadWeight!: number; + + @Column({ type: 'varchar', default: 'AVAILABLE' }) + status!: string; // AVAILABLE, ASSIGNED, MAINTENANCE, RETIRED + + @Column({ type: 'text', nullable: true }) + notes!: string | null; + + // Relationship to Train + @ManyToOne(() => Train, (train) => train.wagons, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'train_id' }) + train!: Train | null; + + // Relationship to Container + @OneToMany(() => Container, (container) => container.wagon) + containers!: Container[]; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts new file mode 100644 index 000000000..339948eec --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { WagonsService } from './wagons.service'; + +@ApiTags('wagons') +@Controller('wagons') +export class WagonsController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @ApiOperation({ summary: 'Create a new wagon' }) + create(@Body() dto: CreateWagonDto) { + return this.wagonsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all wagons' }) + findAll() { + return this.wagonsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a wagon by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a wagon' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { + return this.wagonsService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a wagon' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.remove(id); + } + + @Post(':id/assign-train') + @ApiOperation({ summary: 'Assign wagon to a train' }) + assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { + return this.wagonsService.assignToTrain(id, dto); + } + + @Post(':id/unassign-train') + @ApiOperation({ summary: 'Unassign wagon from train' }) + unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { + return this.wagonsService.unassignFromTrain(id); + } +} + +// Separate controller for train‑specific reorder (registered in module) +@Controller('trains/:trainId/reorder-wagons') +export class TrainWagonsReorderController { + constructor(private readonly wagonsService: WagonsService) {} + + @Post() + @ApiOperation({ summary: 'Reorder wagons of a train' }) + reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { + return this.wagonsService.reorderWagons(trainId, dto); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts new file mode 100644 index 000000000..914de4cbd --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; +import { WagonsController, TrainWagonsReorderController } from './wagons.controller'; +import { WagonsService } from './wagons.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Wagon, Train])], + controllers: [WagonsController, TrainWagonsReorderController], + providers: [WagonsService], + exports: [WagonsService], +}) +export class WagonsModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts new file mode 100644 index 000000000..f0e12842e --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Wagon } from './entities/wagon.entity'; + +@Injectable() +export class WagonsRepository extends BaseRepository { + constructor( + @InjectRepository(Wagon) + repository: Repository, + ) { + super(repository); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts new file mode 100644 index 000000000..70909cbd8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { CreateWagonDto } from './dto/create-wagon.dto'; +import { UpdateWagonDto } from './dto/update-wagon.dto'; +import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; +import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { Wagon } from './entities/wagon.entity'; +import { Train } from '../trains/entities/train.entity'; + +@Injectable() +export class WagonsService { + constructor( + @InjectRepository(Wagon) + private readonly wagonRepo: Repository, + @InjectRepository(Train) + private readonly trainRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + async create(dto: CreateWagonDto): Promise { + const wagon = this.wagonRepo.create(dto); + // Convert undefined to null for nullable fields + if (dto.trainId === undefined) wagon.trainId = null; + if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + return this.wagonRepo.save(wagon); + } + + async findAll(): Promise { + return this.wagonRepo.find({ order: { wagonNumber: 'ASC' } }); + } + + async findById(id: string): Promise { + const wagon = await this.wagonRepo.findOne({ where: { id } }); + if (!wagon) throw new NotFoundException(`Wagon ${id} not found`); + return wagon; + } + + async update(id: string, dto: UpdateWagonDto): Promise { + const wagon = await this.findById(id); + Object.assign(wagon, dto); + if (dto.trainId === undefined) wagon.trainId = null; + if (dto.sequenceNumber === undefined) wagon.sequenceNumber = null; + return this.wagonRepo.save(wagon); + } + + async remove(id: string): Promise { + const wagon = await this.findById(id); + await this.wagonRepo.remove(wagon); + } + + async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { + const wagon = await this.findById(wagonId); + if (wagon.status === 'ASSIGNED') { + throw new ConflictException('Wagon already assigned to a train'); + } + + const train = await this.trainRepo.findOne({ where: { id: dto.trainId } }); + if (!train) throw new NotFoundException('Train not found'); + + let sequence: number | null = dto.sequenceNumber ?? null; + if (sequence === null) { + const maxSeq = await this.wagonRepo + .createQueryBuilder('w') + .select('MAX(w.sequenceNumber)', 'max') + .where('w.trainId = :trainId', { trainId: train.id }) + .getRawOne(); + sequence = (maxSeq?.max ?? 0) + 1; + } + + wagon.trainId = train.id; + wagon.sequenceNumber = sequence; + wagon.status = 'ASSIGNED'; + return this.wagonRepo.save(wagon); + } + + async unassignFromTrain(wagonId: string): Promise { + const wagon = await this.findById(wagonId); + wagon.trainId = null; + wagon.sequenceNumber = null; + wagon.status = 'AVAILABLE'; + return this.wagonRepo.save(wagon); + } + + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + for (let i = 0; i < dto.wagonIds.length; i++) { + await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 }); + } + await queryRunner.commitTransaction(); + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts new file mode 100644 index 000000000..e215dbd9b --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-bookings.seeder.ts @@ -0,0 +1,302 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { randomUUID } from "crypto"; +import { DataSource } from "typeorm"; + +import { BookingContainer } from "../modules/bookings/entities/booking-container.entity"; +import { Booking } from "../modules/bookings/entities/booking.entity"; +import { Customer } from "../modules/customers/entities/customer.entity"; +import { Locomotive } from "../modules/locomotives/entities/locomotive.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; +import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; + +const SEED_FLAG = "SEED_DEMO_BOOKINGS"; + +const SERVICE_TYPE_CODE = "RAIL_CONTAINER"; +const CUSTOMER_EMAIL = "train-scheduling-demo@edr.local"; + +const YARDS = [ + { code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + }, +]; + +const CONTAINER_TYPES = [ + { code: "20FT", label: "20FT", sizeFt: 20 }, + { code: "40FT", label: "40FT", sizeFt: 40 }, +]; + +const DEMO_BOOKINGS = [ + { + reference: "BKG-CONT-001", + containerCode: "40FT", + quantity: 20, + totalWeightTons: 500, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, + { + reference: "BKG-CONT-002", + containerCode: "20FT", + quantity: 10, + totalWeightTons: 300, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, + { + reference: "BKG-CONT-003", + containerCode: "40FT", + quantity: 15, + totalWeightTons: 450, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, + { + reference: "BKG-CONT-007", + containerCode: "20FT", + quantity: 6, + totalWeightTons: 180, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, + { + reference: "BKG-CONT-004", + containerCode: "40FT", + quantity: 12, + totalWeightTons: 360, + originCode: "ADDIS_ABABA", + destinationCode: "DIRE_DAWA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, + { + reference: "BKG-CONT-005", + containerCode: "20FT", + quantity: 8, + totalWeightTons: 160, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-21T08:00:00.000Z", + }, + { + reference: "BKG-CONT-006", + containerCode: "40FT", + quantity: 80, + totalWeightTons: 3600, + originCode: "DJIBOUTI", + destinationCode: "ADDIS_ABABA", + scheduledDate: "2026-06-20T08:00:00.000Z", + }, +]; + +@Injectable() +export class DemoBookingsSeeder { + private readonly logger = new Logger(DemoBookingsSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run() { + const shouldSeed = process.env[SEED_FLAG]?.trim().toLowerCase() === "true"; + if (!shouldSeed) { + this.logger.log( + `Skipping demo booking seed because ${SEED_FLAG} is not enabled`, + ); + return; + } + + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(WagonType).upsert( + { + code: "NW5", + name: "Flat Wagon", + capacityTons: 70, + lengthMeters: 14, + maxWagonsPerTrain: 53, + supportedLoadTypes: ["CONTAINER"], + isActive: true, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Locomotive).upsert( + [ + { + code: "LOC-001", + name: "Demo Locomotive 1", + maxPullWeightTons: 3500, + status: "AVAILABLE", + }, + { + code: "LOC-002", + name: "Demo Locomotive 2", + maxPullWeightTons: 2500, + status: "AVAILABLE", + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: "Rail Container Service", + description: "Temporary service type for train scheduling demos", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Customer).upsert( + { + userId: "00000000-0000-0000-0000-000000000111", + firstName: "Train", + lastName: "Scheduling", + email: CUSTOMER_EMAIL, + phone: "251900000001", + companyName: "Train Scheduling Demo Customer", + companyEmail: CUSTOMER_EMAIL, + companyPhone: "251900000001", + companyLocation: "Addis Ababa", + companyAddress: "Demo Address", + customerType: "DEMO", + status: "ACTIVE", + contactPersonName: "Train Scheduling", + contactPersonPhone: "251900000001", + tinNumber: "1234567890", + vatNumber: "1234567890", + fanNumber: "1234567890123456", + generalManagerName: "Demo Manager", + generalManagerEmail: CUSTOMER_EMAIL, + generalManagerPhone: "251900000001", + }, + { conflictPaths: { email: true } }, + ); + + const [serviceType, customer, yards, containerTypes] = await Promise.all([ + manager + .getRepository(ServiceType) + .findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager + .getRepository(Customer) + .findOneByOrFail({ email: CUSTOMER_EMAIL }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [ + containerType.code, + containerType, + ]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get( + demoBooking.containerCode, + ); + + if (!origin || !destination || !containerType) { + throw new Error( + `demo_booking_seed_dependency_missing:${demoBooking.reference}`, + ); + } + + const vgmPerUnitTons = + demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: customer.id, + status: "APPROVED", + scheduledDate: new Date(demoBooking.scheduledDate), + totalAmount: 0, + paymentStatus: "PENDING", + contractType: "NEW", + serviceTypeId: serviceType.id, + equipmentReturn: "WITHOUT_RETURN", + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: "IMPORT", + freightType: "CONTAINER", + cargoTypeId: null, + cargoFreeText: null, + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + paymentCurrency: "USD", + allowConsolidation: false, + priorityScore: 0, + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager + .getRepository(BookingContainer) + .delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired: Math.ceil(demoBooking.totalWeightTons / 70), + weightLimitRuleId: null, + isOverweight: demoBooking.totalWeightTons > 70, + overweightExcessTons: + demoBooking.totalWeightTons > 70 + ? demoBooking.totalWeightTons - 70 + : null, + }); + } + }); + + this.logger.log("Seeded demo train scheduling data"); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index dda476841..c2705673f 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,3 +1,9 @@ +import { + BOOKING_RULE_ENGINE_PERMISSIONS, + BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ROLE_PERMISSION_PRESETS, +} from './freight-permissions.registry'; + export type FreightSeedRole = { key: string; name: { en: string }; @@ -183,8 +189,11 @@ export const EDR_FREIGHT_PERMISSIONS = [ ...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS, + ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; +export { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from './freight-permissions.registry'; + export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ { key: "edr_employee", @@ -198,11 +207,42 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:position_types:view", ], }, + { + key: "edr_line_staff", + name: { en: "EDR Line Staff" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], + }, + { + key: "edr_director", + name: { en: "EDR Director" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.director], + }, + { + key: "edr_ceo", + name: { en: "EDR CEO" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.ceo], + }, + { + key: "edr_finance", + name: { en: "EDR Finance" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.finance], + }, + { + key: "edr_marketing", + name: { en: "EDR Marketing" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], + }, { key: "edr_org_manager", name: { en: "EDR Org Manager" }, permissionKeys: [ - ...EDR_FREIGHT_PERMISSIONS.map((permission) => permission.key), + ...BOOKING_RULE_ENGINE_PERMISSION_KEYS, + ...EMPLOYEE_REGISTRATION_PERMISSIONS.map((p) => p.key), + ...ROLE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_UNIT_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_POSITION_PERMISSIONS.map((p) => p.key), + ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS.map((p) => p.key), + ...POSITION_TYPE_PERMISSIONS.map((p) => p.key), IAM_PERMISSION_KEYS.createEmployee, IAM_PERMISSION_KEYS.deactivateEmployee, IAM_PERMISSION_KEYS.activateEmployee, diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 49620b593..8243ef267 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -8,6 +8,8 @@ import { } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; +import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; +import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; const EDR_ORG_KEY = "edr_freight"; @@ -37,6 +39,7 @@ export class EdrOrgSeeder { await this.ensureOrganizationConfiguration(manager, organization.id); await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); + await this.ensureSuperAdminPermissions(manager); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -166,4 +169,40 @@ export class EdrOrgSeeder { this.logger.log(`Ensured ${rolePermissions.length} EDR role-permission links`); } + + private async ensureSuperAdminPermissions(manager: EntityManager) { + const role = await manager.getRepository(Role).findOne({ + where: { key: ERoleKey.SUPER_ADMIN }, + select: { id: true, key: true }, + }); + + if (!role) { + this.logger.warn( + `Role ${ERoleKey.SUPER_ADMIN} not found; skipping booking/rule-engine super_admin links`, + ); + return; + } + + const permissions = await manager.getRepository(Permission).find({ + where: { key: In(BOOKING_RULE_ENGINE_PERMISSION_KEYS) }, + select: { id: true, key: true }, + }); + + if (!permissions.length) { + this.logger.warn('No booking/rule-engine permissions found for super_admin'); + return; + } + + await manager.getRepository(RolePermission).upsert( + permissions.map((permission) => ({ + roleId: role.id, + permissionId: permission.id, + })), + { conflictPaths: { roleId: true, permissionId: true } }, + ); + + this.logger.log( + `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, + ); + } } diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts new file mode 100644 index 000000000..2ef1f79f0 --- /dev/null +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -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", + ); + } +} diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts new file mode 100644 index 000000000..dba80d96b --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -0,0 +1,152 @@ +const EDR_FREIGHT_APP_KEY = 'edr_freight_app'; + +export type FreightPermissionSeed = { + id: string; + key: string; + name: { am: string; en: string }; + applicationKey: string; +}; + +export const RULE_ENGINE_RESOURCE_SLUGS = [ + 'cargo-types', + 'container-types', + 'service-types', + 'yards', + 'shipping-lines', + 'weight-limit-rules', + 'surcharge-types', + 'priority-rules', + 'rates', + 'approval-rules', +] as const; + +export type RuleEngineResourceSlug = (typeof RULE_ENGINE_RESOURCE_SLUGS)[number]; + +const slugToResourceKey = (slug: RuleEngineResourceSlug): string => + slug.replace(/-/g, '_'); + +const perm = ( + id: string, + key: string, + en: string, +): FreightPermissionSeed => ({ + id, + key, + name: { am: en, en }, + applicationKey: EDR_FREIGHT_APP_KEY, +}); + +export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a1000001-0001-4000-8000-000000000001', 'edr_freight_app:bookings:view', 'View bookings'), + perm('a1000001-0001-4000-8000-000000000002', 'edr_freight_app:bookings:staff_accept', 'Accept booking intake'), + perm('a1000001-0001-4000-8000-000000000003', 'edr_freight_app:bookings:request_changes', 'Request booking changes'), + perm('a1000001-0001-4000-8000-000000000004', 'edr_freight_app:bookings:reject', 'Reject booking submission'), + perm('a1000001-0001-4000-8000-000000000005', 'edr_freight_app:bookings:approve_line_staff', 'Approve as line staff'), + perm('a1000001-0001-4000-8000-000000000006', 'edr_freight_app:bookings:approve_director', 'Approve as director'), + perm('a1000001-0001-4000-8000-000000000007', 'edr_freight_app:bookings:approve_ceo', 'Approve as CEO'), + perm('a1000001-0001-4000-8000-000000000008', 'edr_freight_app:bookings:reject_approval', 'Reject at approval step'), + perm('a1000001-0001-4000-8000-000000000009', 'edr_freight_app:bookings:generate_contract', 'Generate contract'), + perm('a1000001-0001-4000-8000-00000000000a', 'edr_freight_app:bookings:sign_staff', 'Staff contract signature'), + perm('a1000001-0001-4000-8000-00000000000b', 'edr_freight_app:bookings:payment_pnr', 'Generate PNR'), + perm('a1000001-0001-4000-8000-00000000000c', 'edr_freight_app:bookings:payment_verify', 'Verify payment'), + perm('a1000001-0001-4000-8000-00000000000d', 'edr_freight_app:bookings:operations', 'Booking operations'), + perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), +]; + +const RULE_ENGINE_PERMISSION_IDS: Record = { + 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, + 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, + 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, + yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, + 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, + 'weight-limit-rules': { view: 'b2000001-0001-4000-8000-00000000000b', manage: 'b2000001-0001-4000-8000-00000000000c' }, + 'surcharge-types': { view: 'b2000001-0001-4000-8000-00000000000d', manage: 'b2000001-0001-4000-8000-00000000000e' }, + 'priority-rules': { view: 'b2000001-0001-4000-8000-00000000000f', manage: 'b2000001-0001-4000-8000-000000000010' }, + rates: { view: 'b2000001-0001-4000-8000-000000000011', manage: 'b2000001-0001-4000-8000-000000000012' }, + 'approval-rules': { view: 'b2000001-0001-4000-8000-000000000013', manage: 'b2000001-0001-4000-8000-000000000014' }, +}; + +export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESOURCE_SLUGS.flatMap( + (slug) => { + const resource = slugToResourceKey(slug); + const ids = RULE_ENGINE_PERMISSION_IDS[slug]; + return [ + perm(ids.view, `edr_freight_app:rule_engine:${resource}:view`, `View ${slug}`), + perm(ids.manage, `edr_freight_app:rule_engine:${resource}:manage`, `Manage ${slug}`), + ]; + }, +); + +export const BOOKING_RULE_ENGINE_PERMISSIONS = [ + ...BOOKING_PERMISSIONS, + ...RULE_ENGINE_PERMISSIONS, +]; + +export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( + (p) => p.key, +); + +export const FREIGHT_PERMS = { + bookings: { + view: 'edr_freight_app:bookings:view', + staffAccept: 'edr_freight_app:bookings:staff_accept', + requestChanges: 'edr_freight_app:bookings:request_changes', + reject: 'edr_freight_app:bookings:reject', + approveLineStaff: 'edr_freight_app:bookings:approve_line_staff', + approveDirector: 'edr_freight_app:bookings:approve_director', + approveCeo: 'edr_freight_app:bookings:approve_ceo', + rejectApproval: 'edr_freight_app:bookings:reject_approval', + generateContract: 'edr_freight_app:bookings:generate_contract', + signStaff: 'edr_freight_app:bookings:sign_staff', + operations: 'edr_freight_app:bookings:operations', + cancel: 'edr_freight_app:bookings:cancel', + }, + ruleEngine: { + view: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, + manage: (slug: RuleEngineResourceSlug) => + `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, + }, +} as const; + +const allRuleEngineViewKeys = () => + RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); + +export const ROLE_PERMISSION_PRESETS = { + lineStaff: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, + ...allRuleEngineViewKeys(), + ], + director: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveDirector, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.generateContract, + ...allRuleEngineViewKeys(), + ], + ceo: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.approveCeo, + FREIGHT_PERMS.bookings.rejectApproval, + ...allRuleEngineViewKeys(), + ], + finance: [FREIGHT_PERMS.bookings.view], + marketing: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.generateContract, + FREIGHT_PERMS.bookings.signStaff, + ], + orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], +} as const; + +export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ + key: p.key, + label: p.name.en, + module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine', +})); diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts new file mode 100644 index 000000000..06b9afa94 --- /dev/null +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -0,0 +1,127 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource } from 'typeorm'; + +const SEED_FLAG = 'SEED_FREIGHT_STAFF'; +const EDR_ORG_KEY = 'edr_freight'; + +const STAFF_USERS = [ + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +@Injectable() +export class FreightStaffUsersSeeder { + private readonly logger = new Logger(FreightStaffUsersSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') { + this.logger.log(`Skipping freight staff seed because ${SEED_FLAG} is not enabled`); + return; + } + + const password = + process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + + await this.dataSource.transaction(async (manager) => { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + + if (!organization) { + throw new Error(`missing_organization:${EDR_ORG_KEY}`); + } + + const roleRepository = manager.getRepository(Role); + const userRepository = manager.getRepository(User); + const userCredentialRepository = manager.getRepository(UserCredential); + const userRoleRepository = manager.getRepository(UserRole); + const employeeRepository = manager.getRepository(Employee); + + const hashedPassword = await hashPassword(password); + + for (const staff of STAFF_USERS) { + const role = await roleRepository.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + + if (!role) { + throw new Error(`missing_role:${staff.roleKey}`); + } + + let user = await userRepository.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + + if (!user) { + user = await userRepository.save( + userRepository.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded freight staff user ${staff.email}`); + } + + const activeCredentialExists = await userCredentialRepository.exists({ + where: { userId: user.id, isActive: true }, + }); + + if (!activeCredentialExists) { + await userCredentialRepository.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepository.upsert( + { + userId: user.id, + roleId: role.id, + organizationId: organization.id, + }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const employeeExists = await employeeRepository.exists({ + where: { + userId: user.id, + organizationId: organization.id, + isCurrent: true, + }, + }); + + if (!employeeExists) { + await employeeRepository.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + }); + + this.logger.log('Ensured freight staff users (linestaff@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts new file mode 100644 index 000000000..671436a73 --- /dev/null +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -0,0 +1,800 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { BookingCargoModifier } from "../modules/bookings/entities/booking-cargo-modifier.entity"; +import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity"; +import { ContainerType } from "../modules/rule-engine/entities/container-type.entity"; +import { PriorityRule } from "../modules/rule-engine/entities/priority-rule.entity"; +import { Rate } from "../modules/rule-engine/entities/rate.entity"; +import { ServiceType } from "../modules/rule-engine/entities/service-type.entity"; +import { ShippingLine } from "../modules/rule-engine/entities/shipping-line.entity"; +import { SurchargeType } from "../modules/rule-engine/entities/surcharge-type.entity"; +import { WeightLimitRule } from "../modules/rule-engine/entities/weight-limit-rule.entity"; +import { Yard } from "../modules/rule-engine/entities/yard.entity"; + +const STAFF_USER_ID = "00000000-0000-0000-0000-000000000001"; +const CEO_USER_ID = "00000000-0000-0000-0000-000000000002"; + +@Injectable() +export class PricingDataSeeder { + private readonly logger = new Logger(PricingDataSeeder.name); + + constructor(private readonly dataSource: DataSource) { } + + async run(): Promise { + await this.dataSource.transaction(async (manager) => { + const ctRepo = manager.getRepository(ContainerType); + const stRepo = manager.getRepository(ServiceType); + const yRepo = manager.getRepository(Yard); + const slRepo = manager.getRepository(ShippingLine); + const wlRepo = manager.getRepository(WeightLimitRule); + const prRepo = manager.getRepository(PriorityRule); + const rRepo = manager.getRepository(Rate); + + await this.upsertReferenceData(manager, ctRepo, stRepo, yRepo, slRepo); + await this.seedWeightLimits(wlRepo, ctRepo); + await this.seedPriorityRules(prRepo); + const containerTypes = await ctRepo.find(); + const ctByCode = new Map(containerTypes.map((ct) => [ct.code, ct])); + + const rates = await this.seedRates(rRepo, ctByCode); + const ratesByType = new Map(); + for (const r of rates) { + const key = `${r.rateType}|${r.currency}|${r.containerTypeId ?? ""}`; + if (!ratesByType.has(key)) ratesByType.set(key, []); + ratesByType.get(key)!.push(r); + } + + await this.seedSurchargeTypes(manager, ratesByType); + + const yards = await yRepo.find(); + const yardByCode = new Map(yards.map((y) => [y.code, y])); + const serviceTypes = await stRepo.find(); + const stByCode = new Map(serviceTypes.map((st) => [st.code, st])); + const shippingLines = await slRepo.find(); + const slByCode = new Map(shippingLines.map((sl) => [sl.code, sl])); + const cargoTypes = await manager.getRepository(CargoType).find(); + const cargoByCode = new Map(cargoTypes.map((c) => [c.code, c])); + + await this.seedDraftBookings( + ctByCode, + yardByCode, + stByCode, + slByCode, + cargoByCode, + ); + }); + + this.logger.log("Seeded pricing data"); + } + + private async upsertReferenceData( + manager: any, + ctRepo: any, + stRepo: any, + yRepo: any, + slRepo: any, + ): Promise { + await yRepo.upsert( + [ + { + code: "DJIBOUTI", + label: "Djibouti", + country: "Djibouti", + displayOrder: 1, + isActive: true, + }, + { + code: "ADDIS_ABABA", + label: "Addis Ababa", + country: "Ethiopia", + displayOrder: 2, + isActive: true, + }, + { + code: "DIRE_DAWA", + label: "Dire Dawa", + country: "Ethiopia", + displayOrder: 3, + isActive: true, + }, + { + code: "MODJO", + label: "Modjo", + country: "Ethiopia", + displayOrder: 4, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await ctRepo.upsert( + [ + { + code: "20FT", + label: "20FT Standard", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 1, + }, + { + code: "40FT", + label: "40FT Standard", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: 2, + }, + { + code: "20FT_REEFER", + label: "20FT Reefer", + sizeFt: 20, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 3, + }, + { + code: "40FT_REEFER", + label: "40FT Reefer", + sizeFt: 40, + wagonsPerUnit: 1, + isReefer: true, + isOpenTop: false, + isActive: true, + displayOrder: 4, + }, + ], + { conflictPaths: { code: true } }, + ); + + await stRepo.upsert( + [ + { + code: "RAIL_CONTAINER", + serviceName: "Rail Container Service", + description: "Standard rail container transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 1, + }, + { + code: "RAIL_FORWARDING", + serviceName: "Rail Forwarding Service", + description: "Rail transport with first/last mile and customs", + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: true, + priorityBonusPoints: 100, + isActive: true, + displayOrder: 2, + }, + { + code: "RAIL_BULK", + serviceName: "Rail Bulk Transport", + description: "Bulk commodity rail transport", + canBeBookedAlone: true, + includesFirstMile: false, + includesLastMile: false, + includesCustoms: false, + priorityBonusPoints: 50, + isActive: true, + displayOrder: 3, + }, + ], + { conflictPaths: { code: true } }, + ); + + await slRepo.upsert( + [ + { + code: "MAERSK", + label: "Maersk Line", + mappedToCode: "MAERSK", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "MSC", + label: "MSC", + mappedToCode: "MSC", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "CMA_CGM", + label: "CMA CGM", + mappedToCode: "CMA_CGM", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "COSCO", + label: "COSCO Shipping", + mappedToCode: "COSCO", + showExtraFeeNotice: true, + isActive: true, + }, + { + code: "OTHER", + label: "Other Line", + mappedToCode: null, + showExtraFeeNotice: false, + isActive: true, + }, + ], + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(CargoType).upsert( + [ + { + code: "GRAIN", + cargoTypeName: "Grain / Cereals", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 1, + }, + { + code: "FERTILIZER", + cargoTypeName: "Fertilizer", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 2, + }, + { + code: "CEMENT", + cargoTypeName: "Cement / Clinker", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 3, + }, + { + code: "STEEL", + cargoTypeName: "Steel / Rebar", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 4, + }, + { + code: "MACHINERY", + cargoTypeName: "Heavy Machinery", + requiresDirectorApproval: true, + isActive: true, + displayOrder: 5, + }, + { + code: "OTHER_BULK", + cargoTypeName: "Other Bulk Cargo", + requiresDirectorApproval: false, + isActive: true, + displayOrder: 6, + }, + ], + { conflictPaths: { code: true } }, + ); + } + + private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { + await wlRepo.createQueryBuilder().delete().execute(); + const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); + const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); + const base = new Date("2026-01-01"); + await wlRepo.insert([ + { + containerTypeId: twenty.id, + tradeDirection: "IMPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: twenty.id, + tradeDirection: "EXPORT", + maxVgmTons: 26, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "IMPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + { + containerTypeId: forty.id, + tradeDirection: "EXPORT", + maxVgmTons: 28, + effectiveFrom: base, + }, + ]); + this.logger.log("Seeded weight limit rules"); + } + + private async seedPriorityRules(prRepo: any): Promise { + const existing = await prRepo.find({ + where: [{ code: "USD_PRIORITY" }, { code: "STANDARD_PRIORITY" }], + }); + for (const r of existing) { + await prRepo.remove(r); + } + await prRepo.save([ + prRepo.create({ + code: "USD_PRIORITY", + label: "USD Payment Priority", + score: 200, + conditionCurrency: "USD", + isActive: true, + }), + prRepo.create({ + code: "STANDARD_PRIORITY", + label: "Standard Priority", + score: 50, + conditionCurrency: null, + isActive: true, + }), + ]); + this.logger.log("Seeded priority rules"); + } + + private async seedRates( + rRepo: any, + ctByCode: Map, + ): Promise { + const effectiveFrom = new Date("2026-01-01"); + const now = new Date(); + const rateData = [ + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 800, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 1200, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 45000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 67000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "USD", + rateValue: 600, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "USD", + rateValue: 900, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 34000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 50000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("20FT")!.id, + currency: "ETB", + rateValue: 20000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: ctByCode.get("40FT")!.id, + currency: "ETB", + rateValue: 30000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 1000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 56000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 750, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "CONTAINER_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 42000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "INTERCITY_CONTAINER", + containerTypeId: null, + currency: "ETB", + rateValue: 25000, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_IMPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "USD", + rateValue: 40, + rateUnit: "PER_TON", + }, + { + rateType: "BULK_EXPORT", + containerTypeId: null, + currency: "ETB", + rateValue: 2200, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "USD", + rateValue: 25, + rateUnit: "PER_TON", + }, + { + rateType: "OVERWEIGHT_PER_TON", + containerTypeId: null, + currency: "ETB", + rateValue: 1400, + rateUnit: "PER_TON", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 150, + rateUnit: "FLAT", + }, + { + rateType: "HAZARD_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 8500, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "USD", + rateValue: 200, + rateUnit: "FLAT", + }, + { + rateType: "REEFER_SURCHARGE", + containerTypeId: null, + currency: "ETB", + rateValue: 11000, + rateUnit: "FLAT", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "USD", + rateValue: 100, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "DOUBLE_HANDLING", + containerTypeId: null, + currency: "ETB", + rateValue: 5500, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "USD", + rateValue: 50, + rateUnit: "PER_CONTAINER", + }, + { + rateType: "LASHING", + containerTypeId: null, + currency: "ETB", + rateValue: 2800, + rateUnit: "PER_CONTAINER", + }, + ]; + + const entities = rateData.map((d) => + rRepo.create({ + ...d, + status: "LIVE", + proposedByStaffId: STAFF_USER_ID, + approvedByCeoId: CEO_USER_ID, + approvedAt: now, + effectiveFrom, + }), + ); + return rRepo.save(entities); + } + + private async seedSurchargeTypes( + manager: any, + ratesByType: Map, + ): Promise { + const surRepo = manager.getRepository(SurchargeType); + const bcmRepo = manager.getRepository(BookingCargoModifier); + await bcmRepo.createQueryBuilder().delete().execute(); + const findRate = (rateType: string, currency: string) => { + const key = `${rateType}|${currency}|`; + const rates = ratesByType.get(key); + return rates?.[0]; + }; + + const hazardRateUsd = findRate("HAZARD_SURCHARGE", "USD"); + const hazardRateEtb = findRate("HAZARD_SURCHARGE", "ETB"); + const reeferRateUsd = findRate("REEFER_SURCHARGE", "USD"); + const reeferRateEtb = findRate("REEFER_SURCHARGE", "ETB"); + const overweightRateUsd = findRate("OVERWEIGHT_PER_TON", "USD"); + const overweightRateEtb = findRate("OVERWEIGHT_PER_TON", "ETB"); + const shipLineRateUsd = findRate("DOUBLE_HANDLING", "USD"); + const shipLineRateEtb = findRate("DOUBLE_HANDLING", "ETB"); + const consolidRateUsd = findRate("LASHING", "USD"); + const consolidRateEtb = findRate("LASHING", "ETB"); + + await surRepo.createQueryBuilder().delete().execute(); + await surRepo.save([ + surRepo.create({ + code: "HAZARDOUS_CARGO", + label: "Hazardous Cargo", + triggerCondition: "CARGO_FLAG_HAZARDOUS", + rateId: hazardRateUsd?.id ?? hazardRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "REEFER_CARGO", + label: "Reefer Cargo", + triggerCondition: "CARGO_FLAG_REEFER", + rateId: reeferRateUsd?.id ?? reeferRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "OVERWEIGHT_CARGO", + label: "Overweight Cargo", + triggerCondition: "VGM_EXCEEDS_LIMIT", + rateId: overweightRateUsd?.id ?? overweightRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "SHIPPING_LINE_FEE", + label: "Shipping Line Fee", + triggerCondition: "SHIPPING_LINE_MAPPED", + rateId: shipLineRateUsd?.id ?? shipLineRateEtb?.id, + isActive: true, + }), + surRepo.create({ + code: "CONSOLIDATION_FEE", + label: "Consolidation Fee", + triggerCondition: "CONSOLIDATION_ENABLED", + rateId: consolidRateUsd?.id ?? consolidRateEtb?.id, + isActive: true, + }), + ]); + this.logger.log("Seeded surcharge types"); + } + + private async seedDraftBookings( + ctByCode: Map, + yardByCode: Map, + stByCode: Map, + slByCode: Map, + cargoByCode: Map, + ): Promise { + const djibouti = yardByCode.get("DJIBOUTI")!; + const addis = yardByCode.get("ADDIS_ABABA")!; + const railContainer = stByCode.get("RAIL_CONTAINER")!; + const railBulk = stByCode.get("RAIL_BULK")!; + const maersk = slByCode.get("MAERSK")!; + const grain = cargoByCode.get("GRAIN")!; + const twenty = ctByCode.get("20FT")!; + const forty = ctByCode.get("40FT")!; + const twentyReefer = ctByCode.get("20FT_REEFER")!; + + const drafts = [ + { + reference: "BKG-PRICE-001", + description: "Standard 20FT container import — base rail only", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 250, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-002", + description: "40FT container import + hazardous surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: true, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 135, + containers: [ + { containerTypeId: forty.id, quantity: 5, vgmPerUnitTons: 27 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["HAZARDOUS_CARGO"], + }, + { + reference: "BKG-PRICE-003", + description: "20FT container import + shipping line (ETB)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "ETB", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: maersk.id, + cargoTypeId: null, + cargoTotalWeightVgm: 480, + containers: [ + { containerTypeId: twenty.id, quantity: 20, vgmPerUnitTons: 24 }, + ], + expectedBaseRate: 45000, + expectedSurcharges: ["SHIPPING_LINE_FEE"], + }, + { + reference: "BKG-PRICE-004", + description: "40FT container import + consolidation (USD)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: true, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 224, + containers: [ + { containerTypeId: forty.id, quantity: 8, vgmPerUnitTons: 28 }, + ], + expectedBaseRate: 1200, + expectedSurcharges: ["CONSOLIDATION_FEE"], + }, + { + reference: "BKG-PRICE-005", + description: "Bulk import — grain", + freightType: "BULK" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railBulk.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: grain.id, + cargoTotalWeightVgm: 500, + containers: [], + expectedBaseRate: 50, + expectedSurcharges: [], + }, + { + reference: "BKG-PRICE-006", + description: "20FT reefer container import + reefer surcharge", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 75, + containers: [ + { containerTypeId: twentyReefer.id, quantity: 3, vgmPerUnitTons: 25 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["REEFER_CARGO"], + }, + { + reference: "BKG-PRICE-007", + description: "20FT container import + overweight (30t > 26t limit)", + freightType: "CONTAINER" as const, + tradeDirection: "IMPORT", + paymentCurrency: "USD", + serviceTypeId: railContainer.id, + originYardId: djibouti.id, + destinationYardId: addis.id, + isHazardous: false, + allowConsolidation: false, + shippingLineId: null, + cargoTypeId: null, + cargoTotalWeightVgm: 300, + containers: [ + { containerTypeId: twenty.id, quantity: 10, vgmPerUnitTons: 30 }, + ], + expectedBaseRate: 800, + expectedSurcharges: ["OVERWEIGHT_CARGO"], + }, + ]; + + this.logger.log(`Seeded ${drafts.length} DRAFT bookings for pricing`); + } +} diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index f39ee4541..7499e38a2 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -27,6 +27,7 @@ "react-hot-toast": "^2.6.0", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "zustand": "^5.0.0" }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7cc6a9935..ea824762e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,259 +1,297 @@ -import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { - Boxes, - FileText, - LayoutDashboard, - Network, - Paperclip, - Settings, - SlidersHorizontal, -} 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 BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; -import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; -import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; -import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; -import OverviewPage from "./pages/dashboard/OverviewPage"; -import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; -import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; -import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; -import RolesPage from "./pages/dashboard/user-management/RolesPage"; -import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; -import UsersPage from "./pages/dashboard/user-management/UsersPage"; -import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; -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, - }, - }, -}); - -const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ - { - title: "Main menu", - mutedTitle: true, - items: [ - { - label: "Overview", - href: "/dashboard/overview", - icon: , - }, - { - label: "Booking requests", - href: "/dashboard/booking-requests", - icon: , - }, - ...demoItems, - ], - }, - { - title: "Administration", - items: [ - { - label: "User management", - href: "/dashboard/user-management", - icon: , - children: [ - { - label: "Users", - href: "/dashboard/user-management/users", - }, - { - label: "Employees", - href: "/dashboard/user-management/employees", - }, - { - label: "Position Types", - href: "/dashboard/user-management/position-types", - }, - { - label: "Permissions", - href: "/dashboard/user-management/permissions", - }, - { - label: "Roles", - href: "/dashboard/user-management/roles", - }, - ], - }, - { - label: "File settings", - href: "/dashboard/file-settings", - icon: , - }, - { - label: "Dropdown settings", - href: "/dashboard/dropdown-settings", - icon: , - }, - ], - }, - { - title: "Freight configuration", - mutedTitle: true, - items: [ - { - label: "Configuration", - href: "/dashboard/configuration", - icon: , - children: getCategorySidebarChildren("configuration"), - }, - { - label: "Rules", - href: "/dashboard/rules", - icon: , - children: getCategorySidebarChildren("rules"), - }, - ], - }, -]; - -const hasPermission = ( - user: ReturnType["user"], - key: string, -) => { - if (!user) return false; - if (user.permissions?.some((p) => p.key === key)) return true; - - return (user.employee ?? []).some((emp) => - (emp.positions ?? []).some((pos) => - (pos.permissions ?? []).some((p) => p.key === key), - ), - ); -}; - -const DashboardShell = () => { - const navigate = useNavigate(); - const location = useLocation(); - const { user, logout } = useAuth(); - - const demoItems: SidebarItem[] = [ - ...(hasPermission(user, "can:demo:user1") - ? [ - { - label: "User1", - href: "/dashboard/user1", - icon: , - }, - ] - : []), - ...(hasPermission(user, "can:demo:user2") - ? [ - { - label: "User2", - href: "/dashboard/user2", - icon: , - }, - ] - : []), - ]; - - const sidebarSections = buildSidebarSections(demoItems); - const displayName = user?.name?.en || user?.username || user?.email || "User"; - - return ( - - - - ); -}; - -const App = () => { - const { user, loading } = useAuth(); - - if (loading) { - return ; - } - - if (!user) { - return ( - - - } /> - } /> - - - ); - } - - return ( - - - } /> - } /> - - }> - } /> - - } /> - } /> - - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - - } /> - } /> - - } - /> - } /> - - } - /> - } /> - - } - /> - } /> - - } /> - } /> - - } - /> - } - /> - - - } /> - - - ); -}; - -export default App; +import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom"; +import { + Boxes, + FileText, + LayoutDashboard, + Network, + Paperclip, + Settings, + SlidersHorizontal, + Train, + Truck, + Container, + Package, + //TrainTrack, +} 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"; +import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; +import OverviewPage from "./pages/dashboard/OverviewPage"; +import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; +import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; +import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; +import RolesPage from "./pages/dashboard/user-management/RolesPage"; +import UserManagementPage from "./pages/dashboard/user-management/UserManagementPage"; +import UsersPage from "./pages/dashboard/user-management/UsersPage"; +import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; +import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; +import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; +import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; +// import TrainsPage from "./pages/trains/TrainsPage"; +import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +//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[] => [ + { + title: "Main menu", + mutedTitle: true, + items: [ + { + label: "Overview", + href: "/dashboard/overview", + icon: , + }, + { + label: "Booking requests", + href: "/dashboard/booking-requests", + icon: , + }, + { + label: "Train scheduling", + href: "/dashboard/operations/train-scheduling", + icon: , + }, + ...demoItems, + ], + }, + { + title: "Fleet Management", + items: [ + { + label: "Trains", + href: "/dashboard/trains", + icon: , + }, + { + label: "Wagons", + href: "/dashboard/wagons", + icon: , + }, + { + label: "Containers", + href: "/dashboard/containers", + icon: , + }, + { + label: "Cargoes", + href: "/dashboard/cargoes", + icon: , + }, + ], + }, + { + title: "Administration", + items: [ + { + label: "User management", + href: "/dashboard/user-management", + icon: , + children: [ + { + label: "Users", + href: "/dashboard/user-management/users", + }, + { + label: "Employees", + href: "/dashboard/user-management/employees", + }, + { + label: "Position Types", + href: "/dashboard/user-management/position-types", + }, + { + label: "Permissions", + href: "/dashboard/user-management/permissions", + }, + { + label: "Roles", + href: "/dashboard/user-management/roles", + }, + ], + }, + { + label: "File settings", + href: "/dashboard/file-settings", + icon: , + }, + { + label: "Dropdown settings", + href: "/dashboard/dropdown-settings", + icon: , + }, + ], + }, + { + title: "Freight configuration", + mutedTitle: true, + items: [ + { + label: "Configuration", + href: "/dashboard/configuration", + icon: , + children: getCategorySidebarChildren("configuration"), + }, + { + label: "Rules", + href: "/dashboard/rules", + icon: , + children: getCategorySidebarChildren("rules"), + }, + ], + }, +]; + +const hasPermission = ( + user: ReturnType["user"], + key: string, +) => { + if (!user) return false; + if (user.permissions?.some((p) => p.key === key)) return true; + + return (user.employee ?? []).some((emp) => + (emp.positions ?? []).some((pos) => + (pos.permissions ?? []).some((p) => p.key === key), + ), + ); +}; + +const DashboardShell = () => { + const navigate = useNavigate(); + const location = useLocation(); + const { user, logout } = useAuth(); + + const demoItems: SidebarItem[] = [ + ...(hasPermission(user, "can:demo:user1") + ? [ + { + label: "User1", + href: "/dashboard/user1", + icon: , + }, + ] + : []), + ...(hasPermission(user, "can:demo:user2") + ? [ + { + label: "User2", + href: "/dashboard/user2", + icon: , + }, + ] + : []), + ]; + + const sidebarSections = buildSidebarSections(demoItems); + const displayName = user?.name?.en || user?.username || user?.email || "User"; + + return ( + + + + ); +}; + +const App = () => { + const { user, loading } = useAuth(); + + if (loading) { + return ; + } + + if (!user) { + return ( + + } /> + } /> + + ); + } + + return ( + + } /> + } /> + + }> + } /> + + } /> + } /> + } + /> + {/* } /> + + } /> */} + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + + } /> + } /> + + } + /> + } /> + + } + /> + } /> + + } + /> + } /> + + } /> + } /> + + } + /> + } + /> + + + } /> + + ); +}; + +export default App; diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts index 8c5b11b54..f72e72949 100644 --- a/apps/edr-freight-web/backoffice/src/auth/api.ts +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -18,6 +18,6 @@ export const verifyMfaRequest = async (payload: { }; export const getMeRequest = async () => { - const response = await api.get("/auth/me"); + const response = await api.get("/me"); return response.data; }; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 2d4ecd536..41742039c 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -39,6 +39,9 @@ export interface AuthUser { name?: LocaleText; roles?: AuthRole[]; permissions?: AuthPermission[]; + /** Flat keys from GET /api/me (roles + position permissions). */ + permissionKeys?: string[]; + isSuperAdmin?: boolean; employee?: AuthEmployeeRecord[]; hasSetPassword?: boolean; status?: string; @@ -52,3 +55,10 @@ export interface AuthTokens { export interface LoginResponse extends Partial { mfaRequired?: boolean; } + +// Additional types for Matrix form test +export interface User { + id: string; + name: string; + role: "ADMIN" | "MANAGER" | "CHIEF_EXECUTIVE"; +} diff --git a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts index e454bc509..74a29cf7e 100644 --- a/apps/edr-freight-web/backoffice/src/auth/useAuth.ts +++ b/apps/edr-freight-web/backoffice/src/auth/useAuth.ts @@ -11,3 +11,5 @@ export const useAuth = () => { return context; }; + + diff --git a/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx new file mode 100644 index 000000000..f18389f9e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/baselineRatematrix/RateMatrixForm.tsx @@ -0,0 +1,330 @@ +// components/baselineRatematrix/RateMatrixForm.tsx +import React, { useState, useCallback } from 'react'; +// import { useForm } from 'react-hook-form'; +// import { zodResolver } from '@hookform/resolvers/zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { z } from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Loader2, Save, Send, Shield, AlertTriangle } from 'lucide-react'; +import { RateTypeSection } from './RateTypeSection'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { ValidationSummary } from './ValidationSummary'; +import { LoadingScreen } from '@/ui/LoadingScreen'; +import { useRateMatrixAuth } from '@/auth/hooks/useAuth'; +import { useReferenceData } from '@/hooks/useReferenceData'; +import { queryKeys } from '@/constants/queryKeys'; +import { API_URLS } from '@/constants/apiUrls'; +import { + RATE_TYPES, + RATE_TYPE_LABELS, + REQUIRED_RATE_TYPES +} from '@/constants/rateMatrixConstants'; +import { rateMatrixRulesEngine } from '../../ruleEngine/rateMatrixRules'; +import type { RateEntry } from './types'; + +const formSchema = z.object({ + matrixName: z.string().min(1, 'Matrix name is required').max(200), + effectiveDate: z.string().min(1, 'Effective date is required'), + expiryDate: z.string().optional(), + currency: z.string().min(1, 'Currency is required'), +}); + +type FormData = z.infer; + +const createInitialSections = (): RateEntry[] => { + return REQUIRED_RATE_TYPES.map(rateType => ({ + rateType, + entries: [{ + validFrom: '', + validTo: '', + }], + })); +}; + +export function RateMatrixForm() { + const [rateSections, setRateSections] = useState(createInitialSections()); + const [showConfirmation, setShowConfirmation] = useState(false); + const [savedMatrixId, setSavedMatrixId] = useState(null); + const [validationErrors, setValidationErrors] = useState([]); + + const { isDirector } = useRateMatrixAuth(); + const { data: referenceData, isLoading: isLoadingReference } = useReferenceData(); + const queryClient = useQueryClient(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + matrixName: '', + effectiveDate: '', + expiryDate: '', + currency: 'USD', + }, + }); + + // Save draft mutation + const saveDraftMutation = useMutation({ + mutationFn: async (data: FormData & { rateSections: RateEntry[] }) => { + const response = await fetch(API_URLS.RATE_MATRIX.DRAFT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error('Failed to save draft'); + return response.json(); + }, + onSuccess: (data) => { + setSavedMatrixId(data.id); + queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); + toast.success('Draft saved successfully'); + }, + onError: (error) => { + toast.error('Failed to save draft'); + }, + }); + + // Submit for approval mutation + const submitMutation = useMutation({ + mutationFn: async (matrixId: string) => { + const response = await fetch(API_URLS.RATE_MATRIX.SUBMIT(matrixId), { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to submit'); + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.rateMatrix.all }); + toast.success('Rate matrix submitted for executive approval and locked!'); + setShowConfirmation(false); + }, + onError: (error) => { + toast.error('Failed to submit for approval'); + setShowConfirmation(false); + }, + }); + + const handleValidate = useCallback(() => { + const validation = rateMatrixRulesEngine.validate(rateSections); + setValidationErrors([...validation.errors, ...validation.warnings]); + + if (validation.isValid) { + toast.success('All validations passed!'); + } + }, [rateSections]); + + const handleSaveDraft = async () => { + const formData = form.getValues(); + await saveDraftMutation.mutateAsync({ + ...formData, + rateSections, + }); + }; + + const handleSubmitClick = async () => { + const isFormValid = await form.trigger(); + if (!isFormValid) return; + + const validation = rateMatrixRulesEngine.validate(rateSections); + setValidationErrors([...validation.errors, ...validation.warnings]); + + if (!validation.isValid) { + toast.error('Please fix validation errors before submitting'); + return; + } + + setShowConfirmation(true); + }; + + const handleConfirmSubmit = async () => { + const formData = form.getValues(); + + try { + let matrixId = savedMatrixId; + + if (!matrixId) { + const draftResult = await saveDraftMutation.mutateAsync({ + ...formData, + rateSections, + }); + matrixId = draftResult.id; + } + + await submitMutation.mutateAsync(matrixId!); + } catch (error) { + // Error handling done in mutations + } + }; + + if (isLoadingReference) { + return ; + } + + if (!isDirector) { + return ( +
+ + + Access Denied + + Only Directors can access the rate matrix registration. + + +
+ ); + } + + return ( +
+ {/* Header */} +
+

+ Baseline Rate Matrix Registration +

+

+ Submit a comprehensive rate matrix for executive approval +

+
+ + {/* Director Warning */} + + + Director Notice + + Once submitted, this matrix will be locked pending Chief Executive approval. + No edits can be made by any user until authorization is granted. + + + +
e.preventDefault()}> + {/* Matrix Metadata */} + + + Matrix Information + + +
+
+ + + {form.formState.errors.matrixName && ( +

+ {form.formState.errors.matrixName.message} +

+ )} +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+
+ + {/* Rate Type Sections */} +
+ {rateSections.map((section, index) => ( + { + const newSections = [...rateSections]; + newSections[index] = updatedSection; + setRateSections(newSections); + }} + referenceData={referenceData} + /> + ))} +
+ + {/* Validation Errors */} + {validationErrors.length > 0 && ( +
+ +
+ )} + + {/* Form Actions */} +
+ + + + + +
+
+ + {/* Confirmation Dialog */} + +
+ ); +} \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx new file mode 100644 index 000000000..293c17e2a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/ApprovalStepsCard.tsx @@ -0,0 +1,207 @@ +import { useMemo, useState } from "react"; +import { Check, ShieldCheck } from "lucide-react"; + +import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { useAuth } from "@/auth/useAuth"; +import { formatApprovalProgress } from "@/features/bookings/approval-progress"; +import { + buildApproveActionForStep, + canActOnApprovalStep, + getNextPendingApprovalStep, +} from "@/features/bookings/booking-actions.config"; +import type { useBookingMutations } from "@/hooks/bookings/useBookings"; +import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; +import { bookingGlass, bookingSurface } from "./booking-ui.styles"; +import { Badge, Button } from "@edr/ui-common"; +import { cn } from "@/lib/utils"; + +type Mutations = ReturnType; + +interface ApprovalStepsCardProps { + booking: BookingDetail; + mutations: Mutations; +} + +/** Approval chain with inline approve on the current pending step. */ +export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { + const { user } = useAuth(); + const [confirmOpen, setConfirmOpen] = useState(false); + const [pendingStep, setPendingStep] = useState( + null, + ); + + const steps = useMemo( + () => + [...(booking.approvalSteps ?? [])].sort( + (a, b) => a.stepOrder - b.stepOrder, + ), + [booking.approvalSteps], + ); + + const nextPending = getNextPendingApprovalStep(steps); + const summary = formatApprovalProgress(booking.status, steps); + const pendingAction = pendingStep + ? buildApproveActionForStep(pendingStep) + : null; + + const openApprove = (step: BookingApprovalStep) => { + setPendingStep(step); + setConfirmOpen(true); + }; + + const closeApprove = () => { + setConfirmOpen(false); + setPendingStep(null); + }; + + const runApprove = () => { + if (!pendingStep) return; + mutations.approveStep.mutate( + { stepId: pendingStep.id, requiredRole: pendingStep.requiredRole }, + { onSuccess: () => closeApprove() }, + ); + }; + + return ( + <> +
+
+
+ +
+
+

+ Approval chain +

+

+ {summary.detail || + (nextPending + ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + : steps.length + ? "All steps complete" + : "Accept submission to begin")} +

+
+
+ +
+ {steps.length === 0 ? ( +

+ Use{" "} + + Accept for approval + {" "} + in staff actions to instantiate steps. +

+ ) : ( +
    + {steps.map((step) => ( + + ))} +
+ )} +
+
+ + { + if (!open) closeApprove(); + else setConfirmOpen(true); + }} + action={pendingAction} + reference={booking.reference} + inputValue="" + onInputChange={() => {}} + onConfirm={runApprove} + isPending={mutations.approveStep.isPending} + /> + + ); +} + +function StepRow({ + step, + steps, + user, + isNext, + isPending, + onApprove, +}: { + step: BookingApprovalStep; + steps: BookingApprovalStep[]; + user: ReturnType["user"]; + isNext: boolean; + isPending: boolean; + onApprove: (step: BookingApprovalStep) => void; +}) { + const canApprove = canActOnApprovalStep(user, step, steps); + const statusStyles = + step.status === "APPROVED" + ? "border-emerald-500/25 bg-emerald-500/10 text-black" + : step.status === "REJECTED" + ? "bg-red-500/10 text-red-800 dark:text-red-300" + : isNext + ? "border-emerald-500/25 bg-emerald-500/10 text-black" + : "bg-muted/40 text-muted-foreground"; + + return ( +
  • +
    + + {step.stepOrder} + +
    +

    + {step.requiredRole} +

    + {step.remarks && ( +

    + {step.remarks} +

    + )} +
    +
    +
    + {canApprove && ( + + )} + + {step.status} + +
    +
  • + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx new file mode 100644 index 000000000..e4e1d8db9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsMenu.tsx @@ -0,0 +1,240 @@ +import { useNavigate } from "react-router-dom"; +import { + ChevronRight, + ExternalLink, + Loader2, + MoreHorizontal, +} from "lucide-react"; + +import { BookingConfirmDialog } from "./BookingConfirmDialog"; +import { useBookingActionDialog } from "./useBookingActionDialog"; +import { useAuth } from "@/auth/useAuth"; +import { + getNextPendingApprovalStep, + isContractNavAction, + 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; + /** Suppresses table row navigation after menu/dialog close (click-through). */ + onSuppressRowClick?: () => void; +} + +export function BookingActionsMenu({ + row, + variant = "table", + className, + onSuppressRowClick, +}: BookingActionsMenuProps) { + const navigate = useNavigate(); + const { user } = useAuth(); + const context: BookingActionContext = { + status: row.status, + paymentCurrency: row.paymentCurrency, + reference: row.reference, + approvalSteps: row.approvalSteps, + }; + + const flow = useBookingActionDialog(row.id, context); + const { actions, pendingAction, mutations } = flow; + + const goToContract = () => + navigate(`/dashboard/booking-requests/${row.id}/contract`); + + const hasMenu = listRowHasActions(row, user); + + const primary = actions.find((a) => a.primary) ?? actions[0]; + + if (!hasMenu && variant === "table") { + return ( + + ); + } + + return ( + <> +
    e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + {variant === "table" && primary && ( + + )} + + {variant === "toolbar" && actions.length > 0 ? ( +
    + {actions.map((action) => { + const Icon = action.icon; + return ( + + ); + })} +
    + ) : ( + + + + + + + {row.reference} + + + {actions.map((action) => { + const Icon = action.icon; + return ( + { + event.preventDefault(); + onSuppressRowClick?.(); + if (isContractNavAction(action.id)) { + goToContract(); + } else { + flow.openAction(action); + } + }} + > + + {action.label} + + ); + })} + {actions.length > 0 && } + { + event.preventDefault(); + onSuppressRowClick?.(); + navigate(`/dashboard/booking-requests/${row.id}`); + }} + > + + Open full details + + + + )} +
    + + { + if (!open) onSuppressRowClick?.(); + flow.setDialogOpen(open); + }} + action={pendingAction} + reference={flow.mergedContext.reference} + inputValue={flow.inputValue} + onInputChange={flow.setInputValue} + selectedFile={flow.selectedFile} + onFileChange={flow.setSelectedFile} + onConfirm={() => { + onSuppressRowClick?.(); + flow.runAction(); + }} + isPending={mutations.isPending || flow.detailLoading} + confirmDisabled={flow.confirmDisabled} + extra={ + flow.detailLoading ? ( +

    + + Loading approval steps… +

    + ) : pendingAction?.id === "approve" && + !getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( +

    + No pending approval step. Refresh the page after staff accept, or + reject the booking. +

    + ) : null + } + /> + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx new file mode 100644 index 000000000..006c57e11 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -0,0 +1,121 @@ +import { Download, 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"; +import { cn } from "@/lib/utils"; + +type Mutations = ReturnType; + +interface BookingActionsToolbarProps { + booking: BookingDetail; + mutations: Mutations; +} + +/** Detail-page actions: primary toolbar + downloads. */ +export function BookingActionsToolbar({ + booking, + mutations, +}: BookingActionsToolbarProps) { + const row = toBookingListRow(booking); + const { status } = booking; + + const downloadBlob = async (fn: () => Promise, 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 ( + + {booking.latestChangeRequestNote && ( +

    + {booking.latestChangeRequestNote} +

    + )} +
    + ); + } + + if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { + return ( + + ); + } + + return ( +
    + + + + + {status === "CONTRACT_READY" && ( + + + + )} +
    + ); +} + +function PanelShell({ + title, + description, + children, + muted, +}: { + title: string; + description: string; + children: React.ReactNode; + muted?: boolean; +}) { + return ( +
    +
    +
    + +
    +
    +

    {title}

    +

    {description}

    +
    +
    +
    {children}
    +
    + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx new file mode 100644 index 000000000..69d01692b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingApprovalProgressCell.tsx @@ -0,0 +1,29 @@ +import { formatApprovalProgress } from "@/features/bookings/approval-progress"; +import type { BookingListRow } from "@/types/booking"; +import { cn } from "@/lib/utils"; + +interface BookingApprovalProgressCellProps { + row: BookingListRow; +} + +export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) { + const summary = formatApprovalProgress(row.status, row.approvalSteps); + + return ( +
    +

    + {summary.label} +

    + {summary.detail ? ( +

    + {summary.detail} +

    + ) : null} +
    + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx new file mode 100644 index 000000000..190fe4475 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -0,0 +1,173 @@ +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; + selectedFile?: File | null; + onFileChange?: (file: File | null) => void; + onConfirm: () => void; + isPending: boolean; + confirmDisabled?: boolean; + extra?: React.ReactNode; +} + +export function BookingConfirmDialog({ + open, + onOpenChange, + action, + reference, + inputValue, + onInputChange, + selectedFile = null, + onFileChange, + onConfirm, + isPending, + confirmDisabled = false, + extra, +}: BookingConfirmDialogProps) { + if (!action || !action.confirmTitle) return null; + + const Icon = action.icon; + const needsTextInput = + action.input === "note" || action.input === "reason"; + const needsFileInput = action.input === "file"; + const inputMissing = + (needsTextInput && !inputValue.trim()) || + (needsFileInput && !selectedFile); + const isDestructive = action.variant === "destructive"; + + const preventClickThrough = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + return ( + + event.preventDefault()} + > +
    + +
    +
    + +
    +
    + + {action.confirmTitle} + + {reference && ( +

    + {reference} +

    + )} +
    +
    + + {action.confirmDescription} + +
    +
    + +
    + {needsTextInput && ( +
    + +