diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04edf4fba..fcd560a95 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -50,10 +50,10 @@ jobs: SERVICES=() - NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$" + NON_DEPLOYABLE_PATTERN="^docs/|^README[.]md$|^DEPLOYMENT[.]md$|^CLAUDE[.]md$|^checkpoint[.]md$|^orgstructure[.]md$|^ITMLS_DB_Design[.]md$|.*[.]md$|^[.]eslintrc|^[.]prettierrc|^[.]editorconfig|^[.]gitignore|^[.]gitattributes|^commitlint[.]config[.]js$|^scripts/deploy/sync-env-from-server-jenkins[.]sh$" GLOBAL_PATTERN="^[.]github/|^docker-compose[.]yaml$|^turbo[.]json$|^tsconfig[.]json$|^tsconfig[.]base[.]json$|^pnpm-workspace[.]yaml$|^pnpm-lock[.]yaml$|^package[.]json$|^[.]env([.][a-z]+)?$|^packages/|^local-packages/|^infrastructure/|^scripts/deploy/|^wagon[.][^/]*[.]ts$|^cargo[.][^/]*[.]ts$|^container[.][^/]*[.]ts$|^use-[^/]*[.]ts$|^[^/]*[.]service[.]ts$|^[^/]*[.]entity[.]ts$|^[^/]*-types[.]ts$" - + DEPLOYABLE=$(echo "$CHANGED" | grep -vE "$NON_DEPLOYABLE_PATTERN" || true) if [ -z "$DEPLOYABLE" ]; then echo "Only non-deployable files changed. Skipping deploy." @@ -142,6 +142,31 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh + - name: Resolve env file path for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + case "${{ matrix.service }}" in + passenger-api) echo "SERVICE_ENV_FILE=apps/edr-passenger-api/.env" >> "$GITHUB_ENV" ;; + payment-api) echo "SERVICE_ENV_FILE=apps/edr-payment-api/.env" >> "$GITHUB_ENV" ;; + esac + + - name: Build migration image for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker build \ + --secret id=npmrc,src=.npmrc \ + --target migration \ + -f "apps/edr-${{ matrix.service }}/Dockerfile" \ + -t "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" \ + . + + - name: Run migrations for ${{ matrix.service }} + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + docker run --rm --env-file "${SERVICE_ENV_FILE}" "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}-migration" + - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 4ccffb6a4..e02391ccd 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true +SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false # MinIO (used by @tria-plc/iamapi-common for file storage) MINIO_ENDPOINT=localhost diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..f9107ed23 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -3,6 +3,10 @@ FROM node:24.15.0-alpine AS base RUN apk add --no-cache libc6-compat +# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit +# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" RUN corepack enable WORKDIR /app @@ -14,6 +18,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile FROM base AS builder @@ -23,7 +28,8 @@ RUN pnpm turbo build --filter="@edr/freight-api..." FROM base AS deployer COPY --from=builder /app/ . -RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner RUN apk add --no-cache libc6-compat diff --git a/apps/edr-freight-api/html/payment-tester.html b/apps/edr-freight-api/html/payment-tester.html new file mode 100644 index 000000000..1bb1a5c80 --- /dev/null +++ b/apps/edr-freight-api/html/payment-tester.html @@ -0,0 +1,452 @@ + + + + + +EDR Freight β€” Payment Tester (Telebirr ETB + Card USD) + + + +
+

πŸš‚ EDR Freight β€” Payment Tester

+ Telebirr (ETB) + Card (USD) +
+ +
+ +
+

API connection

+
+
+ + +
+
+ + +
+
+
+ + not checked +
+
+ + +
+

1 Β· Choose a booking

+
+
+ + +
+ +
+
+ + Currency (ETB vs USD) is set per-booking via paymentCurrency. Pick an ETB booking to test Telebirr, a USD booking to test Card. +
+
+ + +
+ + +
+

2 Β· Initiate payment

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ Telebirr β†’ forces method TELEBIRR. Card β†’ forces method CARD. + Each calls POST {base}/payments/initiate and follows the returned clientAction (REDIRECT url for web). +
+

+
+ + +
+

3 Β· Track intent & receipt

+
+ + + no intent yet +
+
+ + Receipt = GET {base}/payments/receipt/{merchantOrderId} +
+
+ + +
+
+

Last response

+
β€”
+
+
+

Request log

+
+
+
+
+ + + + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 580173cc1..a0a119861 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -6,7 +6,7 @@ "scripts": { "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", "predev": "pnpm run clean", - "dev": "nest start --watch", + "dev": "nest start --watch --clearScreen false", "prebuild": "pnpm run clean", "build": "nest build", "start": "node dist/main.js", @@ -17,9 +17,24 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", + "seed:warehouse-export-receive-ready": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-export-receive-ready.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", + "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", + "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", + "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", + "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", - "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" + "seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts", + "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh", + "iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js", + "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", + "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", + "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", + "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", + "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", + "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -38,13 +53,15 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz", + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cross-env": "^10.1.0", "dotenv": "^17.4.2", + "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", @@ -69,13 +86,15 @@ "@types/node": "^20.14.0", "@types/pg": "^8.6.7", "@types/supertest": "^6.0.2", + "@types/vorpal": "^1.12.8", "jest": "^29.7.0", "supertest": "^7.0.0", "ts-jest": "^29.2.5", "ts-loader": "^9.5.1", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.4" + "typescript": "^5.5.4", + "vorpal": "^1.12.0" }, "jest": { "moduleFileExtensions": [ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -2,6 +2,7 @@ import { Module, OnApplicationBootstrap } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; +import { EventEmitterModule } from "@nestjs/event-emitter"; import { DataSource, DataSourceOptions } from "typeorm"; import { ensurePostgresSchemas } from "./config/ensure-postgres-schemas"; import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; @@ -13,7 +14,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; -import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module"; +import { ContractsModule } from "./modules/contracts/contracts.module"; import { SignaturesModule } from "./modules/signatures/signatures.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -52,8 +53,12 @@ import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; +import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; +import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -64,9 +69,12 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; +import { FuelModule } from './modules/fuel/fuel.module'; +import { MaintenanceModule } from './modules/maintenance/maintenance.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; +import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; @Module({ imports: [ @@ -75,7 +83,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig], }), ScheduleModule.forRoot(), - // EventEmitterModule.forRoot(), + EventEmitterModule.forRoot(), TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): TypeOrmModuleOptions => @@ -95,7 +103,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, - BookingOrdersModule, + ContractsModule, SignaturesModule, FilesModule, ConsignmentsModule, @@ -127,9 +135,12 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte OverviewModule, VehiclesModule, DriversModule, + FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -139,12 +150,16 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, DemoFreightDataSeeder, + GovCompaniesSeeder, IndodeFacilitySeeder, Batch14TestDataSeeder, Batch5TestDataSeeder, Batch7TestDataSeeder, Batch8TestDataSeeder, WarehouseDemoSeeder, + ExportDjiboutiInterchangeDemoSeeder, + MarshallingDemoTrainsSeeder, + ApprovedFirstLastMileDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -161,8 +176,11 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { @@ -179,6 +197,8 @@ export class AppModule implements OnApplicationBootstrap { await this.batch7TestDataSeeder.run(); await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); + await this.exportDjiboutiInterchangeDemoSeeder.run(); + await this.marshallingDemoTrainsSeeder.run(); // Idempotent demo data: β‰₯100 wagons/type, approval chains, 4 staff users. // Each block self-guards on an empty-table check, so this is safe every boot. // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, @@ -187,5 +207,8 @@ export class AppModule implements OnApplicationBootstrap { // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval // rules are disabled inside the seeder). Kept running for the staff users. await this.demoFreightDataSeeder.run(); + // Government entities (with importer/exporter profiles) that government + // bookings bill to. Idempotent β€” keyed by fixed IDs. + await this.govCompaniesSeeder.run(); } } diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..47d8f5b3c 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -116,8 +116,10 @@ export default registerAs("database", (): TypeOrmModuleOptions => { freightMigrationsGlob, ], migrationsRun: true, + migrationsTransactionMode: "each", // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts new file mode 100644 index 000000000..05061ed9a --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -0,0 +1,264 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { ContractsRepository } from '../modules/contracts/contracts.repository'; +import { Contract } from '../modules/contracts/entities/contract.entity'; +import { ContractRoute } from '../modules/contracts/entities/contract-route.entity'; +import { + ContractSignature, + ContractSignerRole, +} from '../modules/contracts/entities/contract-signature.entity'; +import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service'; +import { ContractTemplateResolver } from './contract-template.resolver'; +import { getTemplateMeta } from './contract-template.registry'; +import { ContractViewModel } from './contract-view-model.builder'; + +/** + * Signature row for the contract PDF. Mirrors the booking builder's + * `ContractSignatureView` but widens `role` to the contract's signer roles + * (CUSTOMER | STAFF | DIRECTOR | CEO). + */ +export interface ContractDocumentSignatureView { + role: ContractSignerRole; + signerDisplayName: string; + signedAt: string; + signatureImageUrl?: string | null; +} + +/** A single unit-rate row on the contract PDF β€” price per unit, NO total. */ +export interface ContractUnitRateRow { + label: string; + unitPrice: number; + unit: string; + currency: string; +} + +/** + * Pricing schedule for a Contract document: a unit-rate schedule (one price per + * unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped + * to stay structurally compatible with the renderer's expectations of + * {@link ContractViewModel.pricing} (it reads `currency`). + */ +export interface ContractUnitRateSchedule { + displayMode: 'UNIT_RATES'; + unitRates: ContractUnitRateRow[]; + currency: string; + equipmentReturn?: string; + originLabel: string; + destinationLabel: string; +} + +/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */ +function unitLabel(unit: string): string { + switch (unit) { + case 'per_container': + return 'container'; + case 'per_ton': + return 'ton'; + case 'per_item': + return 'item'; + case 'per_km': + return 'km'; + default: + return 'unit'; + } +} + +/** + * Builds the contract PDF view-model from the {@link Contract} aggregate (the new + * source of truth) β€” mirrors {@link ContractViewModelBuilder} but every field is + * sourced from the contract, its routes, cargo scope and unit-rate breakdown. + * The legacy booking-based builder remains untouched for the migration window. + */ +@Injectable() +export class ContractDocumentViewModelBuilder { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly templateResolver: ContractTemplateResolver, + ) {} + + async build( + contractId: string, + ): Promise<{ contract: Contract; view: ContractViewModel }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) { + throw new NotFoundException(`Contract ${contractId} not found`); + } + + const templateKey = + contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); + const template = getTemplateMeta(templateKey); + const pricing = this.buildPricing(contract); + const signatures = await this.loadSignatures(contractId); + + const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); + const hasStaff = signatures.some((s) => s.role === 'STAFF'); + const hasContractFile = Boolean( + contract.files?.some((f) => f.code === 'contract'), + ); + + const view: ContractViewModel = { + bookingId: contract.id, + reference: contract.reference, + status: contract.status, + templateKey, + template, + contractDate: new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + contractYear: new Date().getFullYear(), + client: { + companyName: contract.company?.name ?? 'Client', + companyAddress: this.valueOrDash(contract.company?.address), + companyLocation: this.valueOrDash(contract.company?.country), + phone: this.valueOrDash(contract.company?.phone), + email: this.valueOrDash(contract.company?.email), + tinNumber: this.valueOrDash(contract.company?.tin), + vatNumber: this.valueOrDash(contract.company?.vatNumber), + fanNumber: this.valueOrDash(contract.company?.fanNumber), + businessLicense: this.valueOrDash( + contract.company?.companyProfiles?.[0]?.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(contract), + pricing: pricing as unknown as ContractViewModel['pricing'], + // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking + // view-model's narrower CUSTOMER|STAFF role union. + signatures: signatures as unknown as ContractViewModel['signatures'], + canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: + contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + hasContractDocument: hasContractFile, + hasCustomerSignature: hasCustomer, + hasStaffSignature: hasStaff, + }; + + return { contract, view }; + } + + private async loadSignatures( + contractId: string, + ): Promise { + const rows = await this.contractsRepository.findSignatures(contractId); + return rows.map((s) => this.toSignatureView(s)); + } + + toSignatureView(row: ContractSignature): ContractDocumentSignatureView { + return { + role: row.role, + signerDisplayName: row.signerDisplayName, + signedAt: this.formatDate(row.signedAt), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } + + /** Unit-rate schedule from the contract's frozen pricing breakdown β€” NO totals. */ + private buildPricing(contract: Contract): ContractUnitRateSchedule { + const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null; + const currency = breakdown?.currency ?? contract.paymentCurrency; + const lineItems = breakdown?.lineItems ?? []; + const firstRoute = this.firstRoute(contract); + + return { + displayMode: 'UNIT_RATES', + unitRates: lineItems.map((line) => ({ + label: line.label, + unitPrice: line.unitPrice, + unit: unitLabel(line.unit), + currency, + })), + currency, + equipmentReturn: contract.equipmentReturn ?? 'β€”', + originLabel: this.yardLabel(firstRoute?.originYard), + destinationLabel: this.yardLabel(firstRoute?.destinationYard), + }; + } + + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { + const firstRoute = this.firstRoute(contract); + const cargoScope = (contract.cargoScope ?? [])[0]; + const cargoName = + cargoScope?.cargoType?.cargoTypeName || + cargoScope?.cargoFreeText || + (cargoScope?.containerSize + ? `${cargoScope.containerSize} container` + : 'Container cargo'); + + return { + originLabel: this.yardLabel(firstRoute?.originYard), + destinationLabel: this.yardLabel(firstRoute?.destinationYard), + tradeDirection: this.valueOrDash(contract.tradeDirection), + freightType: this.valueOrDash(contract.freightType), + serviceType: this.valueOrDash( + contract.serviceType?.serviceName ?? contract.serviceType?.code, + ), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), + contractType: this.valueOrDash(contract.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: 'β€”', + equipmentReturn: this.valueOrDash(contract.equipmentReturn), + hazardousLabel: contract.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress), + lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress), + }; + } + + /** The contract's primary route (lowest sortOrder), used for origin/destination labels. */ + private firstRoute(contract: Contract): ContractRoute | undefined { + const routes = [...(contract.routes ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + return routes[0]; + } + + /** + * The template resolver reads a Booking; a contract carries equivalent fields + * under a different shape (cargoType lives on cargoScope). Build a minimal, + * structurally-compatible adapter rather than widening the resolver signature. + */ + private toResolverInput( + contract: Contract, + ): Parameters[0] { + const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType; + return { + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + paymentCurrency: contract.paymentCurrency, + cargoType: cargoType ?? undefined, + serviceType: contract.serviceType, + } as Parameters[0]; + } + + 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); + } +} + +// Re-export for callers that want the role union without importing the entity. +export type { ContractSignerRole }; diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 9e0acc6fb..db2f7693e 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -80,8 +80,15 @@ export class ContractPdfService { this.logger.error( `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } throw new InternalServerErrorException( - 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); } } @@ -113,4 +120,85 @@ export class ContractPdfService { buffer.subarray(0, 5).toString('ascii') === '%PDF-' ); } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 92).slice(0, 72); + const body = lines + .map((line, index) => { + const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; + return `${prefix} (${this.escapePdfText(line)}) Tj`; + }) + .join('\n'); + const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } } 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 index cb3440739..64319612a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,6 +25,26 @@

Equipment return: {{pricing.equipmentReturn}}

{{/if}} + {{#if pricing.unitRates}} +

Unit Rate Schedule

+

+ The rates below are the frozen unit prices applicable to this contract. Quantities and the resulting + totals are determined per shipment at booking time; no total contract value is fixed at this stage. +

+ + + + + + {{#each pricing.unitRates}} + + + + + {{/each}} + +
ItemUnit price
{{label}}{{currency}} {{unitPrice}} / {{unit}}
+ {{else}}

Charges

@@ -56,6 +76,7 @@
+ {{/if}}

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts index 65052bad4..08ce634eb 100644 --- a/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts +++ b/apps/edr-freight-api/src/migrations/1748427600000-AddServiceTypesAndCargoTypes.ts @@ -93,20 +93,25 @@ export class AddServiceTypesAndCargoTypes1748427600000 implements MigrationInter ); // Create indexes for service_types - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_IS_ACTIVE", - columnNames: ["is_active"], - }), - ); - await queryRunner.createIndex( - "freight.service_types", - new TableIndex({ - name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", - columnNames: ["display_order"], - }), - ); + const table = await queryRunner.getTable("freight.service_types"); + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_IS_ACTIVE")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_IS_ACTIVE", + columnNames: ["is_active"], + }), + ); + } + if (table && !table.indices.some((idx) => idx.name === "IDX_SERVICE_TYPES_DISPLAY_ORDER")) { + await queryRunner.createIndex( + "freight.service_types", + new TableIndex({ + name: "IDX_SERVICE_TYPES_DISPLAY_ORDER", + columnNames: ["display_order"], + }), + ); + } // Create cargo_types table if (!(await queryRunner.hasTable("freight.cargo_types"))) await queryRunner.createTable( diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts index b921a7194..0f9021ff2 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface { weight NUMERIC(14,3) NOT NULL DEFAULT 0, volume NUMERIC(12,3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + inspection_status VARCHAR(20) NULL, arrived_at TIMESTAMPTZ NULL, inspected_at TIMESTAMPTZ NULL, ready_for_loading_at TIMESTAMPTZ NULL, diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts index fa6087faa..1cad0fe66 100644 --- a/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddWarehouseAllocationAndFeeRules.ts @@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration { name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'free_days', type: 'int', default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, + { name: 'tiers', type: 'jsonb', default: "'[]'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'is_active', type: 'boolean', default: true }, { name: 'created_at', type: 'timestamptz', default: 'now()' }, diff --git a/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts b/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts new file mode 100644 index 000000000..4a59e33b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000005-AddWarehouseInventoryInspectionStatusFix.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Fix for fresh deployments: AddWarehouseInspection1750000000003 runs before + * the warehouse_inventory table exists, so it cannot add inspection_status. + */ +export class AddWarehouseInventoryInspectionStatusFix1791000000005 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if ((await queryRunner.hasTable(this.table)) && !(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + if ((await queryRunner.hasTable(this.table)) && (await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts new file mode 100644 index 000000000..e635d19c1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000004-SeedContractValidityPeriods.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seeds the admin-configurable "contract validity periods" setting (days). Stored + * as a dropdown_settings row whose options each hold a day count in `value`, so + * backoffice manages them through the existing Dropdown Settings UI and the + * contract staff-accept dialog only offers the configured durations. + */ +export class SeedContractValidityPeriods1792000000004 + implements MigrationInterface +{ + name = 'SeedContractValidityPeriods1792000000004'; + private readonly code = 'contract_validity_periods'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '180', label: '6 months' }, + { value: '365', label: '1 year' }, + { value: '730', label: '2 years' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'Contract Validity Periods (days)', + 'Validity durations (in days) a staff can choose when accepting a submitted contract.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts new file mode 100644 index 000000000..b2026b753 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000002-CreateLastMileContainerAllocations.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.last_mile_container_allocations table β€” container allocation + * records linking last-mile deliveries with containers and vehicles. + */ +export class CreateLastMileContainerAllocations1810000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.last_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'last_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['last_mile_id'], + referencedTableName: 'freight.last_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.last_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_last_mile_id" ON "freight"."last_mile_container_allocations" ("last_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_last_mile_container_allocations_vehicle_id" ON "freight"."last_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.last_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.last_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts new file mode 100644 index 000000000..aeedae2b1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1810000000004-AddPostPaymentCompletedColumn.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddPostPaymentCompletedColumn1810000000004 implements MigrationInterface { + name = 'AddPostPaymentCompletedColumn1810000000004'; + + public async up(queryRunner: QueryRunner): Promise { + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.first_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + const hasColumn = await queryRunner.hasColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + if (!hasColumn) { + await queryRunner.addColumn( + 'freight.last_mile_deliveries', + new TableColumn({ + name: 'is_post_payment_completed', + type: 'boolean', + default: false, + isNullable: false, + }) + ); + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + const lastMileTable = await queryRunner.hasTable('freight.last_mile_deliveries'); + if (lastMileTable) { + await queryRunner.dropColumn('freight.last_mile_deliveries', 'is_post_payment_completed'); + } + + const firstMileTable = await queryRunner.hasTable('freight.first_mile_deliveries'); + if (firstMileTable) { + await queryRunner.dropColumn('freight.first_mile_deliveries', 'is_post_payment_completed'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts new file mode 100644 index 000000000..523772b39 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Catch-up for environments where AddWarehouseInspection ran before the + * warehouse module table existed. Production needs this column for unload and + * inspection flows because the WarehouseInventory entity maps inspectionStatus. + */ +export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: 'inspection_status', + type: 'varchar', + length: '20', + isNullable: true, + }), + ); + } + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status + ON freight.warehouse_inventory(inspection_status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status + `); + + if (await queryRunner.hasColumn(this.table, 'inspection_status')) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts new file mode 100644 index 000000000..98fa8b228 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC, + ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS estimated_distance_km, + DROP COLUMN IF EXISTS actual_distance_km; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts new file mode 100644 index 000000000..4c2fb5d95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -0,0 +1,185 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Freight billing β€” `invoices` + `invoice_lines` tables. + * + * Matches: + * - billing/entities/invoice.entity.ts + * - billing/entities/invoice-line.entity.ts + * + * The status enum mirrors `Freight.InvoiceStatus` and uses TypeORM's default + * enum-type name (`__enum`) so the entity's `type: "enum"` + * column resolves to it without an explicit `enumName`. + */ +export class CreateInvoices1821000000002 implements MigrationInterface { + name = "CreateInvoices1821000000002"; + + public async up(queryRunner: QueryRunner): Promise { + const typeExists = await queryRunner.query( + `SELECT 1 FROM pg_type WHERE typname = 'invoices_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight');`, + ); + + if (!typeExists.length) { + await queryRunner.query(` + CREATE TYPE freight.invoices_status_enum AS ENUM ( + 'DRAFT', + 'PENDING', + 'PAID', + 'OVERDUE', + 'CANCELLED', + 'REFUNDED' + ); + `); + } + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + ` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(), + ADD COLUMN IF NOT EXISTS invoice_number varchar(64), + ADD COLUMN IF NOT EXISTS company_id uuid, + ADD COLUMN IF NOT EXISTS company_profile_id uuid, + ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB', + ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS source varchar(255), + ADD COLUMN IF NOT EXISTS source_id varchar(255), + ADD COLUMN IF NOT EXISTS type varchar(255), + ADD COLUMN IF NOT EXISTS issued_at timestamptz, + ADD COLUMN IF NOT EXISTS payment_id uuid, + ADD COLUMN IF NOT EXISTS due_at timestamptz, + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS deleted_at timestamptz; + `, + ); + await queryRunner.query(` + UPDATE freight.invoices + SET due_at = COALESCE(due_at, issued_at, created_at, now()) + WHERE due_at IS NULL; + `); + await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE contype = 'p' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_invoices_invoice_number' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company + FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_company_profile' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile + FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_invoices_payment' + AND conrelid = 'freight.invoices'::regclass + ) THEN + ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment + FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL; + END IF; + END $$; + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoice_lines;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.invoices;`); + await queryRunner.query( + `DROP TYPE IF EXISTS freight.invoices_status_enum;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts new file mode 100644 index 000000000..95ea3db1b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000003-AddCompanyKindAndGovBookingLinks.ts @@ -0,0 +1,129 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Government bookings now bill to a real seeded government company + an explicit + * importer/exporter profile, instead of carrying a null company + free-text + * institution. This migration: + * + * 1. Adds `companies.kind` (commercial | government). + * 2. Seeds the Ethiopian government entities + their importer/exporter + * profiles (mirrors src/seed/data/gov-companies.data.ts β€” keep in sync). + * 3. Backfills every booking with a NULL company_id / company_profile_id so + * the NOT NULL constraints below can be applied: + * - NULL company_id β†’ the default government company. + * - NULL company_profile_id β†’ the company's profile matching the booking + * trade direction; else any profile of the company; else the default + * government importer profile. + * 4. Enforces NOT NULL on bookings.company_id and bookings.company_profile_id. + */ +export class AddCompanyKindAndGovBookingLinks1821000000003 + implements MigrationInterface +{ + name = "AddCompanyKindAndGovBookingLinks1821000000003"; + + // Mirrors src/seed/data/gov-companies.data.ts + private readonly govCompanies = [ + { id: "0a1b0001-0000-4000-8000-000000000001", name: "Federal Government of Ethiopia", tin: "0000000001", email: "procurement@gov.et", phone: "+251111000001", im: "0b1c0001-0000-4000-8000-000000000001", ex: "0b1c0001-0000-4000-8000-000000000002", imRef: "IM-90001", exRef: "EX-90001" }, + { id: "0a1b0002-0000-4000-8000-000000000002", name: "Ministry of National Defense", tin: "0000000002", email: "logistics@mod.gov.et", phone: "+251111000002", im: "0b1c0002-0000-4000-8000-000000000001", ex: "0b1c0002-0000-4000-8000-000000000002", imRef: "IM-90002", exRef: "EX-90002" }, + { id: "0a1b0003-0000-4000-8000-000000000003", name: "Ethiopian Roads Administration", tin: "0000000003", email: "supply@era.gov.et", phone: "+251111000003", im: "0b1c0003-0000-4000-8000-000000000001", ex: "0b1c0003-0000-4000-8000-000000000002", imRef: "IM-90003", exRef: "EX-90003" }, + { id: "0a1b0004-0000-4000-8000-000000000004", name: "Ministry of Agriculture", tin: "0000000004", email: "imports@moa.gov.et", phone: "+251111000004", im: "0b1c0004-0000-4000-8000-000000000001", ex: "0b1c0004-0000-4000-8000-000000000002", imRef: "IM-90004", exRef: "EX-90004" }, + { id: "0a1b0005-0000-4000-8000-000000000005", name: "Ministry of Trade and Regional Integration", tin: "0000000005", email: "trade@motri.gov.et", phone: "+251111000005", im: "0b1c0005-0000-4000-8000-000000000001", ex: "0b1c0005-0000-4000-8000-000000000002", imRef: "IM-90005", exRef: "EX-90005" }, + { id: "0a1b0006-0000-4000-8000-000000000006", name: "Ethiopian Disaster Risk Management Commission", tin: "0000000006", email: "relief@edrmc.gov.et", phone: "+251111000006", im: "0b1c0006-0000-4000-8000-000000000001", ex: "0b1c0006-0000-4000-8000-000000000002", imRef: "IM-90006", exRef: "EX-90006" }, + ]; + + private get defaultCompanyId(): string { + return this.govCompanies[0].id; + } + private get defaultImporterProfileId(): string { + return this.govCompanies[0].im; + } + + public async up(queryRunner: QueryRunner): Promise { + // 1. kind column + await queryRunner.query( + `ALTER TABLE "freight"."companies" ADD COLUMN IF NOT EXISTS "kind" varchar(20) NOT NULL DEFAULT 'commercial'`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_companies_kind" ON "freight"."companies" ("kind")`, + ); + + // 2. seed government companies + importer/exporter profiles (idempotent) + for (const g of this.govCompanies) { + await queryRunner.query( + `INSERT INTO "freight"."companies" ("id", "name", "type", "kind", "status", "tin", "country", "email", "phone") + VALUES ($1, $2, 'customer', 'government', 'active', $3, 'Ethiopia', $4, $5) + ON CONFLICT ("id") DO NOTHING`, + [g.id, g.name, g.tin, g.email, g.phone], + ); + await queryRunner.query( + `INSERT INTO "freight"."company_profiles" ("id", "company_id", "type", "reference", "status") + VALUES ($1, $2, 'importer', $3, 'active'), ($4, $2, 'exporter', $5, 'active') + ON CONFLICT ("id") DO NOTHING`, + [g.im, g.id, g.imRef, g.ex, g.exRef], + ); + } + + // 3a. backfill NULL company_id β†’ default government company + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_id" = $1 WHERE "company_id" IS NULL`, + [this.defaultCompanyId], + ); + + // 3b. backfill NULL company_profile_id β†’ profile matching trade direction + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = cp."id" + FROM "freight"."company_profiles" cp + WHERE b."company_profile_id" IS NULL + AND cp."company_id" = b."company_id" + AND cp."deleted_at" IS NULL + AND cp."type" = CASE b."trade_direction" + WHEN 'IMPORT' THEN 'importer' + WHEN 'EXPORT' THEN 'exporter' + ELSE NULL END`, + ); + + // 3c. fallback β†’ any profile of the booking's company + await queryRunner.query( + `UPDATE "freight"."bookings" b + SET "company_profile_id" = ( + SELECT cp."id" FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL + ORDER BY cp."created_at" ASC LIMIT 1) + WHERE b."company_profile_id" IS NULL + AND EXISTS ( + SELECT 1 FROM "freight"."company_profiles" cp + WHERE cp."company_id" = b."company_id" AND cp."deleted_at" IS NULL)`, + ); + + // 3d. final fallback β†’ default government importer profile + await queryRunner.query( + `UPDATE "freight"."bookings" SET "company_profile_id" = $1 WHERE "company_profile_id" IS NULL`, + [this.defaultImporterProfileId], + ); + + // 4. enforce NOT NULL + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_profile_id" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."bookings" ALTER COLUMN "company_id" DROP NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "freight"."IDX_companies_kind"`, + ); + await queryRunner.query( + `ALTER TABLE "freight"."companies" DROP COLUMN IF EXISTS "kind"`, + ); + // Seeded government rows are intentionally left in place. + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts new file mode 100644 index 000000000..9f0e8e7bd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000004-MakePaymentsTypeGeneric.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Make the payment projection source-agnostic so any domain (not just bookings) + * can own a payment intent. + * + * - `payments.type` enum `('booking')` β†’ `varchar(50)`. It now stores the + * invoice SOURCE (e.g. 'booking', 'demurrage'), supplied by the caller, so a + * new domain no longer needs an enum migration to write its intents. + * - adds `payments.reference_type varchar(40)` β€” the gateway reference type + * (`PaymentReferenceType`) the intent was opened with, so the reconcile/poll + * path can query the provider without hardcoding it. + * + * Matches payment/entities/payment.entity.ts. + */ +export class MakePaymentsTypeGeneric1821000000004 implements MigrationInterface { + name = "MakePaymentsTypeGeneric1821000000004"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE varchar(50) USING type::text;`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS freight.payments_type_enum;`); + + await queryRunner.query( + `ALTER TABLE freight.payments ADD COLUMN reference_type varchar(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.payments DROP COLUMN IF EXISTS reference_type;`, + ); + + // Restore the single-value enum. Any non-'booking' rows would block the cast; + // collapse them first so the down migration is safe. + await queryRunner.query( + `UPDATE freight.payments SET type = 'booking' WHERE type <> 'booking';`, + ); + await queryRunner.query( + `CREATE TYPE freight.payments_type_enum AS ENUM ('booking');`, + ); + await queryRunner.query( + `ALTER TABLE freight.payments ALTER COLUMN type TYPE freight.payments_type_enum USING type::freight.payments_type_enum;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts new file mode 100644 index 000000000..7995e5023 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts @@ -0,0 +1,357 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Contract–Booking separation (additive phase). Introduces a first-class + * `freight.contracts` aggregate that owns the legal/commercial agreement (scope + * + unit rates, no quantities) and spawns shipment `bookings` via `contract_id`. + * + * Purely additive: no legacy columns are dropped here. The data backfill and + * legacy-column removal happen in a later cutover migration. + * + * See docs/new-doc.md Β§5. + */ +export class CreateContracts1822000000000 implements MigrationInterface { + name = 'CreateContracts1822000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── contracts ─────────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contracts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reference VARCHAR(64) NOT NULL UNIQUE, + + company_id UUID, + company_profile_id UUID, + is_government BOOLEAN NOT NULL DEFAULT FALSE, + government_institution VARCHAR(255), + + contract_kind VARCHAR(20) NOT NULL, + renewal_of_id UUID REFERENCES freight.contracts(id), + trade_direction VARCHAR(10) NOT NULL, + freight_type VARCHAR(20) NOT NULL, + + service_type_id UUID NOT NULL, + payment_currency VARCHAR(5) NOT NULL, + customs_clearing_enabled BOOLEAN NOT NULL DEFAULT FALSE, + customs_clearing_agent VARCHAR(200), + equipment_return VARCHAR(20), + + first_mile_pickup_address TEXT, + first_mile_pickup_lat NUMERIC(10,7), + first_mile_pickup_lng NUMERIC(10,7), + last_mile_delivery_address TEXT, + last_mile_delivery_lat NUMERIC(10,7), + last_mile_delivery_lng NUMERIC(10,7), + + is_hazardous BOOLEAN NOT NULL DEFAULT FALSE, + is_reefer BOOLEAN NOT NULL DEFAULT FALSE, + + estimated_shipment_date TIMESTAMPTZ, + contract_validity_days INT, + contract_valid_from TIMESTAMPTZ, + contract_valid_until TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + + status VARCHAR(40) NOT NULL DEFAULT 'DRAFT', + clearance_status VARCHAR(40) NOT NULL DEFAULT 'NOT_APPLICABLE', + clearance_cycle_number INT NOT NULL DEFAULT 0, + + pricing_breakdown JSONB, + pricing_display_mode VARCHAR(20) DEFAULT 'UNIT_RATES', + + contract_type VARCHAR(20), + contract_template_key VARCHAR(128), + contract_generated_at TIMESTAMPTZ, + contract_summary TEXT, + version_number INT NOT NULL DEFAULT 1, + financial_terms JSONB, + + approved_by_staff_id UUID, + approved_by_staff_at TIMESTAMPTZ, + signed_by_director_id UUID, + signed_by_director_at TIMESTAMPTZ, + signed_by_ceo_id UUID, + signed_by_ceo_at TIMESTAMPTZ, + customer_signed_at TIMESTAMPTZ, + fully_executed_at TIMESTAMPTZ, + locked_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_company ON freight.contracts(company_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_status ON freight.contracts(status);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_kind ON freight.contracts(contract_kind);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contracts_valid_until ON freight.contracts(contract_valid_until);`); + + // ── contract_routes ────────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_routes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + origin_yard_id UUID NOT NULL, + destination_yard_id UUID NOT NULL, + km NUMERIC(10,2), + sort_order SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_route UNIQUE (contract_id, origin_yard_id, destination_yard_id) + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_routes_contract ON freight.contract_routes(contract_id);`); + + // ── contract_cargo_scope ───────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_cargo_scope ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + container_size VARCHAR(10), + cargo_type_id UUID, + cargo_free_text VARCHAR(200), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_container_size UNIQUE NULLS NOT DISTINCT (contract_id, container_size) + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_cargo_scope_contract ON freight.contract_cargo_scope(contract_id);`); + + // ── contract_signatures ────────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_signatures ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + signer_display_name VARCHAR(255) NOT NULL, + signature_file_id UUID, + consent_text TEXT, + signed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_signatures_contract ON freight.contract_signatures(contract_id);`); + + // ── contract_approval_steps ────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_approval_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + step_order SMALLINT NOT NULL DEFAULT 0, + required_role VARCHAR(40) NOT NULL, + blocks_role VARCHAR(40), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + acted_by_staff_id UUID, + acted_at TIMESTAMPTZ, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_approval_steps_contract ON freight.contract_approval_steps(contract_id);`); + + // ── contract_rate_snapshots ────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_rate_snapshots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + rate_id UUID, + rate_code VARCHAR(64) NOT NULL, + description VARCHAR(255), + unit_price NUMERIC(14,2) NOT NULL, + unit_of_measure VARCHAR(32) NOT NULL, + currency VARCHAR(5) NOT NULL, + container_size VARCHAR(10), + is_surcharge BOOLEAN DEFAULT FALSE, + conditional_on VARCHAR(32), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_rate_snapshots_contract ON freight.contract_rate_snapshots(contract_id);`); + + // ── contract_review_notes ──────────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_review_notes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + note_type VARCHAR(40) NOT NULL, + body TEXT NOT NULL, + author_role VARCHAR(20), + author_user_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_review_notes_contract ON freight.contract_review_notes(contract_id);`); + + // ── contract_clearance_cycles ──────────────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_clearance_cycles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + cycle_number INT NOT NULL, + status VARCHAR(40) NOT NULL DEFAULT 'AWAITING_DOCUMENTS', + booking_id UUID, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + clearance_ready_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_clearance_cycle UNIQUE (contract_id, cycle_number) + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_clearance_cycles_contract ON freight.contract_clearance_cycles(contract_id);`); + + // ── contract_document_review (pre-booking clearance, Path B) ───────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.contract_document_review ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES freight.contracts(id) ON DELETE CASCADE, + clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id), + setting_code VARCHAR(128) NOT NULL, + file_key VARCHAR(128) NOT NULL, + file_record_id UUID, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + note TEXT, + uploaded_by_role VARCHAR(20) NOT NULL DEFAULT 'CUSTOMER', + reviewed_by_staff_id UUID, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_contract_document_review_doc + UNIQUE NULLS NOT DISTINCT (contract_id, clearance_cycle_id, setting_code, file_key) + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_contract ON freight.contract_document_review(contract_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_contract_doc_review_status ON freight.contract_document_review(status);`); + + // ── clearance_milestones (GL tracking) ─────────────────────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.clearance_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID REFERENCES freight.bookings(id) ON DELETE CASCADE, + contract_id UUID REFERENCES freight.contracts(id) ON DELETE CASCADE, + clearance_cycle_id UUID REFERENCES freight.contract_clearance_cycles(id), + milestone_code VARCHAR(64) NOT NULL, + milestone_label VARCHAR(255) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + owner_region VARCHAR(5), + triggered_by_doc BOOLEAN DEFAULT FALSE, + triggered_at TIMESTAMPTZ, + triggered_by_user_id UUID, + note TEXT, + sort_order SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_booking ON freight.clearance_milestones(booking_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_contract ON freight.clearance_milestones(contract_id);`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_clearance_milestones_region ON freight.clearance_milestones(owner_region, status);`); + // booking-scoped and contract-cycle-scoped uniqueness for milestone codes + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_booking + ON freight.clearance_milestones(booking_id, milestone_code) WHERE booking_id IS NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_clearance_milestone_cycle + ON freight.clearance_milestones(clearance_cycle_id, milestone_code) WHERE clearance_cycle_id IS NOT NULL; + `); + + // ── booking_container_units (per-unit container detail) ────────────────── + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_container_units ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_container_id UUID NOT NULL REFERENCES freight.booking_container(id) ON DELETE CASCADE, + container_number VARCHAR(64) NOT NULL, + seal_number VARCHAR(64), + vgm_tons NUMERIC(10,3) NOT NULL, + is_hazardous BOOLEAN DEFAULT FALSE, + is_reefer BOOLEAN DEFAULT FALSE, + sort_order SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + CONSTRAINT uq_booking_container_unit_number UNIQUE (booking_container_id, container_number) + ); + `); + + // ── ALTER bookings ─────────────────────────────────────────────────────── + await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`); + await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_route_id UUID REFERENCES freight.contract_routes(id);`); + await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_role VARCHAR(20) DEFAULT 'CUSTOMER';`); + await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS created_by_user_id UUID;`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_bookings_contract ON freight.bookings(contract_id);`); + // One active booking per ONE_TIME contract. Postgres forbids a subquery in an + // index predicate, so we denormalize the contract kind onto the booking and + // predicate on that. The column is stamped at booking creation from the + // contract; the app layer (ContractBookingService) is the primary guard and + // this index is the backstop. + await queryRunner.query(`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS contract_kind VARCHAR(20);`); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS uq_one_active_booking_per_one_time_contract + ON freight.bookings (contract_id) + WHERE status NOT IN ('EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED') + AND contract_id IS NOT NULL + AND contract_kind = 'ONE_TIME'; + `); + + // ── ALTER booking_container ────────────────────────────────────────────── + await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS container_size VARCHAR(10);`); + await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS hazardous_quantity SMALLINT DEFAULT 0;`); + await queryRunner.query(`ALTER TABLE freight.booking_container ADD COLUMN IF NOT EXISTS reefer_quantity SMALLINT DEFAULT 0;`); + + // ── ALTER booking_document_review (denormalized contract link) ─────────── + await queryRunner.query(`ALTER TABLE freight.booking_document_review ADD COLUMN IF NOT EXISTS contract_id UUID REFERENCES freight.contracts(id);`); + + // ── Extend file_upload_fields with phased GL metadata ──────────────────── + await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS phase VARCHAR(40);`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS owner_region VARCHAR(5);`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS trade_direction VARCHAR(10);`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields ADD COLUMN IF NOT EXISTS triggers_milestone_code VARCHAR(64);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS triggers_milestone_code;`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS trade_direction;`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS owner_region;`); + await queryRunner.query(`ALTER TABLE freight.file_upload_fields DROP COLUMN IF EXISTS phase;`); + + await queryRunner.query(`ALTER TABLE freight.booking_document_review DROP COLUMN IF EXISTS contract_id;`); + + await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS reefer_quantity;`); + await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS hazardous_quantity;`); + await queryRunner.query(`ALTER TABLE freight.booking_container DROP COLUMN IF EXISTS container_size;`); + + await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_one_active_booking_per_one_time_contract;`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_contract;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_kind;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_user_id;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS created_by_role;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_route_id;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS contract_id;`); + + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_container_units;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_milestones;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_document_review;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_clearance_cycles;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_review_notes;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_rate_snapshots;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_approval_steps;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_signatures;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_cargo_scope;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_routes;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contracts;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts new file mode 100644 index 000000000..981918f25 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1822000000000-CreateImportDjiboutiOperations.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface { + name = 'CreateImportDjiboutiOperations1822000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_djibouti_operations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'train_schedule_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true }, + { name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true }, + { name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true }, + { name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true }, + { name: 'load_list_generated_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.import_djibouti_operations', + new TableIndex({ + name: 'idx_import_djibouti_operations_schedule', + columnNames: ['train_schedule_id'], + }), + ); + + await queryRunner.createForeignKey( + 'freight.import_djibouti_operations', + new TableForeignKey({ + columnNames: ['train_schedule_id'], + referencedTableName: 'train_schedules', + referencedSchema: 'freight', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.import_djibouti_operations', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts b/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts new file mode 100644 index 000000000..552e4affd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts @@ -0,0 +1,175 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Data backfill for the contract–booking separation (docs/new-doc.md Β§17). + * + * For every legacy `booking_type = 'GENERAL_CONTRACT'` booking we synthesise a + * `freight.contracts` row from its contract-phase columns, copy its routes + * (contract_route_lines β†’ contract_routes, dropping quantity), and point the + * contract + every child shipment booking (linked via booking_orders) at it. + * + * Per Β§19 item 1, historical ONE_TIME bookings that went through the full + * contract flow get a contract parent inserted and `contract_id` set on the same + * booking row (no row split). + * + * Idempotent: skips bookings that already have `contract_id` set, and matches a + * synthesised contract by a deterministic `CTR-` reference. + */ +export class BackfillContractsFromBookings1823000000000 + implements MigrationInterface +{ + name = 'BackfillContractsFromBookings1823000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 1. One contract per GENERAL_CONTRACT booking, carrying the contract-phase + // columns. Reference is derived from the source booking id so re-runs are + // idempotent (ON CONFLICT DO NOTHING on the unique reference). + await queryRunner.query(` + INSERT INTO freight.contracts ( + reference, company_id, company_profile_id, is_government, government_institution, + contract_kind, trade_direction, freight_type, service_type_id, payment_currency, + customs_clearing_enabled, customs_clearing_agent, equipment_return, + first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng, + last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng, + is_hazardous, is_reefer, estimated_shipment_date, + contract_validity_days, contract_valid_from, contract_valid_until, expires_at, + status, clearance_status, clearance_cycle_number, + pricing_breakdown, contract_type, contract_template_key, contract_generated_at, + contract_summary, version_number, + approved_by_staff_id, approved_by_staff_at, + signed_by_director_id, signed_by_director_at, + signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at, + created_at, updated_at + ) + SELECT + 'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution, + 'GENERAL', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency, + b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return, + b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng, + b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng, + b.is_hazardous, b.is_reefer, b.estimated_shipment_date, + b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, b.expires_at, + CASE + WHEN b.status IN ('CONTRACT_ACTIVE') THEN 'CONTRACT_ACTIVE' + WHEN b.status IN ('CONTRACT_CLOSED') THEN 'CONTRACT_CLOSED' + WHEN b.status IN ('EXPIRED') THEN 'EXPIRED' + WHEN b.status IN ('CANCELLED') THEN 'CANCELLED' + WHEN b.status IN ('REJECTED') THEN 'REJECTED' + ELSE 'CONTRACT_ACTIVE' + END, + CASE WHEN b.customs_clearing_enabled THEN 'NOT_APPLICABLE' ELSE 'NOT_APPLICABLE' END, + 0, + b.pricing_breakdown, + b.contract_type, b.contract_template_key, b.contract_generated_at, + b.contract_summary, COALESCE(b.version_number, 1), + b.approved_by_staff_id, b.approved_by_staff_at, + b.signed_by_director_id, b.signed_by_director_at, + b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at, + b.created_at, b.updated_at + FROM freight.bookings b + WHERE b.booking_type = 'GENERAL_CONTRACT' + ON CONFLICT (reference) DO NOTHING; + `); + + // 2. Copy each general contract's route lines into contract_routes (no qty). + await queryRunner.query(` + INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, km, sort_order, created_at, updated_at) + SELECT c.id, crl.origin_yard_id, crl.destination_yard_id, crl.km, 0, now(), now() + FROM freight.contract_route_lines crl + JOIN freight.contracts c ON c.reference = 'CTR-' || crl.contract_booking_id::text + ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING; + `); + + // 3. Point the general-contract booking itself at its new contract, and stamp + // the denormalized contract_kind for the active-booking index. + await queryRunner.query(` + UPDATE freight.bookings b + SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER' + FROM freight.contracts c + WHERE c.reference = 'CTR-' || b.id::text + AND b.booking_type = 'GENERAL_CONTRACT' + AND b.contract_id IS NULL; + `); + + // 4. Point each child shipment booking (spawned via booking_orders) at the + // same contract as its parent general contract. + await queryRunner.query(` + UPDATE freight.bookings child + SET contract_id = c.id, contract_kind = 'GENERAL', created_by_role = 'CUSTOMER' + FROM freight.booking_orders bo + JOIN freight.contracts c ON c.reference = 'CTR-' || bo.contract_booking_id::text + WHERE child.id = bo.booking_id + AND child.contract_id IS NULL; + `); + + // 5. Historical ONE_TIME bookings that completed the contract flow: synthesise + // a contract parent and point the same booking row at it (no row split). + await queryRunner.query(` + INSERT INTO freight.contracts ( + reference, company_id, company_profile_id, is_government, government_institution, + contract_kind, trade_direction, freight_type, service_type_id, payment_currency, + customs_clearing_enabled, customs_clearing_agent, equipment_return, + first_mile_pickup_address, first_mile_pickup_lat, first_mile_pickup_lng, + last_mile_delivery_address, last_mile_delivery_lat, last_mile_delivery_lng, + is_hazardous, is_reefer, estimated_shipment_date, + contract_validity_days, contract_valid_from, contract_valid_until, + status, clearance_status, clearance_cycle_number, + pricing_breakdown, contract_type, contract_template_key, contract_generated_at, + contract_summary, version_number, + approved_by_staff_id, approved_by_staff_at, + signed_by_director_id, signed_by_director_at, + signed_by_ceo_id, signed_by_ceo_at, customer_signed_at, fully_executed_at, + created_at, updated_at + ) + SELECT + 'CTR-' || b.id::text, b.company_id, b.company_profile_id, b.is_government, b.government_institution, + 'ONE_TIME', b.trade_direction, b.freight_type, b.service_type_id, b.payment_currency, + b.customs_clearing_enabled, b.customs_clearing_agent, b.equipment_return, + b.first_mile_pickup_address, b.first_mile_pickup_lat, b.first_mile_pickup_lng, + b.last_mile_delivery_address, b.last_mile_delivery_lat, b.last_mile_delivery_lng, + b.is_hazardous, b.is_reefer, b.estimated_shipment_date, + b.contract_validity_days, b.contract_valid_from, b.contract_valid_until, + 'FULLY_EXECUTED', 'NOT_APPLICABLE', 0, + b.pricing_breakdown, b.contract_type, b.contract_template_key, b.contract_generated_at, + b.contract_summary, COALESCE(b.version_number, 1), + b.approved_by_staff_id, b.approved_by_staff_at, + b.signed_by_director_id, b.signed_by_director_at, + b.signed_by_ceo_id, b.signed_by_ceo_at, b.customer_signed_at, b.fully_executed_at, + b.created_at, b.updated_at + FROM freight.bookings b + WHERE COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME' + AND b.contract_id IS NULL + AND b.contract_generated_at IS NOT NULL + ON CONFLICT (reference) DO NOTHING; + `); + + await queryRunner.query(` + UPDATE freight.bookings b + SET contract_id = c.id, contract_kind = 'ONE_TIME', created_by_role = 'CUSTOMER' + FROM freight.contracts c + WHERE c.reference = 'CTR-' || b.id::text + AND COALESCE(b.booking_type, 'ONE_TIME') = 'ONE_TIME' + AND b.contract_id IS NULL; + `); + + // 6. Build a single route per ONE_TIME contract from the booking's own + // origin/destination (general contracts already got their routes in step 2). + await queryRunner.query(` + INSERT INTO freight.contract_routes (contract_id, origin_yard_id, destination_yard_id, sort_order, created_at, updated_at) + SELECT c.id, b.origin_yard_id, b.destination_yard_id, 0, now(), now() + FROM freight.bookings b + JOIN freight.contracts c ON c.id = b.contract_id AND c.contract_kind = 'ONE_TIME' + WHERE b.origin_yard_id IS NOT NULL AND b.destination_yard_id IS NOT NULL + ON CONFLICT (contract_id, origin_yard_id, destination_yard_id) DO NOTHING; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Unlink bookings and drop the synthesised contracts (and their cascaded routes). + await queryRunner.query(` + UPDATE freight.bookings SET contract_id = NULL, contract_route_id = NULL + WHERE contract_id IN (SELECT id FROM freight.contracts WHERE reference LIKE 'CTR-%'); + `); + await queryRunner.query(`DELETE FROM freight.contracts WHERE reference LIKE 'CTR-%';`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts new file mode 100644 index 000000000..1a198e983 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1823000000000-CreateImportOperationsTables.ts @@ -0,0 +1,95 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateImportOperationsTables1823000000000 implements MigrationInterface { + name = 'CreateImportOperationsTables1823000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'djibouti_import_incidents', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid' }, + { name: 'container_number', type: 'varchar', length: '80', isNullable: true }, + { name: 'cargo_id', type: 'uuid', isNullable: true }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'station', type: 'varchar', length: '120', isNullable: true }, + { name: 'incident_type', type: 'varchar', length: '40' }, + { name: 'description', type: 'text' }, + { name: 'photos', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'reported_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'reported_at', type: 'timestamptz' }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'import_customs_finalizations', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'booking_id', type: 'uuid', isUnique: true }, + { name: 'documents', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true }, + { name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true }, + { name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true }, + { name: 'customs_risk', type: 'varchar', length: '12', isNullable: true }, + { name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true }, + { name: 'completed_at', type: 'timestamptz', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] })); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'empty_container_returns', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' }, + { name: 'container_number', type: 'varchar', length: '80' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'return_date', type: 'timestamptz' }, + { name: 'facility', type: 'varchar', length: '120', isNullable: true }, + { name: 'yard', type: 'varchar', length: '120', isNullable: true }, + { name: 'zone', type: 'varchar', length: '120', isNullable: true }, + { name: 'condition', type: 'text', isNullable: true }, + { name: 'handover_note', type: 'text', isNullable: true }, + { name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" }, + { name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true }, + { name: 'performed_by', type: 'varchar', length: '120', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] })); + await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] })); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.empty_container_returns', true); + await queryRunner.dropTable('freight.import_customs_finalizations', true); + await queryRunner.dropTable('freight.djibouti_import_incidents', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts b/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts new file mode 100644 index 000000000..06bcff14a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Cutover cleanup (docs/new-doc.md Β§17 Phase 4). Runs AFTER the backfill + * (1823…) so every legacy general contract + drawdown already lives in the + * `contracts` aggregate. + * + * Drops the now-unused booking-as-contract artifacts: + * - `bookings.booking_type` (every booking is a real shipment now) + * - `bookings.previous_contract_id` (renewal lives on `contracts.renewal_of_id`) + * - the `booking_orders` / `booking_order_lines` drawdown ledger + * - `contract_route_lines` (superseded by `contract_routes`) + * + * The shipment/payment/scheduling/allocation columns on `bookings` are kept β€” + * the operational pipeline is unchanged. + */ +export class DropLegacyContractTables1824000000000 implements MigrationInterface { + name = 'DropLegacyContractTables1824000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // booking_order_lines references booking_orders β†’ drop child first. + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_order_lines CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_orders CASCADE;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.contract_route_lines CASCADE;`); + + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`); + await queryRunner.query(`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS previous_contract_id;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Re-add the dropped columns (data is not restored β€” this is a one-way cutover). + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) DEFAULT 'ONE_TIME';`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS previous_contract_id UUID;`, + ); + // The legacy ledger/route tables are intentionally NOT recreated here; restore + // from a backup if a rollback past the cutover is ever required. + } +} diff --git a/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts new file mode 100644 index 000000000..cf0cc51f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Global Logistics Phase-2 operational features (docs/new-doc.md Β§11–§13, gap + * matrix #14/#16/#17/#18): + * - `clearance_milestones.metadata` β€” structured payload for RISK_ASSIGNED + * (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial) + * - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at` + * β€” station routing + staff binding (GL US-02) + * - `freight.clearance_incidents` β€” cargo exception/damage reports with photos + * (GL Import US-07) + */ +export class AddGlOperations1825000000000 implements MigrationInterface { + name = 'AddGlOperations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`, + ); + + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`, + ); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.clearance_incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + incident_type VARCHAR(32) NOT NULL, + description TEXT NOT NULL, + photo_file_ids JSONB NOT NULL DEFAULT '[]', + reported_by_user_id UUID, + reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`, + ); + await queryRunner.query( + `ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts new file mode 100644 index 000000000..70b0832f5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-CreateBookingContainerAllocations.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create the freight.booking_container_allocations table β€” container-to-vehicle + * allocation mapping for flexible routing of containers across available vehicles. + */ +export class CreateBookingContainerAllocations1825000000000 implements MigrationInterface { + name = 'CreateBookingContainerAllocations1825000000000'; + + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.booking_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'booking_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { + name: 'container_type', + type: 'text', + isNullable: false, + }, + { + name: 'quantity', + type: 'integer', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['booking_id'], + referencedTableName: 'freight.bookings', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.booking_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_booking_id" ON "freight"."booking_container_allocations" ("booking_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_booking_container_allocations_vehicle_id" ON "freight"."booking_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.booking_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.booking_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts b/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts new file mode 100644 index 000000000..97428c11b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1826000000000-AddCargoScopeQuantityCap.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * GENERAL contracts can be booked repeatedly until a total cargo quantity cap is + * reached (e.g. 100 containers across many shipments). `quantity_cap` on each + * cargo-scope line holds that ceiling (containers per size, or tons/items for + * bulk). NULL = uncapped; always NULL for ONE_TIME (single booking). + */ +export class AddCargoScopeQuantityCap1826000000000 implements MigrationInterface { + name = 'AddCargoScopeQuantityCap1826000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_cargo_scope ADD COLUMN IF NOT EXISTS quantity_cap NUMERIC(12,2);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_cargo_scope DROP COLUMN IF EXISTS quantity_cap;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts new file mode 100644 index 000000000..e88824e28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1827000000000-CreateBookingRequests.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Customer shipment requests for GENERAL customs (Path B) contracts. The customer + * submits date + quantities; Global Logistics reviews, then creates the booking + * on their behalf and per-booking clearance begins. Additive β€” no change to + * existing tables; ONE_TIME contracts are unaffected. + */ +export class CreateBookingRequests1827000000000 implements MigrationInterface { + name = 'CreateBookingRequests1827000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_requests', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'reference', type: 'varchar', length: '40', default: "''" }, + { name: 'contract_id', type: 'uuid' }, + { name: 'requested_by_user_id', type: 'uuid', isNullable: true }, + { name: 'contract_route_id', type: 'uuid', isNullable: true }, + { name: 'scheduled_date', type: 'timestamptz', isNullable: true }, + { name: 'status', type: 'varchar', length: '16', default: "'PENDING'" }, + { name: 'requested_lines', type: 'jsonb', default: "'{}'::jsonb" }, + { name: 'notes', type: 'text', isNullable: true }, + { name: 'created_booking_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true }, + { name: 'reviewed_at', type: 'timestamptz', isNullable: true }, + { name: 'review_note', type: 'text', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['contract_id'], + referencedSchema: 'freight', + referencedTableName: 'contracts', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + { + columnNames: ['created_booking_id'], + referencedSchema: 'freight', + referencedTableName: 'bookings', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ name: 'idx_booking_requests_contract', columnNames: ['contract_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ name: 'idx_booking_requests_status', columnNames: ['status'] }), + ); + await queryRunner.createIndex( + 'freight.booking_requests', + new TableIndex({ + name: 'idx_booking_requests_contract_status', + columnNames: ['contract_id', 'status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_requests', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts new file mode 100644 index 000000000..cc9437eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddBulkHazmatReeferQuantity.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Bulk / break-bulk freight can now declare HOW MUCH of the cargo is hazardous + * or refrigerated, in the cargo's own unit of measure (tons for PER_TON, item + * count for PER_ITEM). These two columns hold that amount on the booking; they + * stay 0 for container freight (which tracks it per line on booking_container) + * and for bulk cargo with no hazardous/reefer portion. The existing + * is_hazardous / is_reefer booleans remain the surcharge trigger. + */ +export class AddBulkHazmatReeferQuantity1828000000000 implements MigrationInterface { + name = 'AddBulkHazmatReeferQuantity1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_hazardous_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS bulk_reefer_quantity NUMERIC(12,3) NOT NULL DEFAULT 0;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_reefer_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS bulk_hazardous_quantity;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts new file mode 100644 index 000000000..c57a43aaa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-AddGrnNumberToWarehouseInventory.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddGrnNumberToWarehouseInventory1828000000000 implements MigrationInterface { + name = 'AddGrnNumberToWarehouseInventory1828000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + ADD COLUMN IF NOT EXISTS grn_number VARCHAR(100) NULL + `); + + await queryRunner.query(` + UPDATE freight.warehouse_inventory + SET grn_number = substring(notes FROM 'GRN Number: ([^\\n\\r]+)') + WHERE grn_number IS NULL + AND notes IS NOT NULL + AND notes ~ 'GRN Number: ' + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_grn_number + ON freight.warehouse_inventory(grn_number) + WHERE grn_number IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_warehouse_inventory_grn_number`); + await queryRunner.query(` + ALTER TABLE freight.warehouse_inventory + DROP COLUMN IF EXISTS grn_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts new file mode 100644 index 000000000..55239c13f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1828000000000-ExtendInvoicesForPartialPayment.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Extend `freight.invoices` into the billing record of record for every source + * (booking, demurrage, warehouse fees, …) so warehouse fee invoices can be + * centralized onto it instead of the parallel `warehouse_fee_invoices` table. + * + * Adds money tracking that supports partial payment (`subtotal/tax/paid/balance`), + * a `paid_at` stamp, a `payments` jsonb ledger, and the `ISSUED` / `PARTIALLY_PAID` + * statuses the warehouse flow uses. + * + * Matches billing/entities/invoice.entity.ts. All columns are additive with + * defaults, so existing booking/demurrage rows are unaffected. + */ +export class ExtendInvoicesForPartialPayment1828000000000 + implements MigrationInterface +{ + name = "ExtendInvoicesForPartialPayment1828000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // New statuses. ADD VALUE is non-transactional-value-safe on PG 12+ as long + // as the value is not referenced in the same transaction (it is not here). + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'ISSUED' BEFORE 'PENDING';`, + ); + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'PARTIALLY_PAID' BEFORE 'PAID';`, + ); + + await queryRunner.query(` + ALTER TABLE freight.invoices + ADD COLUMN IF NOT EXISTS subtotal_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS tax_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS balance_amount numeric(14, 2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS paid_at timestamptz, + ADD COLUMN IF NOT EXISTS payments jsonb NOT NULL DEFAULT '[]'; + `); + + // Backfill existing rows: subtotal mirrors the total (no tax was modeled), + // the outstanding balance is the full total for unpaid invoices. + await queryRunner.query(` + UPDATE freight.invoices + SET subtotal_amount = total_amount, + balance_amount = total_amount; + `); + + // Already-settled invoices: fully paid, zero balance, stamped from updated_at. + await queryRunner.query(` + UPDATE freight.invoices + SET paid_amount = total_amount, + balance_amount = 0, + paid_at = updated_at + WHERE status = 'PAID'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.invoices + DROP COLUMN IF EXISTS payments, + DROP COLUMN IF EXISTS paid_at, + DROP COLUMN IF EXISTS balance_amount, + DROP COLUMN IF EXISTS paid_amount, + DROP COLUMN IF EXISTS tax_amount, + DROP COLUMN IF EXISTS subtotal_amount; + `); + // Postgres cannot drop individual enum values; ISSUED / PARTIALLY_PAID are + // left on freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts new file mode 100644 index 000000000..75082c5c4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-CentralizeWarehouseInvoices.ts @@ -0,0 +1,247 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Fold warehouse fee invoices into the central billing system. + * + * Warehouse fee invoices are no longer a standalone aggregate: each becomes a + * global `freight.invoices` row (`source = 'warehouse'`, `source_id = + * inventory_id`) with its items as `freight.invoice_lines`. The warehouse + * service is now a thin layer over `BillingService`. This migration backfills the + * existing rows (preserving ids, numbers, status, amounts and payment history), + * then drops the two legacy tables. + * + * Rows that cannot be billed centrally β€” no company to bill (`company_id` / + * `company_profile_id` underivable from the customer or the booking) β€” are not + * migrated; they could never have been charged through the gateway and are + * dropped with the table. + */ +export class CentralizeWarehouseInvoices1829000000000 implements MigrationInterface { + name = 'CentralizeWarehouseInvoices1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'booking_id' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN booking_id DROP NOT NULL; + END IF; + + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'invoices' + AND column_name = 'amount' + ) THEN + ALTER TABLE freight.invoices ALTER COLUMN amount DROP NOT NULL; + END IF; + END $$; + `); + + // 1. Invoice headers. Keep the same id so items still link, and so any + // external reference to the invoice id stays valid. + await queryRunner.query(` + INSERT INTO freight.invoices ( + id, invoice_number, company_id, company_profile_id, + subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, status, source, source_id, type, + issued_at, paid_at, payments, payment_id, due_at, + created_at, updated_at, deleted_at + ) + SELECT + fee.id, + fee.invoice_number, + COALESCE(fee.customer_id, b.company_id), + COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ), + fee.subtotal_amount, fee.tax_amount, fee.total_amount, fee.paid_amount, fee.balance_amount, + fee.currency, + fee.status::freight.invoices_status_enum, + 'warehouse', + fee.inventory_id, + fee.invoice_type, + fee.issued_at, + fee.paid_at, + COALESCE(fee.payments, '[]'::jsonb), + NULL, + COALESCE(fee.due_date, fee.issued_at, fee.created_at), + fee.created_at, fee.updated_at, fee.deleted_at + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.bookings b ON b.id = fee.booking_id + WHERE COALESCE(fee.customer_id, b.company_id) IS NOT NULL + AND COALESCE( + b.company_profile_id, + (SELECT cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = COALESCE(fee.customer_id, b.company_id) + AND cp.deleted_at IS NULL + ORDER BY cp.created_at ASC + LIMIT 1) + ) IS NOT NULL + ON CONFLICT (id) DO NOTHING; + `); + + // 2. Invoice lines β€” only for items whose parent invoice migrated. Warehouse + // fee fields (fee_rule_id / chargeable_days / free_days) move into the + // line's jsonb metadata. + await queryRunner.query(` + INSERT INTO freight.invoice_lines ( + id, invoice_id, charge_type, description, quantity, unit_rate, amount, + currency, metadata, created_at, updated_at, deleted_at + ) + SELECT + item.id, + item.invoice_id, + item.fee_type, + item.description, + item.quantity, + item.unit_rate, + item.amount, + item.currency, + jsonb_build_object( + 'feeRuleId', item.fee_rule_id, + 'chargeableDays', item.chargeable_days, + 'freeDays', item.free_days + ), + item.created_at, item.updated_at, item.deleted_at + FROM freight.warehouse_fee_invoice_items item + JOIN freight.invoices i ON i.id = item.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // 3. Drop the legacy tables (items first β€” FK to invoices). + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoice_items;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_fee_invoices;`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Recreate the legacy tables … + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_number varchar(40) NOT NULL, + booking_id uuid, + customer_id uuid, + inventory_id uuid NOT NULL, + facility_id uuid, + warehouse_id uuid, + yard_id uuid, + zone_id uuid, + invoice_type varchar(32) NOT NULL DEFAULT 'MIXED_WAREHOUSE_FEES', + status varchar(20) NOT NULL DEFAULT 'DRAFT', + subtotal_amount numeric(14,2) NOT NULL DEFAULT 0, + tax_amount numeric(14,2) NOT NULL DEFAULT 0, + total_amount numeric(14,2) NOT NULL DEFAULT 0, + paid_amount numeric(14,2) NOT NULL DEFAULT 0, + balance_amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + period_start timestamptz, + period_end timestamptz, + issued_at timestamptz, + due_date timestamptz, + paid_at timestamptz, + cancelled_at timestamptz, + payments jsonb NOT NULL DEFAULT '[]', + notes text, + CONSTRAINT "PK_warehouse_fee_invoices" PRIMARY KEY (id), + CONSTRAINT "UQ_warehouse_fee_invoices_invoice_number" UNIQUE (invoice_number) + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_booking_id" ON freight.warehouse_fee_invoices (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_inventory_id" ON freight.warehouse_fee_invoices (inventory_id);`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoices_status" ON freight.warehouse_fee_invoices (status);`, + ); + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_fee_invoice_items ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + invoice_id uuid NOT NULL, + fee_rule_id uuid, + fee_type varchar(32) NOT NULL, + description varchar(255) NOT NULL, + quantity numeric(12,2) NOT NULL DEFAULT 1, + unit_rate numeric(14,2) NOT NULL DEFAULT 0, + amount numeric(14,2) NOT NULL DEFAULT 0, + currency varchar(8) NOT NULL DEFAULT 'USD', + chargeable_days int, + free_days int, + CONSTRAINT "PK_warehouse_fee_invoice_items" PRIMARY KEY (id), + CONSTRAINT "FK_warehouse_fee_invoice_items_invoice" + FOREIGN KEY (invoice_id) REFERENCES freight.warehouse_fee_invoices (id) ON DELETE CASCADE + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_warehouse_fee_invoice_items_invoice_id" ON freight.warehouse_fee_invoice_items (invoice_id);`, + ); + + // … then copy the warehouse-source invoices back, deriving the typed FKs and + // period from the linked inventory item. + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoices ( + id, created_at, updated_at, deleted_at, invoice_number, + booking_id, customer_id, inventory_id, facility_id, warehouse_id, yard_id, zone_id, + invoice_type, status, subtotal_amount, tax_amount, total_amount, paid_amount, balance_amount, + currency, period_start, period_end, issued_at, due_date, paid_at, cancelled_at, payments, notes + ) + SELECT + i.id, i.created_at, i.updated_at, i.deleted_at, i.invoice_number, + inv.booking_id, i.company_id, i.source_id, w.facility_id, inv.warehouse_id, inv.yard_id, inv.zone_id, + i.type, i.status::text, i.subtotal_amount, i.tax_amount, i.total_amount, i.paid_amount, i.balance_amount, + i.currency, inv.arrived_at, i.issued_at, i.issued_at, i.due_at, i.paid_at, + CASE WHEN i.status::text = 'CANCELLED' THEN i.updated_at ELSE NULL END, + i.payments, NULL + FROM freight.invoices i + LEFT JOIN freight.warehouse_inventory inv ON inv.id = i.source_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + WHERE i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + await queryRunner.query(` + INSERT INTO freight.warehouse_fee_invoice_items ( + id, created_at, updated_at, deleted_at, invoice_id, fee_rule_id, fee_type, + description, quantity, unit_rate, amount, currency, chargeable_days, free_days + ) + SELECT + l.id, l.created_at, l.updated_at, l.deleted_at, l.invoice_id, + NULLIF(l.metadata->>'feeRuleId', '')::uuid, + l.charge_type, + COALESCE(l.description, ''), + l.quantity, l.unit_rate, l.amount, l.currency, + NULLIF(l.metadata->>'chargeableDays', '')::int, + NULLIF(l.metadata->>'freeDays', '')::int + FROM freight.invoice_lines l + JOIN freight.invoices i ON i.id = l.invoice_id AND i.source = 'warehouse' + ON CONFLICT (id) DO NOTHING; + `); + + // Remove the migrated rows from the central tables. + await queryRunner.query(` + DELETE FROM freight.invoice_lines + WHERE invoice_id IN (SELECT id FROM freight.invoices WHERE source = 'warehouse'); + `); + await queryRunner.query(`DELETE FROM freight.invoices WHERE source = 'warehouse';`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts new file mode 100644 index 000000000..75288e94c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000000-PhasedClearanceCycleMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhasedClearanceCycleMeta1829000000000 implements MigrationInterface { + name = 'PhasedClearanceCycleMeta1829000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS current_phase VARCHAR(40);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS current_phase;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts new file mode 100644 index 000000000..7165ea7a8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000001-SeedRoVesselMinDays.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** Admin-configurable minimum days between today and export RO vessel departure. */ +export class SeedRoVesselMinDays1829000000001 implements MigrationInterface { + name = 'SeedRoVesselMinDays1829000000001'; + private readonly code = 'ro_vessel_min_days'; + private readonly options: Array<{ value: string; label: string }> = [ + { value: '2', label: '2 days' }, + { value: '3', label: '3 days' }, + ]; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'RO vessel minimum lead time (days)', + 'Minimum days between today and the vessel departure date on an export Release Order.', + ], + ); + const settingId = inserted[0].id; + + for (let i = 0; i < this.options.length; i++) { + const opt = this.options[i]; + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, $4);`, + [settingId, opt.value, opt.label, i], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM freight.dropdown_settings WHERE code = $1;`, [ + this.code, + ]); + } +} diff --git a/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts new file mode 100644 index 000000000..c108b7baa --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1829000000002-BookingClearanceMeta.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class BookingClearanceMeta1829000000002 implements MigrationInterface { + name = 'BookingClearanceMeta1829000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_current_phase VARCHAR(40);`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS duty_required BOOLEAN;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS vessel_departure_date DATE;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_amendment_requested_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS ro_hold_reason TEXT;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_current_phase;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS duty_required;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS vessel_departure_date;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_amendment_requested_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS ro_hold_reason;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts new file mode 100644 index 000000000..e4b353b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-AddExpiredInvoiceStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add the `EXPIRED` invoice status. An invoice expires when its source's pay + * window closes before settlement (e.g. a booking whose `paymentDeadline` + * lapses) β€” driven event-style from the domain via `BillingService.expirePayable`, + * which emits `${source}.invoice.expired`. Terminal and not settle-able (kept out + * of `OPEN_STATUSES`), so it is distinct from `CANCELLED` (manual void) and + * `OVERDUE` (still payable). + * + * Matches Freight.InvoiceStatus in packages/types. ADD VALUE only β€” additive and + * not referenced in this same transaction, so it is PG 12+ safe. + */ +export class AddExpiredInvoiceStatus1830000000000 implements MigrationInterface { + name = "AddExpiredInvoiceStatus1830000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE freight.invoices_status_enum ADD VALUE IF NOT EXISTS 'EXPIRED' AFTER 'REFUNDED';`, + ); + } + + public async down(): Promise { + // Postgres cannot drop individual enum values; EXPIRED is left on + // freight.invoices_status_enum (harmless, unused after down). + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts new file mode 100644 index 000000000..b91e88633 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-CreateFirstMileContainerAllocations.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm'; + +/** + * Create freight.first_mile_container_allocations table β€” tracks + * container allocations per first-mile shipment with optional vehicle assignment. + */ +export class CreateFirstMileContainerAllocations1830000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) return; + + await queryRunner.createTable( + new Table({ + name: 'freight.first_mile_container_allocations', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'gen_random_uuid()', + }, + { name: 'first_mile_id', type: 'uuid', isNullable: false }, + { name: 'container_id', type: 'uuid', isNullable: false }, + { name: 'vehicle_id', type: 'uuid', isNullable: true }, + { name: 'container_type', type: 'text', isNullable: false }, + { + name: 'quantity', + type: 'int', + default: 1, + isNullable: false, + }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['first_mile_id'], + referencedTableName: 'freight.first_mile', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'freight.first_mile_container_allocations', + new TableForeignKey({ + columnNames: ['vehicle_id'], + referencedTableName: 'freight.vehicles', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_first_mile_id" ON "freight"."first_mile_container_allocations" ("first_mile_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_first_mile_container_allocations_vehicle_id" ON "freight"."first_mile_container_allocations" ("vehicle_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.hasTable('freight.first_mile_container_allocations'); + if (exists) { + await queryRunner.dropTable('freight.first_mile_container_allocations'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts new file mode 100644 index 000000000..5bdfe1f30 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000000-DropCargoTypeShowFreeTextBox.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class DropCargoTypeShowFreeTextBox1830000000000 implements MigrationInterface { + name = 'DropCargoTypeShowFreeTextBox1830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + DROP COLUMN IF EXISTS show_free_text_box + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.cargo_types + ADD COLUMN IF NOT EXISTS show_free_text_box boolean NOT NULL DEFAULT false + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts new file mode 100644 index 000000000..701b52c18 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000001-RouteStatusAndSegmentKm.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RouteStatusAndSegmentKm1830000000001 implements MigrationInterface { + name = 'RouteStatusAndSegmentKm1830000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS status varchar(32) NOT NULL DEFAULT 'AVAILABLE' + `); + + await queryRunner.query(` + UPDATE freight.routes + SET status = CASE WHEN is_active = true THEN 'AVAILABLE' ELSE 'STOP_WORKING' END + `); + + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_name" + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS name + `); + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS is_active + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_routes_status" ON freight.routes (status) + `); + + await queryRunner.query(` + ALTER TABLE freight.route_milestones + ADD COLUMN IF NOT EXISTS distance_km numeric(10,2) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.route_milestones DROP COLUMN IF EXISTS distance_km + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS name varchar(120) + `); + await queryRunner.query(` + UPDATE freight.routes SET name = id::text WHERE name IS NULL + `); + await queryRunner.query(` + ALTER TABLE freight.routes ALTER COLUMN name SET NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE freight.routes + ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + UPDATE freight.routes + SET is_active = CASE WHEN status = 'AVAILABLE' THEN true ELSE false END + `); + + await queryRunner.query(` + ALTER TABLE freight.routes DROP COLUMN IF EXISTS status + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight."IDX_routes_status" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_routes_name" ON freight.routes (name) + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts new file mode 100644 index 000000000..df57cd828 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1830000000002-PreClearanceFinalizedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PreClearanceFinalizedAt1830000000002 implements MigrationInterface { + name = 'PreClearanceFinalizedAt1830000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS pre_clearance_finalized_at TIMESTAMPTZ;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_clearance_cycles DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_clearance_finalized_at;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts new file mode 100644 index 000000000..c6da11cc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1831000000000-AddWarehouseFeeRuleTiers.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface { + name = 'AddWarehouseFeeRuleTiers1831000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]'; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_fee_rules + DROP COLUMN IF EXISTS tiers; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts new file mode 100644 index 000000000..c137ca264 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1832000000000-AddCustomerTruckAssignmentToBookings.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface { + name = 'AddCustomerTruckAssignmentToBookings1832000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32), + ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120), + ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60), + ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16), + ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz, + ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS customer_truck_arrived_at, + DROP COLUMN IF EXISTS customer_truck_assigned_at, + DROP COLUMN IF EXISTS customer_truck_container_number, + DROP COLUMN IF EXISTS customer_truck_type, + DROP COLUMN IF EXISTS customer_truck_driver_name, + DROP COLUMN IF EXISTS customer_truck_plate_number + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts new file mode 100644 index 000000000..261e16099 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1860000000000-AddPaidToFirstAndLastMile.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add paid column to first_mile and last_mile tables to track invoice payment status. + */ +export class AddPaidToFirstAndLastMile1860000000000 + implements MigrationInterface +{ + name = "AddPaidToFirstAndLastMile1860000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + ADD COLUMN IF NOT EXISTS paid boolean NOT NULL DEFAULT false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.first_mile + DROP COLUMN IF EXISTS paid; + `); + + await queryRunner.query(` + ALTER TABLE freight.last_mile + DROP COLUMN IF EXISTS paid; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train β€” release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix β€” not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts new file mode 100644 index 000000000..3434631a2 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1870000000000-AddLocationToVehicles.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add location_id column to vehicles table to track vehicle base location. + */ +export class AddLocationToVehicles1870000000000 implements MigrationInterface { + name = "AddLocationToVehicles1870000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS location_id uuid; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS location_id; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts new file mode 100644 index 000000000..484a2686b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1880000000000-AddVehicleStatuses.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add FREE and BUSY statuses to vehicle status enum. + */ +export class AddVehicleStatuses1880000000000 implements MigrationInterface { + name = "AddVehicleStatuses1880000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // Create enum type if it doesn't exist + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vehicles_status_enum' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'freight')) THEN + CREATE TYPE freight.vehicles_status_enum AS ENUM ('ACTIVE', 'FREE', 'BUSY', 'MAINTENANCE', 'RETIRED', 'OUT_OF_SERVICE'); + ELSE + -- Add values if enum already exists but doesn't have them + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'FREE' BEFORE 'MAINTENANCE'; + ALTER TYPE freight.vehicles_status_enum ADD VALUE IF NOT EXISTS 'BUSY' AFTER 'FREE'; + END IF; + END $$; + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Note: Postgres cannot drop individual enum values, so the down migration is a no-op + // The enum values FREE and BUSY will remain but will be unused after downgrade + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts new file mode 100644 index 000000000..c0015c3c6 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000000-SeparateVehicleAvailability.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Split the mixed vehicle status into two fields: + * - status: operational state (ACTIVE, MAINTENANCE, RETIRED, OUT_OF_SERVICE) + * - availability: assignment state (FREE, BUSY) + * + * Existing FREE/BUSY statuses are moved to availability and the status is + * normalized back to ACTIVE. + */ +export class SeparateVehicleAvailability1890000000000 implements MigrationInterface { + name = "SeparateVehicleAvailability1890000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS availability varchar DEFAULT 'FREE' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'BUSY' WHERE status = 'BUSY' + `); + await queryRunner.query(` + UPDATE freight.vehicles SET availability = 'FREE' WHERE availability IS NULL + `); + await queryRunner.query(` + UPDATE freight.vehicles SET status = 'ACTIVE' WHERE status IN ('FREE', 'BUSY') + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Fold availability back into status before dropping the column + await queryRunner.query(` + UPDATE freight.vehicles SET status = availability + WHERE status = 'ACTIVE' AND availability IN ('FREE', 'BUSY') + `); + await queryRunner.query(` + ALTER TABLE freight.vehicles DROP COLUMN IF EXISTS availability + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts new file mode 100644 index 000000000..6f9faa1f8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1890000000001-AddVehicleCodeAndPlates.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Add code, power_plate_no and trailer_plate_no columns to vehicles. + * These fields existed in the DTO and UI form but had no entity columns, + * so submitted values were silently dropped. + */ +export class AddVehicleCodeAndPlates1890000000001 implements MigrationInterface { + name = "AddVehicleCodeAndPlates1890000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS code varchar, + ADD COLUMN IF NOT EXISTS power_plate_no varchar, + ADD COLUMN IF NOT EXISTS trailer_plate_no varchar + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS code, + DROP COLUMN IF EXISTS power_plate_no, + DROP COLUMN IF EXISTS trailer_plate_no + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..7da5e2d66 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @@ -7,8 +8,9 @@ import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") @FreightAdmin() +@ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor(private readonly billingService: BillingService) { } @Get("invoices") @ApiOperation({ summary: "List all invoices" }) @@ -16,9 +18,31 @@ export class BillingController { return this.billingService.findAll(); } - @Get("invoices/booking/:bookingId") - @ApiOperation({ summary: "List invoices for a booking" }) - findByBooking(@Param("bookingId", ParseUUIDPipe) bookingId: string) { - return this.billingService.findByBooking(bookingId); + @Get("invoices/:id") + @ApiOperation({ summary: "Get an invoice with its line items" }) + findById(@Param("id", ParseUUIDPipe) id: string) { + return this.billingService.findById(id); + } + + @Get("invoices/:id/document") + @ApiOperation({ summary: "Download the sealed invoice PDF" }) + async document(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.document(id); + sendPdf(res, filename, buffer); + } + + @Get("invoices/:id/receipt") + @ApiOperation({ summary: "Download the sealed payment receipt PDF" }) + async receipt(@Param("id", ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.billingService.receipt(id); + sendPdf(res, filename, buffer); } } + +/** Stream a generated PDF as a file download. */ +export function sendPdf(res: Response, filename: string, buffer: Buffer): void { + res.setHeader("Content-Type", "application/pdf"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + res.setHeader("Content-Length", buffer.length); + res.send(buffer); +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index 2b16b1515..771156fd3 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -1,14 +1,27 @@ -import { Module } from "@nestjs/common"; +import { forwardRef, Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { BillingController } from "./billing.controller"; +import { PortalBillingController } from "./portal-billing.controller"; +import { PaymentController } from "./payment.controller"; import { BillingService } from "./billing.service"; +import { DocumentsModule } from "./documents/documents.module"; import { Invoice } from "./entities/invoice.entity"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { InvoiceRepository } from "./invoice.repository"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { PaymentModule } from "../payment/payment.module"; +import { CompaniesModule } from "../companies/companies.module"; @Module({ - imports: [TypeOrmModule.forFeature([Invoice])], - controllers: [BillingController], - providers: [BillingService], + imports: [ + TypeOrmModule.forFeature([Invoice, InvoiceLine]), + forwardRef(() => PaymentModule), + CompaniesModule, + DocumentsModule, + ], + controllers: [BillingController, PortalBillingController, PaymentController], + providers: [BillingService, InvoiceRepository, InvoiceLineRepository], exports: [BillingService], }) export class BillingModule {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts new file mode 100644 index 000000000..4df2a0eb3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,298 @@ +import { Freight } from "@edr/types"; + +import { BillingService } from "./billing.service"; + +/** + * Minimal in-memory EntityManager stand-in covering the methods + * `generateInvoice` / `markInvoiceAsPaid` call on the transaction manager. + */ +function makeManager(savedLines: unknown[]) { + return { + create: (_entity: unknown, data: Record) => data, + save: (data: Record) => { + const row = { id: data.id ?? "gen-1", ...data }; + if (data.invoiceId) savedLines.push(row); + return Promise.resolve(row); + }, + query: () => Promise.resolve([{ seq: 0 }]), + update: jest.fn().mockResolvedValue(undefined), + findOne: jest.fn().mockResolvedValue(null), + }; +} + +function makeEvents() { + return { emit: jest.fn() }; +} + +function generateInput(overrides: Record = {}) { + return { + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + type: "prepaid", + companyId: "company-1", + companyProfileId: "profile-1", + currency: "ETB", + lines: [ + { + chargeType: "RAIL_FREIGHT", + description: "Rail freight", + quantity: 2, + unitRate: 500, + amount: 1000, + }, + { + chargeType: "HAZARD_SURCHARGE", + description: "Hazard surcharge", + quantity: 2, + unitRate: 250, + amount: 500, + }, + ], + ...overrides, + }; +} + +describe("BillingService.generateInvoice", () => { + let savedLines: unknown[]; + let manager: ReturnType; + let events: ReturnType; + let dataSource: { transaction: jest.Mock; manager: unknown }; + let service: BillingService; + + beforeEach(() => { + savedLines = []; + manager = makeManager(savedLines); + events = makeEvents(); + dataSource = { + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + manager, + }; + service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + }); + + it("creates a PENDING invoice with one line per input line", async () => { + const invoice = await service.generateInvoice(generateInput()); + + expect(invoice.status).toBe(Freight.InvoiceStatus.Pending); + expect(invoice.companyId).toBe("company-1"); + expect(invoice.source).toBe("booking"); + expect(invoice.sourceId).toBe("booking-1"); + expect(invoice.totalAmount).toBe(1500); + expect(invoice.issuedAt).toBeInstanceOf(Date); + expect(invoice.invoiceNumber).toMatch(/^INV-\d{8}-00001$/); + expect(savedLines).toHaveLength(2); + }); + + it("sums line amounts when no explicit totalAmount is given", async () => { + const invoice = await service.generateInvoice( + generateInput({ totalAmount: undefined }), + ); + expect(invoice.totalAmount).toBe(1500); + }); + + it("leaves issuedAt null for a DRAFT invoice", async () => { + const invoice = await service.generateInvoice( + generateInput({ status: Freight.InvoiceStatus.Draft }), + ); + expect(invoice.status).toBe(Freight.InvoiceStatus.Draft); + expect(invoice.issuedAt).toBeNull(); + }); + + it("enlists in a caller's transaction when a manager is passed", async () => { + await service.generateInvoice(generateInput(), manager as never); + expect(dataSource.transaction).not.toHaveBeenCalled(); + expect(savedLines).toHaveLength(2); + }); +}); + +describe("BillingService.markInvoiceAsPaid", () => { + it("marks the invoice PAID, stamps amounts/paidAt, links the payment, and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + totalAmount: 1500, + paidAt: null, + }; + const mg = { + findOne: jest.fn().mockResolvedValue(open), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + paidAt: expect.any(Date), + paidAmount: 1500, + balanceAmount: 0, + payments: [ + { + amount: 1500, + method: "GATEWAY", + reference: "pay-1", + paidAt: expect.any(String), + metadata: null, + }, + ], + }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.objectContaining({ + invoiceId: "inv-1", + status: Freight.InvoiceStatus.Paid, + paymentId: "pay-1", + }), + ); + }); + + it("is a no-op (no event) when the invoice is already paid", async () => { + const paid = { + id: "inv-1", + status: Freight.InvoiceStatus.Paid, + source: "booking", + }; + const mg = { + findOne: jest.fn().mockResolvedValue(paid), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const service = new BillingService( + { manager: mg } as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + +describe("BillingService.recordPayment", () => { + function serviceFor(invoice: Record | null) { + const mg = { + findOne: jest.fn().mockResolvedValue(invoice), + update: jest.fn().mockResolvedValue(undefined), + }; + const events = makeEvents(); + const dataSource = { + manager: mg, + transaction: jest + .fn() + .mockImplementation((cb: (mg: unknown) => unknown) => cb(mg)), + }; + const service = new BillingService( + dataSource as never, + {} as never, + {} as never, + events as never, + {} as never, // payment + {} as never, // companies + {} as never, // invoiceDocuments + ); + return { service, mg, events }; + } + + const openInvoice = (overrides: Record = {}) => ({ + id: "inv-1", + status: Freight.InvoiceStatus.Issued, + source: "warehouse", + sourceId: "inv-item-1", + totalAmount: 1000, + paidAmount: 0, + balanceAmount: 1000, + payments: [], + paidAt: null, + ...overrides, + }); + + it("moves to PARTIALLY_PAID and emits no event on a partial payment", async () => { + const { service, mg, events } = serviceFor(openInvoice()); + + const updated = await service.recordPayment("inv-1", { amount: 400, method: "CASH" }); + + expect(updated.status).toBe(Freight.InvoiceStatus.PartiallyPaid); + expect(updated.paidAmount).toBe(400); + expect(updated.balanceAmount).toBe(600); + expect(updated.payments).toHaveLength(1); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + expect.objectContaining({ + status: Freight.InvoiceStatus.PartiallyPaid, + paidAmount: 400, + balanceAmount: 600, + }), + ); + expect(events.emit).not.toHaveBeenCalled(); + }); + + it("settles to PAID, stamps paidAt, and emits ${source}.invoice.paid when the balance clears", async () => { + const { service, mg, events } = serviceFor(openInvoice({ paidAmount: 400, balanceAmount: 600 })); + + const updated = await service.recordPayment("inv-1", { amount: 600 }); + + expect(updated.status).toBe(Freight.InvoiceStatus.Paid); + expect(updated.balanceAmount).toBe(0); + expect(updated.paidAt).toBeInstanceOf(Date); + expect(mg.update).toHaveBeenCalled(); + expect(events.emit).toHaveBeenCalledWith( + "warehouse.invoice.paid", + expect.objectContaining({ invoiceId: "inv-1", status: Freight.InvoiceStatus.Paid }), + ); + }); + + it("rejects a non-positive amount", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect(service.recordPayment("inv-1", { amount: 0 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects a payment that exceeds the outstanding balance", async () => { + const { service, mg } = serviceFor(openInvoice()); + await expect( + service.recordPayment("inv-1", { amount: 1500 }), + ).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); + + it("rejects payment against a cancelled invoice", async () => { + const { service, mg } = serviceFor( + openInvoice({ status: Freight.InvoiceStatus.Cancelled }), + ); + await expect(service.recordPayment("inv-1", { amount: 100 })).rejects.toThrow(); + expect(mg.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 39eae6ef5..82a47fa29 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -1,26 +1,943 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { Freight, PaymentReferenceType } from "@edr/types"; +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { DataSource, EntityManager, In } from "typeorm"; -import { Invoice } from "./entities/invoice.entity"; +import { CompaniesService } from "../companies/companies.service"; +import { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; +import { + InvoiceDocumentModel, + InvoiceDocumentService, +} from "./documents/invoice-document.service"; +import { InvoiceLine } from "./entities/invoice-line.entity"; +import { Invoice, InvoicePayment } from "./entities/invoice.entity"; +import { InvoiceLineRepository } from "./invoice-line.repository"; +import { nextDailyInvoiceNumber } from "./invoice-numbering.util"; +import { applySettlement, round2 } from "./invoice-settlement.util"; +import { InvoiceRepository } from "./invoice.repository"; + +/** Options forwarded to the payment gateway when settling an invoice. */ +export interface PayInvoiceOptions { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +/** A single manual/offline settlement to record against an invoice. */ +export interface RecordPaymentInput { + /** Amount settled by this payment; must be > 0. */ + amount: number; + method?: string | null; + reference?: string | null; + /** When the settlement occurred; defaults to now. */ + paidAt?: Date; + metadata?: Record | null; +} + +/** Default invoice payment-term window, in days, used to compute `dueAt`. */ +const DEFAULT_DUE_DAYS = 14; + +/** Statuses an invoice can still be settled (paid/refunded/cancelled) from. */ +const OPEN_STATUSES: Freight.InvoiceStatus[] = [ + Freight.InvoiceStatus.Issued, + Freight.InvoiceStatus.Pending, + Freight.InvoiceStatus.PartiallyPaid, + Freight.InvoiceStatus.Overdue, +]; + +/** A single line to bill on a generated invoice. */ +export interface InvoiceLineInput { + chargeType: string; + description?: string; + /** Units this line bills for; defaults to 1. */ + quantity?: number; + /** Price per unit; defaults to 0. */ + unitRate?: number; + /** Line total; defaults to `quantity * unitRate`. */ + amount?: number; + currency?: string; + metadata?: Record | null; +} + +/** Everything needed to generate an invoice for any source. */ +export interface GenerateInvoiceInput { + /** Originating subsystem; namespaces events (`${source}.invoice.`). */ + source: Freight.InvoiceSource; + /** Identifier of the source record (e.g. booking id). */ + sourceId: string; + /** What the invoice is for (e.g. "prepaid", "credit"). */ + type: string; + companyId: string; + companyProfileId: string; + lines: InvoiceLineInput[]; + currency?: string; + /** Explicit pre-tax subtotal; defaults to the sum of line amounts. */ + subtotalAmount?: number; + /** Tax applied on top of the subtotal; defaults to 0. */ + taxAmount?: number; + /** Explicit total; defaults to `subtotalAmount + taxAmount`. */ + totalAmount?: number; + /** Issue date window; defaults to `DEFAULT_DUE_DAYS` from now. */ + dueAt?: Date; + dueInDays?: number; + /** + * Initial status. DRAFT leaves `issuedAt` null; any issued status + * (default PENDING) stamps `issuedAt`. + */ + status?: Freight.InvoiceStatus; +} + +/** Payload broadcast on `${source}.invoice.`. */ +export interface InvoiceEventPayload { + invoiceId: string; + invoiceNumber: string; + source: Freight.InvoiceSource; + sourceId: string; + type: string; + companyId: string; + companyProfileId: string; + totalAmount: number; + currency: string; + status: Freight.InvoiceStatus; + paymentId?: string | null; +} @Injectable() export class BillingService { + private readonly logger = new Logger(BillingService.name); + constructor( - @InjectRepository(Invoice) - private readonly invoicesRepository: Repository, - ) {} + private readonly dataSource: DataSource, + private readonly invoices: InvoiceRepository, + private readonly invoiceLines: InvoiceLineRepository, + private readonly events: EventEmitter2, + @Inject(forwardRef(() => PaymentService)) + private readonly payment: PaymentService, + private readonly companies: CompaniesService, + private readonly invoiceDocuments: InvoiceDocumentService, + ) { } + + // ── Reads ────────────────────────────────────────────────────────────────── /** List every invoice (most recent first). */ findAll(): Promise { - return this.invoicesRepository.find({ order: { issuedAt: "DESC" } }); + return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } - /** List invoices for a given booking. */ - findByBooking(bookingId: string): Promise { - return this.invoicesRepository.find({ - where: { bookingId }, + /** Invoice header plus its line items. */ + async findById(id: string): Promise { + const invoice = await this.invoices.findById(id); + if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const lines = await this.invoiceLines.findAll({ + where: { invoiceId: id }, + order: { createdAt: "ASC" }, + }); + return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + } + + // ── Documents (central PDF) ────────────────────────────────────────────────── + + /** Sealed PDF invoice for any source, rendered by the shared document service. */ + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "INVOICE"), + ); + } + + /** Sealed PDF receipt; available once any payment has been recorded. */ + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException( + "A receipt is available only after payment is recorded.", + ); + } + return this.invoiceDocuments.render( + this.toDocumentModel(invoice, "RECEIPT"), + ); + } + + /** Map a global invoice (+ lines) onto the source-agnostic document model. */ + private toDocumentModel( + invoice: Invoice & { lines: InvoiceLine[] }, + kind: "INVOICE" | "RECEIPT", + ): InvoiceDocumentModel { + const title = invoice.source + ? invoice.source.charAt(0).toUpperCase() + invoice.source.slice(1) + : "EDR"; + const totals: InvoiceDocumentModel["totals"] = [ + { label: "Subtotal", amount: Number(invoice.subtotalAmount) }, + ]; + if (Number(invoice.taxAmount) > 0) { + totals.push({ label: "Tax", amount: Number(invoice.taxAmount) }); + } + totals.push({ + label: "Total", + amount: Number(invoice.totalAmount), + grand: true, + }); + totals.push({ label: "Paid", amount: Number(invoice.paidAmount) }); + totals.push({ label: "Balance", amount: Number(invoice.balanceAmount) }); + + return { + kind, + title, + documentNumber: invoice.invoiceNumber, + issuedAt: invoice.issuedAt ?? invoice.createdAt, + status: invoice.status, + currency: invoice.currency, + summary: [ + { label: "Status", value: invoice.status }, + { label: "Type", value: invoice.type }, + { label: "Reference", value: invoice.sourceId }, + { label: "Currency", value: invoice.currency }, + { + label: "Issued", + value: invoice.issuedAt + ? new Date(invoice.issuedAt).toLocaleDateString("en-GB") + : null, + }, + { + label: "Due", + value: invoice.dueAt + ? new Date(invoice.dueAt).toLocaleDateString("en-GB") + : null, + }, + ], + categoryHeader: "Charge type", + lines: invoice.lines.map((l) => ({ + description: l.description ?? l.chargeType, + category: l.chargeType, + quantity: l.quantity, + unitRate: l.unitRate, + amount: l.amount, + currency: l.currency, + })), + totals, + }; + } + + // ── Customer-scoped reads (portal) ─────────────────────────────────────────── + + /** Resolve the customer's company id from their IAM user id (null if none). */ + async resolveCompanyId(userId: string): Promise { + try { + const { company } = await this.companies.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** + * Every invoice billed to a company, newest first, with billing relations. + * Optionally narrow to a single source record (e.g. a booking's invoices) via + * `{ source, sourceId }`. + */ + findByCompany( + companyId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { + return this.invoices.findAll({ + where: { + companyId, + ...(filter.source ? { source: filter.source } : {}), + ...(filter.sourceId ? { sourceId: filter.sourceId } : {}), + }, + relations: { company: true, companyProfile: true }, + order: { createdAt: "DESC" }, + }); + } + + /** Invoices for the signed-in customer; empty when they have no company. */ + async findForUser( + userId: string, + filter: { source?: string; sourceId?: string } = {}, + ): Promise { + const companyId = await this.resolveCompanyId(userId); + return companyId ? this.findByCompany(companyId, filter) : []; + } + + /** Company-scoped invoice detail (+ lines); 404 when not owned by the user. */ + async findByIdForUser( + id: string, + userId: string, + ): Promise { + const companyId = await this.resolveCompanyId(userId); + const invoice = await this.findById(id); + if (!companyId || invoice.companyId !== companyId) { + throw new NotFoundException(`Invoice ${id} not found`); + } + return invoice; + } + + /** + * Initiate gateway payment for one of the customer's own invoices. Verifies + * ownership, then charges the invoice directly by ID (see {@link payInvoice}). + */ + async payInvoiceForUser( + id: string, + userId: string, + opts: PayInvoiceOptions = {}, + ): Promise { + await this.findByIdForUser(id, userId); + return this.payInvoice(id, opts); + } + + /** Sealed invoice PDF for one of the customer's own invoices (ownership-checked). */ + async documentForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.document(id); + } + + /** Sealed receipt PDF for one of the customer's own invoices (ownership-checked). */ + async receiptForUser( + id: string, + userId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + await this.findByIdForUser(id, userId); + return this.receipt(id); + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** `-YYYYMMDD-00001` β€” sequential per day & prefix, within the active transaction. */ + private nextInvoiceNumber(mg: EntityManager): Promise { + return nextDailyInvoiceNumber(mg, { + table: "freight.invoices", + code: "INV", + }); + } + + /** + * Generate an invoice for any source (booking, demurrage, manual, …). + * + * Persists the header plus its lines in one transaction and assigns the next + * sequential `invoice_number`. The total defaults to the sum of line amounts + * unless `totalAmount` is given. Issued invoices (default PENDING) stamp + * `issuedAt`; pass `status: DRAFT` to leave it unissued. + * + * Pass `manager` to enlist in a caller's transaction (e.g. when generating an + * invoice as part of a larger booking flow). + */ + async generateInvoice( + input: GenerateInvoiceInput, + manager?: EntityManager, + ): Promise { + console.log("oooooooooo", input); + const run = (mg: EntityManager) => this.createInvoice(input, mg); + return manager ? run(manager) : this.dataSource.transaction(run); + } + + private async createInvoice( + input: GenerateInvoiceInput, + mg: EntityManager, + ): Promise { + const currency = input.currency ?? "ETB"; + const status = input.status ?? Freight.InvoiceStatus.Pending; + const issued = status !== Freight.InvoiceStatus.Draft; + + const lines = input.lines.map((l) => { + const quantity = l.quantity ?? 1; + const unitRate = l.unitRate ?? 0; + return { + chargeType: l.chargeType, + description: l.description, + quantity, + unitRate, + amount: l.amount ?? quantity * unitRate, + currency: l.currency ?? currency, + metadata: l.metadata ?? null, + }; + }); + + const subtotalAmount = + input.subtotalAmount ?? + lines.reduce((sum, l) => sum + Number(l.amount), 0); + const taxAmount = input.taxAmount ?? 0; + const totalAmount = input.totalAmount ?? round2(subtotalAmount + taxAmount); + + const dueAt = + input.dueAt ?? + new Date( + Date.now() + + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + ); + + const invoiceNumber = await this.nextInvoiceNumber(mg); + + const invoice = await mg.save( + mg.create(Invoice, { + invoiceNumber, + source: input.source, + sourceId: input.sourceId, + type: input.type, + companyId: input.companyId, + companyProfileId: input.companyProfileId, + subtotalAmount: round2(subtotalAmount), + taxAmount: round2(taxAmount), + totalAmount: round2(totalAmount), + paidAmount: 0, + balanceAmount: round2(totalAmount), + payments: [], + currency, + status, + issuedAt: issued ? new Date() : null, + dueAt, + }), + ); + + const savedLines = await Promise.all( + lines.map((l) => + mg.save(mg.create(InvoiceLine, { ...l, invoiceId: invoice.id })), + ), + ); + + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${input.source}:${input.sourceId}`, + ); + + return { ...invoice, lines: savedLines }; + } + + // ── State transitions ──────────────────────────────────────────────────────── + + /** + * Run `fn` inside a transaction and only emit its returned domain event + * after commit. When the caller passes their own `manager`, they own commit + * timing β€” `fn`'s event fires inline as soon as it resolves (the outer + * transaction may still roll back afterwards; this is the caller's + * documented tradeoff). When no `manager` is given, this opens its own + * transaction and defers the emit until after that transaction commits, so + * listeners (e.g. booking advancement) can never observe an invoice change + * that then rolls back. + */ + private async runTransition( + manager: EntityManager | undefined, + fn: (mg: EntityManager) => Promise<{ result: T; emit?: () => void }>, + ): Promise { + if (manager) { + const { result, emit } = await fn(manager); + emit?.(); + return result; + } + let pending: (() => void) | undefined; + const result = await this.dataSource.transaction(async (mg) => { + const out = await fn(mg); + pending = out.emit; + return out.result; + }); + pending?.(); + return result; + } + + /** + * Mark an invoice paid, stamp the paid timestamp, sync paid/balance amounts, + * append the settlement to the `payments` ledger, link the gateway payment, + * then emit `${source}.invoice.paid`. Full-payment only β€” no partial + * settlement. No-op when the invoice is already paid. Pass `manager` to + * enlist in a caller's transaction; otherwise locks the row for update and + * emits only after commit (see {@link runTransition}). + */ + async markInvoiceAsPaid( + invoiceId: string, + paymentId: string | null = null, + manager?: EntityManager, + settlement: { providerTxnId?: string; paidAt?: Date } = {}, + ): Promise { + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + return { result: invoice }; + } + + const paidAt = invoice.paidAt ?? settlement.paidAt ?? new Date(); + const settledAmount = round2( + Number(invoice.totalAmount) - Number(invoice.paidAmount ?? 0), + ); + const entry: InvoicePayment = { + amount: settledAmount, + method: "GATEWAY", + reference: settlement.providerTxnId ?? paymentId ?? null, + paidAt: paidAt.toISOString(), + metadata: null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + status: Freight.InvoiceStatus.Paid, + paymentId, + paidAt, + paidAmount: invoice.totalAmount, + balanceAmount: 0, + payments, + }; + await mg.update(Invoice, { id: invoiceId }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent("paid", updated), + }; + }); + } + + /** + * Record a (possibly partial) settlement against an invoice and sync its + * status. Appends to the `payments` ledger, recomputes `paidAmount` / + * `balanceAmount`, and moves the invoice to PARTIALLY_PAID or β€” once the + * balance reaches zero β€” PAID, stamping `paidAt` and emitting + * `${source}.invoice.paid`. Use this for manual/offline settlement (e.g. cash + * at the warehouse counter); gateway settlement goes through + * {@link markInvoiceAsPaid}. + * + * Throws when the invoice is missing, cancelled, refunded, already fully + * paid, `amount` is not positive, or `amount` exceeds the outstanding + * balance. Pass `manager` to enlist in a caller's transaction; otherwise + * locks the row for update and emits only after commit (see + * {@link runTransition}). + */ + async recordPayment( + invoiceId: string, + input: RecordPaymentInput, + manager?: EntityManager, + ): Promise { + if (!(input.amount > 0)) { + throw new BadRequestException( + "Payment amount must be greater than zero.", + ); + } + + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === Freight.InvoiceStatus.Cancelled) { + throw new BadRequestException("Cannot pay a cancelled invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Refunded) { + throw new BadRequestException("Cannot pay a refunded invoice."); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException("Invoice is already fully paid."); + } + if (round2(input.amount) > Number(invoice.balanceAmount)) { + throw new BadRequestException( + `Payment of ${round2(input.amount)} exceeds the outstanding balance of ${Number(invoice.balanceAmount)}.`, + ); + } + + const at = input.paidAt ?? new Date(); + const { paidAmount, balanceAmount, fullyPaid } = applySettlement( + invoice.totalAmount, + invoice.paidAmount, + input.amount, + ); + const status = fullyPaid + ? Freight.InvoiceStatus.Paid + : Freight.InvoiceStatus.PartiallyPaid; + + const entry: InvoicePayment = { + amount: round2(input.amount), + method: input.method ?? null, + reference: input.reference ?? null, + paidAt: at.toISOString(), + metadata: input.metadata ?? null, + }; + const payments = [...(invoice.payments ?? []), entry]; + + const patch = { + paidAmount, + balanceAmount, + status, + payments, + paidAt: fullyPaid ? at : (invoice.paidAt ?? null), + }; + await mg.update(Invoice, { id: invoice.id }, patch as never); + + const updated = { ...invoice, ...patch } as Invoice; + return { + result: updated, + emit: fullyPaid + ? () => this.emitInvoiceEvent("paid", updated) + : undefined, + }; + }); + } + + /** + * Mark an invoice refunded and emit `${source}.invoice.refunded`. + * No-op when already refunded. Throws when the invoice has no recorded + * payment (nothing to refund). + */ + async markInvoiceAsRefunded( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Refunded, + "refunded", + {}, + manager, + (invoice) => { + if (!(Number(invoice.paidAmount) > 0)) { + throw new BadRequestException( + "Cannot refund an invoice with no recorded payment.", + ); + } + }, + ); + } + + /** + * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. + * No-op when already cancelled. Throws when the invoice has payments + * recorded against it (refund it instead). + */ + async cancelInvoice( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Cancelled, + "cancelled", + {}, + manager, + (invoice) => { + if (Number(invoice.paidAmount) > 0) { + throw new BadRequestException( + "Cannot cancel an invoice that has payments recorded against it.", + ); + } + }, + ); + } + + /** + * Load the invoice, apply the new status (+ extra columns), then emit + * `${source}.invoice.`. No-op (returns the invoice, skipping `guard`) + * when it is already in the target status. Throws when the invoice does not + * exist or `guard` rejects the current state. Pass `manager` to enlist in a + * caller's transaction; otherwise locks the row for update and emits only + * after commit (see {@link runTransition}). + */ + private async transition( + invoiceId: string, + status: Freight.InvoiceStatus, + event: string, + extra: { paymentId?: string }, + manager?: EntityManager, + guard?: (invoice: Invoice) => void, + ): Promise { + return this.runTransition(manager, async (mg) => { + const invoice = await mg.findOne(Invoice, { + where: { id: invoiceId }, + lock: { mode: "pessimistic_write" }, + }); + if (!invoice) { + throw new NotFoundException(`Invoice ${invoiceId} not found`); + } + if (invoice.status === status) return { result: invoice }; + guard?.(invoice); + + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + + const updated = { ...invoice, ...extra, status } as Invoice; + return { + result: updated, + emit: () => this.emitInvoiceEvent(event, updated), + }; + }); + } + + /** Broadcast `${invoice.source}.invoice.` to in-process listeners. */ + private emitInvoiceEvent(event: string, invoice: Invoice): void { + const payload: InvoiceEventPayload = { + invoiceId: invoice.id, + invoiceNumber: invoice.invoiceNumber, + source: invoice.source as Freight.InvoiceSource, + sourceId: invoice.sourceId, + type: invoice.type, + companyId: invoice.companyId, + companyProfileId: invoice.companyProfileId, + totalAmount: invoice.totalAmount, + currency: invoice.currency, + status: invoice.status, + paymentId: invoice.paymentId ?? null, + }; + this.events + .emitAsync(`${invoice.source}.invoice.${event}`, payload) + .catch((err) => + this.logger.error( + `Listener for ${invoice.source}.invoice.${event} (invoice ${invoice.id}) failed: ${err instanceof Error ? err.message : String(err)}`, + ), + ); + } + + // ── Payment reconciliation (by source) ─────────────────────────────────────── + + /** + * The invoice a source record already has open, or null if it needs a new + * one. This is the idempotency check every `ensureInvoiceFor*` (booking, + * first-mile, last-mile) runs before generating β€” it must see DRAFT + * invoices too, not just issued ones, otherwise a source that already has + * an unissued draft gets a second, duplicate invoice minted alongside it + * instead of that draft being reused and then issued. + * + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching draft-or-open + * (unpaid, non-cancelled) invoice. + */ + findPayable( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); } + + /** + * Pass `type` to select a specific invoice when a source carries several (e.g. + * a booking's up-front vs final charge); omit it to settle whichever single + * invoice is currently open. Returns the most recent matching open (unpaid, + * non-cancelled) invoice. + */ + findInvoice( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + } + + /** + * Expire a source's currently-open invoice (its pay window closed before + * settlement), then emit `${source}.invoice.expired`. Resolves the open invoice + * and transitions it to EXPIRED β€” a terminal, non-payable status (kept out of + * `OPEN_STATUSES`). No-op (returns null) when the source has no open invoice + * (already paid/cancelled/expired). + * + * Pass the caller's transaction `manager` (e.g. the booking pay-window expiry in + * the batch engine) to enlist in its DB transaction. + */ + async expirePayable( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.transition( + invoice.id, + Freight.InvoiceStatus.Expired, + "expired", + {}, + mg, + ); + } + + /** + * Sync a source's open invoice `dueAt` to its real pay-window deadline. The + * booking invoice is generated before the pay window opens (at booking + * creation/approval), so its printed due date is refreshed when the batch engine + * sets `paymentDeadline`. No-op when the source has no open invoice. + */ + async syncPayableDueDate( + source: Freight.InvoiceSource, + sourceId: string, + dueAt: Date, + type?: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return; + await mg.update(Invoice, { id: invoice.id }, { dueAt }); + } + + /** + * Force an invoice to `status`, including issuing a still-DRAFT invoice + * (stamping `issuedAt`) β€” unlike the other transitions here, this is a + * blunt admin/workflow override, not a settlement. No-op when the invoice + * is missing or already terminal (paid/cancelled/refunded/expired). + */ + async updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { + id: invoiceId, + status: In([Freight.InvoiceStatus.Draft, ...OPEN_STATUSES]), + }, + }); + if (!invoice) return; + await mg.update( + Invoice, + { id: invoice.id }, + { status, issuedAt: invoice.issuedAt ?? new Date() }, + ); + } + + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── + + /** + * Charge an invoice through the payment gateway. Billing is the single place + * that turns "what is owed" (the invoice) into a payment intent β€” the domain + * never talks to the payment service directly. Resolves the invoice by ID, + * opens an intent for `invoice.balanceAmount` (so partial payments are honored), + * records the intent id on the invoice (the settlement correlation key), and + * returns the client action. + * + * When the provider settles synchronously, the invoice is settled inline here β€” + * after the intent id is stored β€” so the `payment.succeeded` correlation can + * never fire before the link exists. Throws when the invoice is not found or + * not in an open/payable status. + */ + async payInvoice( + invoiceId: string, + opts: { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + } = {}, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { id: invoiceId, status: In(OPEN_STATUSES) }, + }); + if (!invoice) { + throw new NotFoundException( + `Invoice ${invoiceId} not found or not in a payable status`, + ); + } + + const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount); + if (!(amountDue > 0)) { + throw new BadRequestException("Invoice has no outstanding balance."); + } + + const result = await this.payment.initiate({ + referenceId: invoice.sourceId, + source: invoice.source, + // Freight payments settle under the generic SHIPMENT reference β€” how the + // payment service attributes them to the freight API. The payment ↔ invoice + // link is the intent id (`paymentId`); per-source post-payment reactions live + // in the domain via `${source}.invoice.paid`. Neither billing nor the payment + // service branches on a domain-specific reference type. + referenceType: PaymentReferenceType.SHIPMENT, + orderRef: invoice.invoiceNumber.replace("-", "_"), + amountMinor: Math.round(Number(invoice.balanceAmount)), + currency: invoice.currency, + reason: `Payment for invoice ${invoice.invoiceNumber}`, + method: opts.method ?? "TELEBIRR", + platform: opts.platform, + payerAccount: opts.payerAccount, + returnUrl: opts.returnUrl, + failureUrl: opts.failureUrl, + }); + + // Link the intent to the invoice BEFORE any settlement can correlate against it. + await this.dataSource + .getRepository(Invoice) + .update({ id: invoice.id }, { paymentId: result.intentId }); + + if (result.immediateSuccess) { + await this.settleByPaymentId( + result.intentId, + result.providerTxnId, + result.paidAt, + ); + } + + return result.response; + } + + /** + * Settle the open invoice linked to a gateway intent id, if any. Called by the + * payment service when an intent succeeds: finds the invoice linked by + * `paymentId`, marks it paid, and emits `${source}.invoice.paid` for the domain + * to advance on. Idempotent β€” no-op when no open invoice is linked (already + * settled, or settled inline by {@link payInvoice}). + */ + async settleByPaymentId( + paymentId: string, + providerTxnId?: string, + paidAt?: Date, + ): Promise { + const invoice = await this.dataSource.getRepository(Invoice).findOne({ + where: { paymentId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId, undefined, { + providerTxnId, + paidAt, + }); + } } diff --git a/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts new file mode 100644 index 000000000..c320a5d44 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/documents.module.ts @@ -0,0 +1,16 @@ +import { Module } from "@nestjs/common"; + +import { InvoiceDocumentService } from "./invoice-document.service"; +import { PdfRenderService } from "./pdf-render.service"; + +/** + * Standalone document infrastructure β€” generic HTMLβ†’PDF plus the shared + * invoice/receipt renderer. Has no domain dependencies, so any module (billing, + * warehouses, …) can import it to print invoices without coupling to the + * billing payment graph. + */ +@Module({ + providers: [PdfRenderService, InvoiceDocumentService], + exports: [PdfRenderService, InvoiceDocumentService], +}) +export class DocumentsModule {} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts new file mode 100644 index 000000000..a07087f8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from "@nestjs/common"; + +import { PdfRenderService } from "./pdf-render.service"; + +export type InvoiceDocumentKind = "INVOICE" | "RECEIPT"; + +/** One billed line on the document (charge type / fee type agnostic). */ +export interface InvoiceDocumentLine { + description: string | null; + /** Optional categorisation column (e.g. "Fee type" / "Charge type"). */ + category?: string | null; + quantity?: number | null; + unitRate?: number | null; + amount?: number | null; + currency?: string | null; +} + +/** A labelled total row in the totals box; mark `grand` for the headline total. */ +export interface InvoiceDocumentTotal { + label: string; + amount: number; + grand?: boolean; +} + +/** + * Source-agnostic description of a printable invoice/receipt. Each billing + * source maps its own entity onto this shape; the renderer owns the layout so + * every EDR invoice document looks identical regardless of source. + */ +export interface InvoiceDocumentModel { + kind: InvoiceDocumentKind; + /** Document heading, e.g. "Warehouse Fee Invoice" / "Freight Invoice". */ + title: string; + documentNumber: string; + issuedAt?: Date | string | null; + status: string; + currency: string; + /** Free-form summary grid (label/value pairs). */ + summary: Array<{ label: string; value: string | null }>; + /** Header for the line-item category column; column hidden when omitted. */ + categoryHeader?: string; + lines: InvoiceDocumentLine[]; + totals: InvoiceDocumentTotal[]; + /** Override the round seal text; defaults from kind/status. */ + sealText?: string; +} + +/** + * Central invoice/receipt PDF renderer shared by every billing source. Turns a + * {@link InvoiceDocumentModel} into the sealed EDR document HTML and renders it + * via {@link PdfRenderService}. Previously this layout lived (warehouse-only) in + * `WarehouseInvoiceService`; it now serves all invoices. + */ +@Injectable() +export class InvoiceDocumentService { + constructor(private readonly pdf: PdfRenderService) {} + + async render( + model: InvoiceDocumentModel, + ): Promise<{ filename: string; buffer: Buffer }> { + const html = this.buildHtml(model); + const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; + return { + filename: `${this.safeFilename(model.documentNumber)}-${kindLabel}.pdf`, + buffer: await this.pdf.htmlToPdfBuffer(html, { label: `${model.title} ${kindLabel}` }), + }; + } + + buildHtml(model: InvoiceDocumentModel): string { + const esc = (value: unknown) => + String(value ?? "-") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + const money = (amount: unknown, currency = model.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + const date = (value: unknown) => + value ? new Date(value as string | Date).toLocaleDateString("en-GB") : "-"; + + const showCategory = Boolean(model.categoryHeader); + const sealText = + model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); + + const summaryRows = model.summary + .map((row) => `
${esc(row.label)}${esc(row.value)}
`) + .join(""); + + const itemRows = model.lines + .map( + (item) => `
+ + ${showCategory ? `` : ""} + + + + `, + ) + .join(""); + + const totalRows = model.totals + .map( + (total) => + `
${esc(total.label)}${esc(money(total.amount))}
`, + ) + .join(""); + + return ` + + + + ${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

+
+
+ Document no. + ${esc(model.documentNumber)} + Issued: ${esc(date(model.issuedAt))} +
+
+
${esc(sealText)}
+
${summaryRows}
+
${esc(item.description)}${esc((item.category ?? "").replace(/_/g, " "))}${esc(item.quantity ?? 0)}${esc(money(item.unitRate, item.currency ?? model.currency))}${esc(money(item.amount, item.currency ?? model.currency))}
+ + + + ${showCategory ? `` : ""} + + + + + + + ${itemRows} + +
Description${esc(model.categoryHeader)}QtyRateAmount
+

${totalRows}
+ + + +`; + } + + safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, "-"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts new file mode 100644 index 000000000..447bc2516 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/pdf-render.service.ts @@ -0,0 +1,160 @@ +import { existsSync } from "fs"; + +import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; + +const MIN_VALID_PDF_BYTES = 2_000; + +const PDF_PRINT_STYLES = ` +`; + +export interface PdfRenderOptions { + /** Label used in logs to identify the document kind. */ + label?: string; + /** + * Degraded renderer used when Chromium is unavailable. Receives the + * print-prepared HTML and must return a valid PDF buffer (β‰₯ 2KB, `%PDF-` + * header). When omitted, a generic single-page fallback is produced. + */ + fallback?: (preparedHtml: string) => Buffer; +} + +/** + * Generic HTML β†’ PDF renderer shared by every document producer (invoices, + * receipts, warehouse release orders). Renders via headless Chromium when + * available and degrades to a caller-supplied (or generic) hand-built PDF + * otherwise. This is pure infrastructure β€” it knows nothing about invoices. + */ +@Injectable() +export class PdfRenderService { + private readonly logger = new Logger(PdfRenderService.name); + + async htmlToPdfBuffer(html: string, opts: PdfRenderOptions = {}): Promise { + const label = opts.label ?? "document"; + 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, 250)); + + const pdf = await page.pdf({ + format: "A4", + printBackground: true, + margin: { top: "16mm", bottom: "18mm", left: "14mm", right: "14mm" }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid ${label} PDF (${buffer.length} bytes)`); + } + this.logger.log( + `${label} PDF rendered (${buffer.length} bytes) via ${executablePath ?? "bundled Chromium"}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error(`${label} PDF failed (executable=${executablePath ?? "default"}): ${error}`); + const fallback = (opts.fallback ?? ((h) => this.genericFallbackPdf(h)))(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using ${label} PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + `${label} PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.`, + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes("edr-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((path) => existsSync(path)); + } + + isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + + /** Minimal valid one-page PDF carrying a plain-text rendering of the document. */ + private genericFallbackPdf(html: string): Buffer { + const text = html + .replace(//gi, "") + .replace(//gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/[^\x20-\x7e]/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 900); + + const escape = (value: string) => value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)"); + const lines = (text.match(/.{1,90}/g) ?? ["Document"]).slice(0, 40); + const stream = + "BT\n/F1 10 Tf\n36 800 Td\n12 TL\n" + + lines.map((line, i) => `${i === 0 ? "" : "T*\n"}(${escape(line)}) Tj\n`).join("") + + "ET"; + + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = "%PDF-1.4\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, "latin1")); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, "latin1") < MIN_VALID_PDF_BYTES) pdf += "% pad\n"; + const xrefOffset = Buffer.byteLength(pdf, "latin1"); + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, "0")} 00000 n \n`; + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, "latin1"); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts new file mode 100644 index 000000000..c29160ab7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/pay-invoice.dto.ts @@ -0,0 +1,30 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsIn, IsOptional, IsString } from "class-validator"; + +/** Gateway options for paying an invoice from the customer portal. */ +export class PayInvoiceDto { + @ApiPropertyOptional({ description: "Payment method (defaults to TELEBIRR)." }) + @IsOptional() + @IsString() + method?: string; + + @ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" }) + @IsOptional() + @IsIn(["web", "mobile"]) + platform?: "web" | "mobile"; + + @ApiPropertyOptional({ description: "Payer account / phone, for wallet methods." }) + @IsOptional() + @IsString() + payerAccount?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on success." }) + @IsOptional() + @IsString() + returnUrl?: string; + + @ApiPropertyOptional({ description: "Browser redirect URL on failure." }) + @IsOptional() + @IsString() + failureUrl?: string; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts new file mode 100644 index 000000000..a042dedb7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice-line.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { Invoice } from "./invoice.entity"; + +@Entity({ schema: "freight", name: "invoice_lines" }) +export class InvoiceLine extends BaseEntity { + @Column({ name: "invoice_id", type: "uuid", nullable: false }) + invoiceId!: string; + + @ManyToOne(() => Invoice, { onDelete: "CASCADE" }) + @JoinColumn({ name: "invoice_id" }) + invoice!: Invoice; + + @Column({ name: "charge_type", type: "varchar", nullable: false }) + chargeType!: string; + + @Column({ name: "description", type: "varchar", length: 255, nullable: true }) + description?: string; + + /** Units this line bills for (e.g. container count, wagon count, tons). */ + @Column({ name: "quantity", type: "numeric", precision: 12, scale: 2, default: 1 }) + quantity!: number; + + /** Price per unit; `amount` is normally `quantity * unitRate`. */ + @Column({ name: "unit_rate", type: "numeric", precision: 14, scale: 2, default: 0 }) + unitRate!: number; + + @Column({ + name: "amount", + type: "numeric", + precision: 14, + scale: 2, + nullable: false, + }) + amount!: number; + + @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) + currency!: string; + + @Column({ name: "metadata", type: "jsonb", nullable: true }) + metadata?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts index e2a6f7cc2..23c332f80 100644 --- a/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts +++ b/apps/edr-freight-api/src/modules/billing/entities/invoice.entity.ts @@ -1,17 +1,60 @@ import { BaseEntity } from "@edr/api-common"; import { Freight } from "@edr/types"; -import { Column, Entity } from "typeorm"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { PaymentEntity } from "../../payment/entities/payment.entity"; +import { Company } from "../../companies/entities/company.entity"; +import { CompanyProfile } from "../../companies/entities/company-profile.entity"; -@Entity({schema:"freight", name: "invoices" }) +/** A single recorded settlement against an invoice (payment ledger entry). */ +export interface InvoicePayment { + amount: number; + method?: string | null; + reference?: string | null; + /** ISO timestamp of when the settlement was recorded. */ + paidAt: string; + metadata?: Record | null; +} + +@Entity({ schema: "freight", name: "invoices" }) +@Index(["companyId"]) +@Index(["companyProfileId"]) export class Invoice extends BaseEntity { - @Column({ name: "booking_id", type: "uuid" }) - bookingId!: string; - @Column({ name: "invoice_number", type: "varchar", length: 64, unique: true }) invoiceNumber!: string; - @Column({ name: "amount", type: "numeric", precision: 14, scale: 2 }) - amount!: number; + /** The customer (company) this invoice is billed to. */ + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company) + @JoinColumn({ name: "company_id" }) + company?: Company; + + /** The specific company profile (importer/exporter/forwarder/...) billed. */ + @Column({ name: "company_profile_id", type: "uuid" }) + companyProfileId!: string; + + @ManyToOne(() => CompanyProfile) + @JoinColumn({ name: "company_profile_id" }) + companyProfile?: CompanyProfile; + + /** Sum of line amounts before tax; defaults to `totalAmount` for tax-free invoices. */ + @Column({ name: "subtotal_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + subtotalAmount!: number; + + @Column({ name: "tax_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + taxAmount!: number; + + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) + totalAmount!: number; + + /** Cumulative amount settled so far (supports partial payment). */ + @Column({ name: "paid_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + paidAmount!: number; + + /** Outstanding balance = `totalAmount - paidAmount` (0 once fully paid). */ + @Column({ name: "balance_amount", type: "numeric", precision: 14, scale: 2, default: 0 }) + balanceAmount!: number; @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -19,13 +62,46 @@ export class Invoice extends BaseEntity { @Column({ name: "status", type: "enum", - enum: Freight.PaymentStatus, - default: Freight.PaymentStatus.Pending, + enum: Freight.InvoiceStatus, + default: Freight.InvoiceStatus.Draft, }) - status!: Freight.PaymentStatus; + status!: Freight.InvoiceStatus; - @Column({ name: "issued_at", type: "timestamptz" }) - issuedAt!: Date; + /** The source of the payment (e.g. booking, customer, etc.). */ + @Column({ name: "source", type: "varchar", length: 255, nullable: false }) + source!: string; + + /** The ID of the source (e.g. booking ID, customer ID, etc.). */ + @Column({ name: "source_id", type: "varchar", length: 255, nullable: false }) + sourceId!: string; + + /** The type of Invoice (e.g. prepaid, credit, etc.). it suppose to answer the question "what is the invoice for?" */ + @Column({ + type: "varchar", + length: 255, + nullable: false, + }) + type!: string; + + /** Set when the invoice is actually issued (DRAFT invoices leave this null). */ + @Column({ name: "issued_at", type: "timestamptz", nullable: true }) + issuedAt?: Date | null; + + /** Set when the invoice is fully settled. */ + @Column({ name: "paid_at", type: "timestamptz", nullable: true }) + paidAt?: Date | null; + + /** Ledger of individual settlements (manual or gateway), newest last. */ + @Column({ name: "payments", type: "jsonb", default: () => "'[]'" }) + payments!: InvoicePayment[]; + + /** The ID of the payment that generated this invoice. */ + @Column({ name: "payment_id", type: "uuid", nullable: true }) + paymentId?: string | null; + + @ManyToOne(() => PaymentEntity) + @JoinColumn({ name: "payment_id" }) + payment?: PaymentEntity; @Column({ name: "due_at", type: "timestamptz" }) dueAt!: Date; diff --git a/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice-line.repository.ts new file mode 100644 index 000000000..6a5482543 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-line.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 { InvoiceLine } from "./entities/invoice-line.entity"; + +@Injectable() +export class InvoiceLineRepository extends BaseRepository { + constructor( + @InjectRepository(InvoiceLine) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts new file mode 100644 index 000000000..d3e6a208f --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-numbering.util.ts @@ -0,0 +1,51 @@ +/** + * Shared per-day sequential invoice numbering, used by every billing source + * (freight `FRT-…`, warehouse fees `WHF-…`, …) so the format and the + * `MAX(seq)+1` allocation live in one place instead of being copy-pasted per + * service. + * + * Produces `-YYYYMMDD-00001`: the sequence is the max existing suffix for + * the day + 1. Run inside the caller's transaction (pass that transaction's + * manager) so concurrent generation within a transaction stays consistent. + */ + +/** Anything exposing TypeORM's `.query` β€” an `EntityManager` or `DataSource`. */ +export interface SqlRunner { + query(sql: string, params?: unknown[]): Promise; +} + +export interface InvoiceNumberOptions { + /** Schema-qualified table to scan, e.g. `freight.invoices`. */ + table: string; + /** Document code prefix, e.g. `FRT` or `WHF`. */ + code: string; + /** Column holding the number; defaults to `invoice_number`. */ + column?: string; + /** Clock injection point (tests); defaults to now. */ + now?: Date; +} + +export async function nextDailyInvoiceNumber( + runner: SqlRunner, + opts: InvoiceNumberOptions, +): Promise { + const now = opts.now ?? new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `${opts.code}-${ymd}-`; + const column = opts.column ?? "invoice_number"; + + // Serialize concurrent allocation for this exact day+code prefix so two + // simultaneous transactions can't both read the same MAX(seq) and mint a + // duplicate number. Session-scoped to the caller's transaction β€” released + // automatically on commit/rollback. Different prefixes hash to different + // keys and never contend with each other. + await runner.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [prefix]); + + const rows = (await runner.query( + `SELECT COALESCE(MAX(CAST(split_part(${column}, '-', 3) AS int)), 0) AS seq + FROM ${opts.table} WHERE ${column} LIKE $1`, + [`${prefix}%`], + )) as Array<{ seq: number | string }>; + const next = Number(rows[0]?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts new file mode 100644 index 000000000..ab1e27b1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice-settlement.util.ts @@ -0,0 +1,36 @@ +/** + * Shared payment/settlement math for invoices. Both the global + * `BillingService.recordPayment` and the warehouse fee invoice flow apply a + * payment the same way β€” accumulate `paidAmount`, derive the outstanding + * `balanceAmount`, and decide whether the invoice is now fully settled. Keeping + * it here means the two flows can never drift on rounding or the + * partial-vs-full threshold. + */ + +/** Round to 2 decimals, avoiding binary float drift. */ +export const round2 = (n: number): number => Math.round(n * 100) / 100; + +export interface SettlementResult { + /** New cumulative amount paid. */ + paidAmount: number; + /** Remaining balance (0 once fully paid). */ + balanceAmount: number; + /** True once the balance reaches zero. */ + fullyPaid: boolean; +} + +/** + * Apply a single payment of `amount` to an invoice with `totalAmount` already + * carrying `currentPaid`. Caller is responsible for validating `amount > 0` and + * the invoice being in a payable state. + */ +export function applySettlement( + totalAmount: number, + currentPaid: number, + amount: number, +): SettlementResult { + const total = Number(totalAmount); + const paidAmount = round2(Number(currentPaid) + Number(amount)); + const balanceAmount = Math.max(0, round2(total - paidAmount)); + return { paidAmount, balanceAmount, fullyPaid: paidAmount >= total }; +} diff --git a/apps/edr-freight-api/src/modules/billing/invoice.repository.ts b/apps/edr-freight-api/src/modules/billing/invoice.repository.ts new file mode 100644 index 000000000..cc2e89df7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/invoice.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 { Invoice } from "./entities/invoice.entity"; + +@Injectable() +export class InvoiceRepository extends BaseRepository { + constructor( + @InjectRepository(Invoice) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/payment.controller.ts b/apps/edr-freight-api/src/modules/billing/payment.controller.ts new file mode 100644 index 000000000..543c84201 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/payment.controller.ts @@ -0,0 +1,177 @@ +import { + Body, + Controller, + Get, + HttpStatus, + Post, + Query, + Res, +} from "@nestjs/common"; +import { + ApiTags, + ApiOperation, + ApiQuery, + ApiOkResponse, + ApiProduces, +} from "@nestjs/swagger"; +import { Response } from "express"; +import { Public } from "@edr/api-common"; + +import { BillingService } from "./billing.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "../payment/payments.dto"; + +/** + * Central payment entrypoints. Domain-agnostic β€” the caller supplies an + * invoice ID and the billing service resolves the amount and drives the + * gateway. The domain never talks to the payment service directly. + * Routes are unchanged (`/payments/*`) so the portal is unaffected. + */ +@ApiTags("Payment") +@Controller("payments") +export class PaymentController { + constructor(private readonly billing: BillingService) { } + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment for an invoice", + description: "Charges the invoice through the payment gateway.", + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiate(@Body() dto: InitiatePaymentDto): Promise { + return this.billing.payInvoice(dto.invoiceId, { + method: dto.method, + platform: dto.platform, + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } + + @Get("checkout") + @Public() + @ApiOperation({ + summary: "Browser checkout redirect", + description: + "Charges the invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + }) + @ApiQuery({ name: "invoiceId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("invoiceId") invoiceId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!invoiceId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: invoiceId")); + } + if (!method || !Object.values(PaymentMethodTypeEnum).includes(method)) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing or invalid query parameter: method")); + } + + try { + const result = await this.billing.payInvoice( + invoiceId, + { method, platform }, + ); + const url = + result.clientAction?.type === "REDIRECT" ? result.clientAction.url : undefined; + + if (url) { + return res.status(HttpStatus.OK).type("html").send(this.buildRedirectHtml(url)); + } + return res + .status(HttpStatus.OK) + .type("html") + .send(this.buildStatusHtml(result.status, result.intentId)); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "An unexpected error occurred"; + return res.status(HttpStatus.OK).type("html").send(this.buildErrorHtml(message)); + } + } + + private buildRedirectHtml(url: string): string { + const escaped = url.replace(/\"/g, """); + return ` + + + + + Redirecting to payment… + + + +
+
+

Redirecting to payment provider…

+

Click here if you are not redirected

+
+ + +`; + } + + private buildStatusHtml(status: string, intentId: string): string { + return ` + + + + Payment status + + + +
+
${status}
+ Intent: ${intentId} +
+ +`; + } + + private buildErrorHtml(message: string): string { + return ` + + + + Payment error + + + +
+
Payment could not be initiated
+

${message}

+
+ +`; + } +} diff --git a/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts new file mode 100644 index 000000000..94e917754 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Res, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import type { Response } from "express"; +import { CurrentUser } from "@edr/api-common"; + +import { + type AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +import { sendPdf } from "./billing.controller"; +import { BillingService } from "./billing.service"; +import { PayInvoiceDto } from "./dto/pay-invoice.dto"; + +/** + * Customer-facing billing endpoints. Unlike {@link BillingController} (admin, + * org-wide), every route here is force-scoped to the signed-in customer's + * company β€” they only ever see and pay their own invoices. + */ +@ApiTags("billing") +@ApiBearerAuth() +@Controller("billing") +export class PortalBillingController { + constructor(private readonly billingService: BillingService) {} + + @Get("my-invoices") + @ApiOperation({ summary: "List the signed-in customer's invoices" }) + findMine( + @CurrentUser() user: AuthUserPayload, + @Query("source") source?: string, + @Query("sourceId") sourceId?: string, + ) { + return this.billingService.findForUser(resolveAuthUserId(user), { + source, + sourceId, + }); + } + + @Get("my-invoices/:id") + @ApiOperation({ summary: "Get one of the customer's invoices (+ line items)" }) + findMineById( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.billingService.findByIdForUser(id, resolveAuthUserId(user)); + } + + @Get("my-invoices/:id/document") + @ApiOperation({ summary: "Download one of the customer's invoice PDFs" }) + async document( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.documentForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Get("my-invoices/:id/receipt") + @ApiOperation({ summary: "Download one of the customer's payment receipt PDFs" }) + async receipt( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Res() res: Response, + ) { + const { filename, buffer } = await this.billingService.receiptForUser( + id, + resolveAuthUserId(user), + ); + sendPdf(res, filename, buffer); + } + + @Post("my-invoices/:id/pay") + @ApiOperation({ summary: "Initiate payment for one of the customer's invoices" }) + pay( + @Param("id", ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + @Body() dto: PayInvoiceDto, + ) { + return this.billingService.payInvoiceForUser(id, resolveAuthUserId(user), { + method: dto.method, + platform: dto.platform ?? "web", + payerAccount: dto.payerAccount, + returnUrl: dto.returnUrl, + failureUrl: dto.failureUrl, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts deleted file mode 100644 index b05895407..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - Body, - Controller, - Get, - Param, - ParseUUIDPipe, - Post, - Query, -} from '@nestjs/common'; -import { CurrentUser } from '@edr/api-common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingOrdersService } from './booking-orders.service'; -import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; -import { GeneralContractService } from './general-contract.service'; - -@ApiTags('Booking Orders') -@Controller('booking-orders') -export class BookingOrdersController { - constructor( - private readonly ordersService: BookingOrdersService, - private readonly generalContractService: GeneralContractService, - ) {} - - @Post() - @ApiOperation({ summary: 'Place a drawdown order against a general contract' }) - async create( - @Body() dto: CreateBookingOrderDto, - @CurrentUser() user: TCurrentUser, - ) { - return this.ordersService.create(dto, user?.id); - } - - @Get() - @ApiOperation({ summary: 'List orders placed against a contract' }) - async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) { - return this.ordersService.listByContract(contractBookingId); - } - - @Get('contract/:id/pool') - @ApiOperation({ - summary: 'Contracted / ordered / remaining quantities for a general contract', - }) - async pool(@Param('id', ParseUUIDPipe) id: string) { - return this.generalContractService.getQuantityLines(id); - } - - @Get('contract/:id/routes') - @ApiOperation({ - summary: - 'Per-route contracted / ordered / remaining quantities (multi-route contracts). Empty for single-route.', - }) - async routes(@Param('id', ParseUUIDPipe) id: string) { - return this.generalContractService.getRouteLines(id); - } - - @Get(':id') - @ApiOperation({ summary: 'Get a single booking order' }) - async findOne(@Param('id', ParseUUIDPipe) id: string) { - return this.ordersService.findById(id); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts deleted file mode 100644 index aafb04474..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { forwardRef, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { BookingsModule } from '../bookings/bookings.module'; -import { CompaniesModule } from '../companies/companies.module'; -import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; -import { RuleEngineModule } from '../rule-engine/rule-engine.module'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; -import { BookingOrdersController } from './booking-orders.controller'; -import { BookingOrdersRepository } from './booking-orders.repository'; -import { BookingOrdersService } from './booking-orders.service'; -import { BookingOrder } from './entities/booking-order.entity'; -import { BookingOrderLine } from './entities/booking-order-line.entity'; -import { ContractRouteLine } from './entities/contract-route-line.entity'; -import { GeneralContractService } from './general-contract.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([BookingOrder, BookingOrderLine, ContractRouteLine]), - BookingsModule, - CompaniesModule, - DropdownSettingsModule, - RuleEngineModule, - forwardRef(() => TrainSchedulingModule), - ], - controllers: [BookingOrdersController], - providers: [ - BookingOrdersService, - BookingOrdersRepository, - GeneralContractService, - ], - exports: [BookingOrdersService, GeneralContractService], -}) -export class BookingOrdersModule {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts deleted file mode 100644 index c45029b2b..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BaseRepository } from '@edr/api-common'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { BookingOrder } from './entities/booking-order.entity'; - -@Injectable() -export class BookingOrdersRepository extends BaseRepository { - constructor( - @InjectRepository(BookingOrder) - repository: Repository, - ) { - super(repository); - } - - /** Orders placed against a given contract, newest first, with their lines. */ - findByContract(contractBookingId: string): Promise { - return this.repository.find({ - where: { contractBookingId }, - relations: { lines: { containerType: true }, booking: true }, - order: { createdAt: 'DESC' }, - }); - } - - override findById(id: string): Promise { - return this.repository.findOne({ - where: { id }, - relations: { lines: { containerType: true }, booking: true, contractBooking: true }, - }); - } - - /** Count this calendar year's orders, for reference generation. */ - async countByYear(year: number): Promise { - const start = new Date(Date.UTC(year, 0, 1)); - const end = new Date(Date.UTC(year + 1, 0, 1)); - return this.repository - .createQueryBuilder('o') - .where('o.createdAt >= :start AND o.createdAt < :end', { start, end }) - .getCount(); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts deleted file mode 100644 index b870037f4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { BookingOrdersService } from './booking-orders.service'; - -/** - * Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that - * waits for Marketing review (or the customs clearance gate first) β€” it does - * NOT auto-enter the train batch pool, and the contract is not charged. - */ -describe('BookingOrdersService β€” child spawn on order create', () => { - function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) { - const contract = { - id: 'c-1', - bookingType: 'GENERAL_CONTRACT', - status: 'CONTRACT_ACTIVE', - expiresAt: new Date('2030-01-01T00:00:00.000Z'), - freightType: 'BULK', - originYardId: 'o-1', - destinationYardId: 'd-1', - companyId: null, - paymentCurrency: 'ETB', - serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' }, - bookingContainers: [], - }; - - // Capture what status the child is created with. - const created: Record[] = []; - const managerUpdates: Record[] = []; - const fakeManager = { - create: (_entity: unknown, data: Record) => { - created.push(data); - return { id: 'child-1', ...data }; - }, - save: async (row: Record) => ({ id: 'child-1', ...row }), - getRepository: () => ({ - findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }), - update: async (_id: string, data: Record) => { - managerUpdates.push(data); - }, - }), - }; - - const dataSource = { - transaction: async (cb: (m: unknown) => Promise) => cb(fakeManager), - getRepository: () => ({ update: jest.fn() }), - }; - const ordersRepository = { - countByYear: jest.fn().mockResolvedValue(0), - findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }), - }; - const bookingsRepository = { - findById: jest.fn().mockResolvedValue(contract), - countByYear: jest.fn().mockResolvedValue(0), - }; - const generalContractService = { - isGeneralContract: () => true, - getRouteLines: jest.fn().mockResolvedValue([]), - getQuantityLines: jest - .fn() - .mockResolvedValue([ - { containerTypeId: null, remainingQuantity: 100, containerTypeName: null }, - ]), - isExhausted: jest.fn().mockResolvedValue(false), - }; - const pricingService = { - computePriceForBooking: jest.fn().mockResolvedValue({ - totalAmount: 500, - priorityScore: 10, - lineItems: [], - currency: 'ETB', - }), - }; - const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) }; - const trainSchedulingService = { - existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true), - }; - const companiesService = {}; - - const service = new BookingOrdersService( - dataSource as never, - ordersRepository as never, - bookingsRepository as never, - companiesService as never, - generalContractService as never, - pricingService as never, - ratesService as never, - trainSchedulingService as never, - ); - return { service, created, managerUpdates, pricingService }; - } - - const dto = { - contractBookingId: 'c-1', - scheduledDate: '2026-07-01T00:00:00.000Z', - lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }], - }; - - it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => { - const { service, created, managerUpdates, pricingService } = makeService({ - includesCustoms: false, - }); - await service.create(dto as never); - - const child = created.find((c) => c.bookingType === 'ONE_TIME')!; - expect(child.status).toBe('OPERATION_REQUEST_PENDING'); - expect(child.paymentStatus).toBe('PENDING'); - expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0 - expect(pricingService.computePriceForBooking).toHaveBeenCalled(); - // The computed price is persisted onto the child. - expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true); - }); - - it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => { - const { service, created } = makeService({ includesCustoms: true }); - await service.create(dto as never); - const child = created.find((c) => c.bookingType === 'ONE_TIME')!; - expect(child.status).toBe('AWAITING_DOCUMENTS'); - }); - - it('rejects when hazardous quantity exceeds the line quantity', async () => { - const { service } = makeService({ includesCustoms: false }); - await expect( - service.create({ - ...dto, - lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }], - } as never), - ).rejects.toThrow(/exceed the line quantity/); - }); -}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts deleted file mode 100644 index 3c5bc4019..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ /dev/null @@ -1,469 +0,0 @@ -import { - BadRequestException, - forwardRef, - Inject, - Injectable, - Logger, - NotFoundException, -} from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { BookingPricingService } from '../bookings/booking-pricing.service'; -import { clearanceCodesForBooking } from '../bookings/clearance.util'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingContainer } from '../bookings/entities/booking-container.entity'; -import { CompaniesService } from '../companies/companies.service'; -import { ContainerType } from '../rule-engine/entities/container-type.entity'; -import { RatesService } from '../rule-engine/services/rates.service'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; -import { eatDay } from '../train-scheduling/batch-window.util'; -import { BookingOrdersRepository } from './booking-orders.repository'; -import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; -import { BookingOrder } from './entities/booking-order.entity'; -import { BookingOrderLine } from './entities/booking-order-line.entity'; -import { GeneralContractService } from './general-contract.service'; -import { isRoadService, roadKmPrice } from './road.util'; - -@Injectable() -export class BookingOrdersService { - private readonly logger = new Logger(BookingOrdersService.name); - - constructor( - private readonly dataSource: DataSource, - private readonly ordersRepository: BookingOrdersRepository, - private readonly bookingsRepository: BookingsRepository, - private readonly companiesService: CompaniesService, - private readonly generalContractService: GeneralContractService, - private readonly pricingService: BookingPricingService, - private readonly ratesService: RatesService, - @Inject(forwardRef(() => TrainSchedulingService)) - private readonly trainSchedulingService: TrainSchedulingService, - ) {} - - /** Orders placed against a contract, with their lines and child booking. */ - async listByContract(contractBookingId: string): Promise { - const orders = await this.ordersRepository.findByContract(contractBookingId); - await Promise.all(orders.map((o) => this.syncOrderFromChild(o))); - return orders; - } - - async findById(id: string): Promise { - const order = await this.ordersRepository.findById(id); - if (order) await this.syncOrderFromChild(order); - return order; - } - - /** - * The order is a ledger row; the spawned child ONE_TIME booking is what - * actually moves through the workflow (clearance β†’ marketing/ops accept β†’ - * pay β†’ allocate), exactly like a one-time booking. Nothing writes the order - * row after creation, so its stored status would stay 'PENDING' forever. - * - * Mirror the child onto the order whenever it is read: copy the child's - * status, schedulingStatus and trainScheduleId onto the order (mutating the - * in-memory instance the caller gets back), and persist that snapshot when it - * has drifted so list/detail views and any stored reporting stay in sync. - */ - private async syncOrderFromChild(order: BookingOrder): Promise { - const child = order.booking; - if (!child) return; - - const nextStatus = child.status; - const nextScheduling = child.schedulingStatus; - const nextTrainScheduleId = child.trainScheduleId ?? null; - - const drifted = - order.status !== nextStatus || - order.schedulingStatus !== nextScheduling || - (order.trainScheduleId ?? null) !== nextTrainScheduleId; - - // Reflect the child onto the instance returned to the caller. - order.status = nextStatus; - order.schedulingStatus = nextScheduling; - order.trainScheduleId = nextTrainScheduleId; - - if (drifted) { - await this.ordersRepository.update(order.id, { - status: nextStatus, - schedulingStatus: nextScheduling, - trainScheduleId: nextTrainScheduleId, - }); - } - } - - /** - * Place a drawdown order against an ACTIVE general contract. - * - * Validates the requested quantities against the remaining pool, then spawns a - * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's - * route/cargo/service) so it flows through the existing train-scheduling - * pipeline. The order row is the ledger entry linking contract β†’ child booking. - */ - async create( - dto: CreateBookingOrderDto, - userId?: string, - ): Promise { - const contract = await this.bookingsRepository.findById(dto.contractBookingId); - if (!contract) { - throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); - } - if (!this.generalContractService.isGeneralContract(contract)) { - throw new BadRequestException('Booking is not a general contract'); - } - if (contract.status !== 'CONTRACT_ACTIVE') { - throw new BadRequestException( - `Contract is ${contract.status} β€” orders can only be placed against an ACTIVE contract`, - ); - } - if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { - throw new BadRequestException('Contract ordering window has expired'); - } - - // The customer placing the order must own the contract. - if (userId && !(await this.userOwnsContract(userId, contract))) { - throw new BadRequestException('You do not have access to this contract'); - } - - // Resolve the route the order ships on: a chosen contract route line for a - // multi-route contract, else the contract's own origin/destination. - const routeLines = await this.generalContractService.getRouteLines( - contract.id, - ); - let originYardId = contract.originYardId; - let destinationYardId = contract.destinationYardId; - let routeLineId: string | null = null; - let routeKm: number | null = null; - - if (routeLines.length > 0) { - if (!dto.routeLineId) { - throw new BadRequestException( - 'This contract has multiple routes β€” select a route to draw from', - ); - } - const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId); - if (!chosen) { - throw new BadRequestException( - 'Selected route is not part of this contract', - ); - } - originYardId = chosen.originYardId; - destinationYardId = chosen.destinationYardId; - routeLineId = chosen.routeLineId; - routeKm = chosen.km ?? null; - } - - // Validate the route has a departure on the chosen day. - const day = eatDay(new Date(dto.scheduledDate)); - const hasDeparture = - await this.trainSchedulingService.existsOpenScheduleOnRouteDay( - originYardId, - destinationYardId, - day, - ); - if (!hasDeparture) { - throw new BadRequestException( - 'No departures available on the selected day for this route', - ); - } - - const isContainer = contract.freightType === 'CONTAINER'; - - // Hazardous/reefer counts the customer entered cannot exceed the line they - // belong to. Validated for every order regardless of routing. - for (const line of dto.lines) { - const haz = line.hazardousQuantity ?? 0; - const reefer = line.reeferQuantity ?? 0; - if (haz < 0 || reefer < 0) { - throw new BadRequestException('Hazardous/reefer quantities cannot be negative'); - } - if (haz > line.quantity || reefer > line.quantity) { - throw new BadRequestException( - 'Hazardous/reefer quantity cannot exceed the line quantity', - ); - } - } - - // The contract has a single shared drawdown pool (per container type for - // CONTAINER, or one bulk bucket). Routes are pure lanes β€” the chosen route - // only fixed origin/destination/km above β€” so every order, routed or not, - // validates each line against the same shared pool. - const poolLines = await this.generalContractService.getQuantityLines( - contract.id, - ); - for (const line of dto.lines) { - if (line.quantity <= 0) { - throw new BadRequestException('Order quantities must be greater than zero'); - } - const key = isContainer ? (line.containerTypeId ?? '') : ''; - const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); - if (!poolLine) { - throw new BadRequestException( - isContainer - ? `Container type ${line.containerTypeId} is not part of this contract` - : 'This contract has no matching quantity pool', - ); - } - if (line.quantity > poolLine.remainingQuantity) { - throw new BadRequestException( - `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + - (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), - ); - } - } - - // Persist the order + its child shipment booking atomically. - const order = await this.dataSource.transaction(async (manager) => { - const childBooking = await this.spawnChildBooking( - contract, - dto, - { originYardId, destinationYardId, km: routeKm }, - manager, - ); - - const reference = await this.generateReference(); - const orderRow = manager.create(BookingOrder, { - reference, - contractBookingId: contract.id, - bookingId: childBooking.id, - routeLineId, - companyId: contract.companyId ?? null, - scheduledDate: new Date(dto.scheduledDate), - // The order is a ledger row; the child booking drives the workflow - // (review β†’ pay β†’ allocate), so the order tracks PENDING until done. - status: 'PENDING', - schedulingStatus: 'NOT_SCHEDULED', - }); - const savedOrder = await manager.save(orderRow); - - const lines = dto.lines.map((l) => - manager.create(BookingOrderLine, { - orderId: savedOrder.id, - containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, - quantity: l.quantity, - hazardousQuantity: l.hazardousQuantity ?? 0, - reeferQuantity: l.reeferQuantity ?? 0, - }), - ); - await manager.save(lines); - savedOrder.lines = lines; - return savedOrder; - }); - - // The child does NOT enter the train batch pool here. It is priced and - // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs - // clearance first; the batch enqueue happens only on accept. - - // Close the contract once its pool is exhausted (pending orders count, so - // the pool reserves quantity as soon as an order is placed). - if (await this.generalContractService.isExhausted(contract.id)) { - await this.dataSource - .getRepository(Booking) - .update(contract.id, { status: 'CONTRACT_CLOSED' }); - this.logger.log( - `Contract ${contract.reference} CLOSED β€” quantity exhausted`, - ); - } - - return (await this.ordersRepository.findById(order.id)) ?? order; - } - - /** - * Create the ONE_TIME child booking for an order, inheriting the contract's - * shipment context. Unlike the contract (which is no longer paid up front), - * the child is PRICED and UNPAID and waits for Marketing review β€” going - * through the customs clearance gate first when the service includes customs, - * mirroring a one-time booking. It only enters the train pool on accept. - */ - private async spawnChildBooking( - contract: Booking, - dto: CreateBookingOrderDto, - route: { originYardId: string; destinationYardId: string; km: number | null }, - manager: import('typeorm').EntityManager, - ): Promise { - const reference = await this.generateChildBookingReference(); - const isContainer = contract.freightType === 'CONTAINER'; - - // Sum line quantities Γ— the contract's per-unit weight for the child total. - const containerByType = new Map( - (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), - ); - let totalWeight = 0; - if (isContainer) { - for (const line of dto.lines) { - const src = containerByType.get(line.containerTypeId ?? ''); - const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; - totalWeight += vgmPerUnit * line.quantity; - } - } else { - totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); - } - - // Per-order hazardous/reefer: set the child flags from the order's line - // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply. - const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0); - const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0); - - // Customs orders flow through the one-time clearance gate first; others go - // straight to operations review with the chosen shipment day. - const { includesCustoms } = clearanceCodesForBooking(contract); - const spawnStatus = includesCustoms - ? 'AWAITING_DOCUMENTS' - : 'OPERATION_REQUEST_PENDING'; - - const child = manager.create(Booking, { - reference, - companyId: contract.companyId ?? null, - companyProfileId: contract.companyProfileId ?? null, - isGovernment: contract.isGovernment, - governmentInstitution: contract.governmentInstitution ?? null, - contractType: contract.contractType, - previousContractId: contract.id, - serviceTypeId: contract.serviceTypeId, - firstMilePickupAddress: contract.firstMilePickupAddress ?? null, - lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, - equipmentReturn: contract.equipmentReturn, - originYardId: route.originYardId, - destinationYardId: route.destinationYardId, - tradeDirection: contract.tradeDirection, - freightType: contract.freightType, - cargoTypeId: contract.cargoTypeId ?? null, - cargoFreeText: contract.cargoFreeText ?? null, - shippingLineId: contract.shippingLineId ?? null, - cargoTotalWeightVgm: totalWeight, - isHazardous: hasHazardous, - isReefer: hasReefer, - paymentCurrency: contract.paymentCurrency, - bookingType: 'ONE_TIME', - scheduledDate: new Date(dto.scheduledDate), - // Priced + unpaid: the customer pays this order on its own. - status: spawnStatus, - paymentStatus: 'PENDING', - priorityScore: contract.priorityScore, - totalAmount: 0, - schedulingStatus: 'NOT_SCHEDULED', - }); - const savedChild = await manager.save(child); - - if (isContainer) { - for (const line of dto.lines) { - const src = containerByType.get(line.containerTypeId ?? ''); - const ct = line.containerTypeId - ? await manager.getRepository(ContainerType).findOne({ - where: { id: line.containerTypeId }, - }) - : null; - const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; - const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; - const row = manager.create(BookingContainer, { - bookingId: savedChild.id, - containerTypeId: line.containerTypeId ?? null, - quantity: line.quantity, - vgmPerUnitTons: vgmPerUnit, - totalVgmTons: vgmPerUnit * line.quantity, - wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), - isOverweight: false, - }); - await manager.save(row); - } - } - - // Price the order: base freight for the drawn quantity + haz/reefer - // surcharges, plus a road KM charge when the service ships by road. - const roadKm = isRoadService(contract.serviceType) ? route.km : null; - await this.priceChildBooking(savedChild.id, roadKm, manager); - - return savedChild; - } - - /** - * Compute and persist the child order's price (base + surcharges) inside the - * order transaction. The contract is no longer paid up front, so each order - * carries its own total that the customer pays. - */ - private async priceChildBooking( - childId: string, - roadKm: number | null, - manager: import('typeorm').EntityManager, - ): Promise { - const child = await manager.getRepository(Booking).findOne({ - where: { id: childId }, - relations: { bookingContainers: true }, - }); - if (!child) return; - - try { - const computed = await this.pricingService.computePriceForBooking(child); - const lineItems = [...computed.lineItems]; - let total = computed.totalAmount; - - // Road KM charge: distance Γ— the live PER_KM rate, added as its own line. - if (roadKm && roadKm > 0) { - const perKmRate = await this.findPerKmRate(child.paymentCurrency); - const kmAmount = roadKmPrice(roadKm, perKmRate); - if (kmAmount > 0) { - lineItems.push({ - code: 'ROAD_KM', - description: `Road transport (${roadKm} km)`, - amount: kmAmount, - unitAmount: perKmRate!, - unit: 'PER_KM', - quantity: roadKm, - currency: child.paymentCurrency, - }); - total += kmAmount; - } - } - - await manager.getRepository(Booking).update(childId, { - totalAmount: total, - priorityScore: computed.priorityScore, - pricingBreakdown: { - lineItems, - totalAmount: total, - currency: computed.currency, - generatedAt: new Date().toISOString(), - }, - } as never); - } catch (err) { - this.logger.error( - `Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - } - - /** The live PER_KM rate value for road billing, in the given currency. */ - private async findPerKmRate(currency: string): Promise { - const rates = await this.ratesService.findLiveRates(); - const rate = rates.find( - (r) => r.rateUnit === 'PER_KM' && r.currency === currency, - ); - return rate ? Number(rate.rateValue) : null; - } - - private async userOwnsContract( - userId: string, - contract: Booking, - ): Promise { - if (!contract.companyId) return true; // government / staff-created - try { - const { company } = await this.companiesService.getCompanyInfoByUserId( - userId, - ); - return company.id === contract.companyId; - } catch { - return false; - } - } - - private async generateReference(): Promise { - const year = new Date().getFullYear(); - const count = await this.ordersRepository.countByYear(year); - return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; - } - - private async generateChildBookingReference(): Promise { - const year = new Date().getFullYear(); - const count = await this.bookingsRepository.countByYear(year); - return `BK-${year}-${String(count + 1).padStart(6, '0')}`; - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts deleted file mode 100644 index bfdcc72b8..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { CargoUnitOfMeasure } from '@edr/types'; - -/** A single contracted/ordered/remaining pool line for a general contract. */ -export class ContractQuantityLineView { - @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) - containerTypeId!: string | null; - - @ApiProperty({ nullable: true }) - containerTypeName!: string | null; - - @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true }) - unitOfMeasure!: CargoUnitOfMeasure | null; - - @ApiProperty() - contractedQuantity!: number; - - @ApiProperty() - orderedQuantity!: number; - - @ApiProperty() - remainingQuantity!: number; -} - -/** - * A contracted route (lane) of a general contract. Routes are pure - * originβ†’destination lanes the contract covers; they carry NO quantity. The - * contract has a single shared drawdown pool (see {@link ContractQuantityLineView}), - * and an order picks one lane (for scheduling/billing) while drawing from that - * shared pool. - */ -export class ContractRouteLineView { - @ApiProperty({ description: 'Contract route line id' }) - routeLineId!: string; - - @ApiProperty() - originYardId!: string; - - @ApiProperty({ nullable: true }) - originYardName!: string | null; - - @ApiProperty() - destinationYardId!: string; - - @ApiProperty({ nullable: true }) - destinationYardName!: string | null; - - @ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' }) - km!: number | null; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts deleted file mode 100644 index d7d226855..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import 'reflect-metadata'; -import { plainToInstance } from 'class-transformer'; -import { CreateBookingOrderLineDto } from './create-booking-order.dto'; - -/** - * Order line haz/reefer quantities arrive as JSON numbers but must default to 0 - * when omitted and coerce string inputs (defensive) to numbers. - */ -describe('CreateBookingOrderLineDto β€” haz/reefer coercion', () => { - const toDto = (plain: Record) => - plainToInstance(CreateBookingOrderLineDto, plain, { - enableImplicitConversion: false, - exposeDefaultValues: true, - }) as unknown as CreateBookingOrderLineDto; - - it('defaults hazardous/reefer quantities to 0 when omitted', () => { - const dto = toDto({ quantity: 5 }); - expect(dto.hazardousQuantity).toBe(0); - expect(dto.reeferQuantity).toBe(0); - }); - - it('coerces provided string quantities to numbers', () => { - const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' }); - expect(dto.quantity).toBe(5); - expect(dto.hazardousQuantity).toBe(2); - expect(dto.reeferQuantity).toBe(3); - }); -}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts deleted file mode 100644 index 4e7382ec4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Transform, Type } from 'class-transformer'; -import { - ArrayMinSize, - IsArray, - IsDateString, - IsNumber, - IsOptional, - IsUUID, - Min, - ValidateNested, -} from 'class-validator'; - -export class CreateBookingOrderLineDto { - @ApiPropertyOptional({ - format: 'uuid', - description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.', - }) - @IsOptional() - @IsUUID() - containerTypeId?: string; - - @ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 }) - @IsNumber() - @Min(0) - @Transform(({ value }) => Number(value)) - quantity!: number; - - @ApiPropertyOptional({ - description: 'How much of this line is hazardous (≀ quantity). Defaults to 0.', - minimum: 0, - }) - @IsOptional() - @IsNumber() - @Min(0) - @Transform(({ value }) => Number(value ?? 0)) - hazardousQuantity?: number = 0; - - @ApiPropertyOptional({ - description: 'How much of this line is refrigerated (≀ quantity). Defaults to 0.', - minimum: 0, - }) - @IsOptional() - @IsNumber() - @Min(0) - @Transform(({ value }) => Number(value ?? 0)) - reeferQuantity?: number = 0; -} - -export class CreateBookingOrderDto { - @ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' }) - @IsUUID() - contractBookingId!: string; - - @ApiPropertyOptional({ - format: 'uuid', - description: - 'For multi-route contracts: the contract route line being drawn from. ' + - 'Determines the shipment origin/destination. Omit for single-route contracts.', - }) - @IsOptional() - @IsUUID() - routeLineId?: string; - - @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) - @IsDateString() - scheduledDate!: string; - - @ApiProperty({ type: [CreateBookingOrderLineDto] }) - @IsArray() - @ArrayMinSize(1) - @ValidateNested({ each: true }) - @Type(() => CreateBookingOrderLineDto) - lines!: CreateBookingOrderLineDto[]; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts deleted file mode 100644 index d6fff951a..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; -import { ContainerType } from '../../rule-engine/entities/container-type.entity'; -import { BookingOrder } from './booking-order.entity'; - -/** - * Postgres `numeric` columns are serialized to JS strings by the driver. This - * transformer hydrates them back into real numbers so consumers (and the - * `quantity: number` API type) don't have to coerce on every read. - */ -const numericColumn = { - to: (value: number) => value, - from: (value: string | null) => (value == null ? value : Number(value)), -}; - -/** - * One drawn-down quantity line of an order. For CONTAINER contracts there is one - * line per container type (matching the contract's pools); for BULK/BREAK_BULK a - * single line with a null containerTypeId carries the tons/items. - */ -@Entity({ schema: 'freight', name: 'booking_order_lines' }) -export class BookingOrderLine extends BaseEntity { - @Column({ name: 'order_id', type: 'uuid' }) - orderId!: string; - - @ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'order_id' }) - order?: BookingOrder; - - @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) - containerTypeId?: string | null; - - @ManyToOne(() => ContainerType, { nullable: true }) - @JoinColumn({ name: 'container_type_id' }) - containerType?: ContainerType | null; - - /** Containers (count), tons, or items depending on the contract's freight/UoM. */ - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn }) - quantity!: number; - - /** - * How much of this line is hazardous / refrigerated, entered per order by the - * customer when they toggle the flag. Drives the HAZARD_SURCHARGE / - * REEFER_SURCHARGE rates on the spawned child booking. Both ≀ quantity. - */ - @Column({ - name: 'hazardous_quantity', - type: 'numeric', - precision: 12, - scale: 3, - default: 0, - transformer: numericColumn, - }) - hazardousQuantity!: number; - - @Column({ - name: 'reefer_quantity', - type: 'numeric', - precision: 12, - scale: 3, - default: 0, - transformer: numericColumn, - }) - reeferQuantity!: number; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts deleted file mode 100644 index 610496d79..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { SchedulingStatus } from '@edr/types'; -import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; -import { Booking } from '../../bookings/entities/booking.entity'; -import { Company } from '../../companies/entities/company.entity'; -import { BookingOrderLine } from './booking-order-line.entity'; - -/** - * A single drawdown against a general contract. Each order spawns its own - * ONE_TIME child Booking (the shipment that enters the train scheduling - * pipeline); this row is the ledger entry linking the contract to that - * shipment and recording the drawn-down quantities. - */ -@Entity({ schema: 'freight', name: 'booking_orders' }) -export class BookingOrder extends BaseEntity { - @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) - reference!: string; - - /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ - @Column({ name: 'contract_booking_id', type: 'uuid' }) - contractBookingId!: string; - - @ManyToOne(() => Booking) - @JoinColumn({ name: 'contract_booking_id' }) - contractBooking?: Booking; - - /** The ONE_TIME child shipment booking spawned for this order. */ - @Column({ name: 'booking_id', type: 'uuid', nullable: true }) - bookingId?: string | null; - - @ManyToOne(() => Booking, { nullable: true }) - @JoinColumn({ name: 'booking_id' }) - booking?: Booking | null; - - /** Denormalized from the contract for fast company-scoped filtering. */ - @Column({ name: 'company_id', type: 'uuid', nullable: true }) - companyId?: string | null; - - @ManyToOne(() => Company, { nullable: true }) - @JoinColumn({ name: 'company_id' }) - company?: Company | null; - - /** - * The contract route line this order drew down (multi-route general contracts). - * Null for legacy/single-route contracts that have no route lines β€” the order - * then uses the contract's own origin/destination. - */ - @Column({ name: 'route_line_id', type: 'uuid', nullable: true }) - routeLineId?: string | null; - - @Column({ name: 'scheduled_date', type: 'timestamptz' }) - scheduledDate!: Date; - - @Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' }) - status!: string; - - @Column({ - name: 'scheduling_status', - type: 'varchar', - length: 30, - default: SchedulingStatus.NotScheduled, - }) - schedulingStatus!: string; - - @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) - trainScheduleId?: string | null; - - @OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true }) - lines?: BookingOrderLine[]; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts deleted file mode 100644 index e05af758b..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { Booking } from '../../bookings/entities/booking.entity'; -import { ContainerType } from '../../rule-engine/entities/container-type.entity'; -import { Yard } from '../../rule-engine/entities/yard.entity'; - -/** - * One contracted route+quantity line of a GENERAL contract. A general contract - * may span several routes (e.g. Addisβ†’Dire Dawa: 10, Modjoβ†’Djibouti: 5); each - * route reserves its own quantity pool. Drawdown orders pick one of these routes - * and decrement that route's pool. One-time bookings do not use this β€” they keep - * the single origin/destination on the booking itself. - */ -@Entity({ schema: 'freight', name: 'contract_route_lines' }) -@Index(['contractBookingId']) -export class ContractRouteLine extends BaseEntity { - /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ - @Column({ name: 'contract_booking_id', type: 'uuid' }) - contractBookingId!: string; - - @ManyToOne(() => Booking) - @JoinColumn({ name: 'contract_booking_id' }) - contractBooking?: Booking; - - @Column({ name: 'origin_yard_id', type: 'uuid' }) - originYardId!: string; - - @ManyToOne(() => Yard) - @JoinColumn({ name: 'origin_yard_id' }) - originYard?: Yard; - - @Column({ name: 'destination_yard_id', type: 'uuid' }) - destinationYardId!: string; - - @ManyToOne(() => Yard) - @JoinColumn({ name: 'destination_yard_id' }) - destinationYard?: Yard; - - /** - * Container type this route line reserves (CONTAINER contracts); null for - * BULK/BREAK_BULK, where the quantity is tons/items. - */ - @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) - containerTypeId?: string | null; - - @ManyToOne(() => ContainerType, { nullable: true }) - @JoinColumn({ name: 'container_type_id' }) - containerType?: ContainerType | null; - - /** Contracted quantity for this (route, container type): containers, tons, or items. */ - @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) - quantity!: number; - - /** - * Road distance for this route, configured with the route. Road (truck) - * drawdown orders bill KM Γ— the PER_KM rate from this value. Null for - * rail-only routes where KM is not billed. - */ - @Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true }) - km?: number | null; -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts deleted file mode 100644 index 58122bd65..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { BookingType, CargoUnitOfMeasure } from '@edr/types'; -import { DataSource } from 'typeorm'; -import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingOrder } from './entities/booking-order.entity'; -import { ContractRouteLine } from './entities/contract-route-line.entity'; -import { - ContractQuantityLineView, - ContractRouteLineView, -} from './dto/contract-view.dto'; - -/** Setting code holding the global ordering window (in months) for general contracts. */ -export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; -/** Fallback when the setting is missing or unparseable. */ -export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; - -/** - * Owns general-contract concerns that sit alongside the generic booking flow: - * the configurable ordering period, post-payment activation, and computing the - * remaining drawdown pool per contract. - */ -@Injectable() -export class GeneralContractService { - private readonly logger = new Logger(GeneralContractService.name); - - constructor( - private readonly dataSource: DataSource, - private readonly dropdownSettings: DropdownSettingsService, - ) {} - - isGeneralContract(booking: Pick): boolean { - return booking.bookingType === BookingType.GeneralContract; - } - - /** The configured ordering window in months (defaults to 3). */ - async getPeriodMonths(): Promise { - try { - const setting = await this.dropdownSettings.getByCode( - CONTRACT_PERIOD_SETTING_CODE, - ); - const raw = setting.children?.[0]?.value; - const months = Number(raw); - if (Number.isFinite(months) && months > 0) return months; - } catch { - // Setting not seeded yet β€” fall back to the default. - } - return DEFAULT_CONTRACT_PERIOD_MONTHS; - } - - /** - * Called when a general contract's payment succeeds: mark it ACTIVE (instead of - * entering the train queue like a one-time booking) and stamp the ordering - * window. Idempotent. - */ - async activateAfterPayment(bookingId: string): Promise { - const repo = this.dataSource.getRepository(Booking); - const booking = await repo.findOne({ where: { id: bookingId } }); - if (!booking || !this.isGeneralContract(booking)) return; - if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') { - return; - } - - const months = await this.getPeriodMonths(); - const expiresAt = new Date(); - expiresAt.setMonth(expiresAt.getMonth() + months); - - await repo.update(bookingId, { - status: 'CONTRACT_ACTIVE', - paymentStatus: 'PAID', - expiresAt, - }); - this.logger.log( - `General contract ${booking.reference} ACTIVE β€” ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`, - ); - } - - /** - * The drawdown pool for a contract: contracted vs. ordered vs. remaining, - * per container type for CONTAINER contracts, or a single total line for - * BULK/BREAK_BULK (keyed on a null container type). - */ - async getQuantityLines( - contractBookingId: string, - ): Promise { - const booking = await this.dataSource.getRepository(Booking).findOne({ - where: { id: contractBookingId }, - relations: { bookingContainers: { containerType: true }, cargoType: true }, - }); - if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`); - - const ordered = await this.orderedByContainerType(contractBookingId); - - if (booking.freightType === 'CONTAINER') { - return (booking.bookingContainers ?? []).map((c) => { - const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0; - const contracted = Number(c.quantity); - return { - containerTypeId: c.containerTypeId ?? null, - containerTypeName: c.containerType?.label ?? null, - unitOfMeasure: null, - contractedQuantity: contracted, - orderedQuantity: orderedQty, - remainingQuantity: Math.max(0, contracted - orderedQty), - }; - }); - } - - // BULK / BREAK_BULK β€” a single pool keyed on the contracted total weight/items. - const orderedQty = ordered.get('') ?? 0; - const contracted = Number(booking.cargoTotalWeightVgm); - const uom: CargoUnitOfMeasure | null = - (booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ?? - CargoUnitOfMeasure.PerTon; - return [ - { - containerTypeId: null, - containerTypeName: null, - unitOfMeasure: uom, - contractedQuantity: contracted, - orderedQuantity: orderedQty, - remainingQuantity: Math.max(0, contracted - orderedQty), - }, - ]; - } - - /** - * The contracted routes (lanes) of a multi-route general contract β€” pure - * originβ†’destination pairs the contract covers. Routes carry NO quantity; the - * contract draws from a single shared pool ({@link getQuantityLines}). An order - * picks one lane (for scheduling + road billing) and draws from that pool. - * Returns [] for single-route contracts (no route lines) β€” callers then use the - * contract's own origin/destination. - */ - async getRouteLines( - contractBookingId: string, - ): Promise { - const routeLines = await this.dataSource - .getRepository(ContractRouteLine) - .find({ - where: { contractBookingId }, - relations: { - originYard: true, - destinationYard: true, - }, - order: { createdAt: 'ASC' }, - }); - - return routeLines.map((rl) => ({ - routeLineId: rl.id, - originYardId: rl.originYardId, - originYardName: rl.originYard?.label ?? null, - destinationYardId: rl.destinationYardId, - destinationYardName: rl.destinationYard?.label ?? null, - km: rl.km != null ? Number(rl.km) : null, - })); - } - - /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ - private async orderedByContainerType( - contractBookingId: string, - ): Promise> { - const rows = await this.dataSource - .getRepository(BookingOrder) - .createQueryBuilder('o') - .innerJoin('o.lines', 'line') - .select('COALESCE(line.container_type_id::text, :empty)', 'key') - .addSelect('SUM(line.quantity)', 'total') - .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) - .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) - .setParameter('empty', '') - .groupBy('key') - .getRawMany<{ key: string; total: string }>(); - - const map = new Map(); - for (const row of rows) map.set(row.key ?? '', Number(row.total)); - return map; - } - - /** Convenience: how many units remain for a given container type ('' = bulk). */ - async remainingFor( - contractBookingId: string, - containerTypeKey: string, - ): Promise { - const lines = await this.getQuantityLines(contractBookingId); - const line = lines.find( - (l) => (l.containerTypeId ?? '') === containerTypeKey, - ); - return line?.remainingQuantity ?? 0; - } - - /** - * True once the contract's shared pool is fully drawn down. Routes are pure - * lanes with no quantity, so exhaustion is purely a function of the shared - * per-container-type (or bulk) pool, regardless of how many routes exist. - */ - async isExhausted(contractBookingId: string): Promise { - const lines = await this.getQuantityLines(contractBookingId); - return lines.every((l) => l.remainingQuantity <= 0); - } -} diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts deleted file mode 100644 index 10258e3b4..000000000 --- a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { isRoadService, roadKmPrice } from './road.util'; - -describe('road.util', () => { - describe('isRoadService', () => { - it('treats ROAD/TRUCK codes (and prefixes) as road', () => { - expect(isRoadService({ code: 'ROAD' })).toBe(true); - expect(isRoadService({ code: 'TRUCK' })).toBe(true); - expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true); - expect(isRoadService({ code: 'truck_forwarding' })).toBe(true); - }); - - it('treats rail / unknown / missing services as not road', () => { - expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false); - expect(isRoadService({ code: 'OFFROADING' })).toBe(false); - expect(isRoadService(null)).toBe(false); - expect(isRoadService(undefined)).toBe(false); - }); - }); - - describe('roadKmPrice', () => { - it('multiplies distance by the per-km rate', () => { - expect(roadKmPrice(120, 5)).toBe(600); - }); - - it('returns 0 when km or rate is missing/non-positive', () => { - expect(roadKmPrice(null, 5)).toBe(0); - expect(roadKmPrice(120, null)).toBe(0); - expect(roadKmPrice(0, 5)).toBe(0); - expect(roadKmPrice(120, 0)).toBe(0); - }); - }); -}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts new file mode 100644 index 000000000..cfb9887c3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingsService } from './bookings.service'; +import { AllocateContainersDto } from './dto/allocate-containers.dto'; + +@ApiTags('bookings') +@Controller('bookings') +@ApiBearerAuth() +export class BookingAllocationController { + constructor(private readonly bookingsService: BookingsService) {} + + @Post(':bookingId/allocate-containers') + @ApiOperation({ summary: 'Allocate containers to vehicles' }) + async allocateContainers( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AllocateContainersDto, + ) { + return this.bookingsService.allocateContainers(bookingId, dto.allocations); + } +} 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 index fabd3dbca..5297f3d57 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -24,12 +24,6 @@ import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; -/** - * Default ordering window (months) for a general contract activated on - * counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS; - * defined locally to avoid a circular module dependency on booking-orders. - */ -const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { SignaturesService } from '../signatures/signatures.service'; @@ -240,23 +234,9 @@ export class BookingContractService { includesCustoms, ); - const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; - if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; - } else if (isGeneralContract) { - // A general contract is NOT paid up front β€” each drawdown order is priced - // and paid on its own. So on counter-sign it becomes ACTIVE directly and - // opens its ordering window; orders spawn their own priced child bookings. - const expiresAt = new Date(now); - expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS); - updates.fullyExecutedAt = now; - updates.marketingApprovedAt = now; - updates.marketingApprovedById = options.signerUserId ?? null; - updates.lockedAt = now; - updates.status = 'CONTRACT_ACTIVE'; - updates.expiresAt = expiresAt; } else { updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts new file mode 100644 index 000000000..ddf09dea6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -0,0 +1,241 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, +} from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; +import { Freight } from "@edr/types"; +import { DataSource, EntityManager } from "typeorm"; + +import { + BillingService, + GenerateInvoiceInput, + InvoiceEventPayload, + InvoiceLineInput, +} from "../billing/billing.service"; +import { Invoice } from "../billing/entities/invoice.entity"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { PriceLineItemDto } from "./dto/generate-price-response.dto"; +import { BookingsRepository } from "./bookings.repository"; +import { Booking } from "./entities/booking.entity"; + +/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */ +interface StoredPricingBreakdown { + lineItems?: PriceLineItemDto[]; + totalAmount?: number; + currency?: string; +} + +export interface InvoiceOptions { + dueDate?: Date; + invoiceType?: string; + invoiceStatus?: Freight.InvoiceStatus; +} + +/** Round to 2 decimals, avoiding binary float drift. */ +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Owns the booking ⇄ invoice mapping β€” the one place that knows how a booking + * turns into invoices, which type to use, and how it advances when paid. Bookings + * are the billable business entity, so they generate their own invoices directly + * via {@link BillingService} (billing stays source-agnostic). All booking-specific + * type branching lives here, at the two points it belongs: invoice creation and + * settlement (the paid handler). + */ +@Injectable() +export class BookingInvoiceService { + private readonly logger = new Logger(BookingInvoiceService.name); + + constructor( + private readonly billing: BillingService, + private readonly bookingsRepository: BookingsRepository, + private readonly dataSource: DataSource, + @Inject(forwardRef(() => FirstMileService)) + private readonly firstMile: FirstMileService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatch: BookingBatchService, + ) { } + + /** + * Ensure the booking has its invoice, generating one from the snapshotted + * pricing breakdown if absent. Called when a booking reaches a billable state. + * Idempotent β€” returns the existing open invoice instead of a duplicate. + * Throws `BadRequestException` when the booking is not billable: no company + * to bill (e.g. government bookings whose `companyId` is null, which the + * invoices FK requires), or no priced amount. + */ + async ensureInvoiceForBooking( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + booking.id, + "PREPAID", + ); + if (existing) return existing; + + if (!booking.companyId) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + ); + } + + const input = this.buildInput(booking, invoiceOptions); + + return this.billing.generateInvoice(input); + } + + /** + * React to a booking invoice being paid β€” the settlement branch point. Per-type + * reactions live here (not in the payment process): each invoice type advances + * the booking its own way. Only PREPAID exists today. + */ + @OnEvent("booking.invoice.paid") + async onBookingInvoicePaid(payload: InvoiceEventPayload): Promise { + this.logger.log( + `onBookingInvoicePaid trigger for ${payload.sourceId} from ${payload.invoiceId}`, + ); + switch (payload.type) { + case "PREPAID": + await this.advanceBookingOnPayment(payload.sourceId); + break; + default: + this.logger.warn( + `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, + ); + } + } + + updateStatus( + invoiceId: string, + status: Freight.InvoiceStatus, + manager?: EntityManager, + ): Promise { + return this.billing.updateStatus(invoiceId, status, manager); + } + + /** + * Advance a booking once its prepaid invoice settles β€” the domain side-effect + * of payment, relocated out of the payment service: the booking becomes PAID + * and is allocated into its batch. Idempotent β€” no-op when already PAID. + * + * General contracts are a separate aggregate now: their CONTRACT_ACTIVE + * lifecycle and ordering window live in the contracts module, advanced by the + * contract transition/clearance services β€” not by booking payment. Every + * booking that settles here is a ONE_TIME shipment, so there is no contract + * branch (legacy GENERAL_CONTRACT booking creation now 410s). + */ + private async advanceBookingOnPayment(bookingId: string): Promise { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + this.logger.warn( + `Cannot advance unknown booking ${bookingId} on payment.`, + ); + return; + } + // if (booking.paymentStatus === "PAID") return; + + await this.dataSource.transaction(async (mg) => { + await mg.update( + Booking, + { id: bookingId }, + { paymentStatus: "PAID", status: "PAID" }, + ); + }); + + try { + await this.firstMile.acceptBooking(bookingId); + } catch (err) { + this.logger.error( + `Error accepting first-mile after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + try { + await this.bookingBatch.ensurePaidBookingAllocated(bookingId); + } catch (err) { + this.logger.error( + `Error allocating booking after payment: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Map a booking's pricing snapshot into a generic invoice request. */ + private buildInput( + booking: Booking, + invoiceOptions: InvoiceOptions = {}, + ): GenerateInvoiceInput { + const breakdown = (booking.pricingBreakdown ?? + {}) as StoredPricingBreakdown; + const currency = breakdown.currency ?? booking.paymentCurrency ?? "ETB"; + + const lines: InvoiceLineInput[] = (breakdown.lineItems ?? []).map((l) => ({ + chargeType: l.code, + description: l.description, + quantity: l.quantity, + unitRate: l.unitAmount, + amount: l.amount, + currency: l.currency ?? currency, + metadata: l.unit ? { unit: l.unit } : null, + })); + + // Fall back to a single freight line when no breakdown was snapshotted. + if (lines.length === 0) { + const amount = Number(booking.totalAmount); + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException( + `Cannot generate invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + } + lines.push({ + chargeType: "FREIGHT", + description: "Rail freight", + quantity: 1, + unitRate: amount, + amount, + currency, + }); + } + + const subtotal = round2( + lines.reduce((sum, l) => sum + Number(l.amount), 0), + ); + let totalAmount = subtotal; + + // Honor a staff price override: bill the adjusted total, recording the delta + // as an ADJUSTMENT line so the lines still sum to the invoice total. + const adjusted = booking.adjustedTotalAmount; + if (adjusted != null && Number.isFinite(Number(adjusted))) { + const delta = round2(Number(adjusted) - subtotal); + if (delta !== 0) { + lines.push({ + chargeType: "ADJUSTMENT", + description: "Staff price adjustment", + quantity: 1, + unitRate: delta, + amount: delta, + currency, + }); + } + totalAmount = round2(Number(adjusted)); + } + + return { + source: Freight.InvoiceSource.Booking, + sourceId: booking.id, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency, + lines, + totalAmount, + dueAt: invoiceOptions.dueDate, + type: invoiceOptions.invoiceType ?? "PREPAID", + status: invoiceOptions.invoiceStatus ?? Freight.InvoiceStatus.Draft, + }; + } +} 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 deleted file mode 100644 index 21473eeb8..000000000 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ /dev/null @@ -1,55 +0,0 @@ -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'; -import { PaymentService } from '../payment/payment.service'; -import { PaymentStatus } from '../payment/entities/payment.entity'; -import { PaymentMethodTypeEnum } from '../payment/payments.dto'; -export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } - -const NON_TERMINAL_STATUSES: PaymentStatus[] = [ - "action-required", - "processing", - "success", -]; - -@Injectable() -export class BookingPaymentService { - constructor( - private readonly bookingsRepository: BookingsRepository, - private readonly paymentService: PaymentService, - ) { } - - async pay(bookingId: string): Promise<{ redirectUrl: string }> { - const booking = await this.requireBooking(bookingId); - assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']); - - const existing = await this.paymentService.findBookingById(bookingId); - if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) { - if (existing.clientAction) { - const action = existing.clientAction as { type?: string; url?: string }; - if (action.type === "REDIRECT" && action.url) { - return { redirectUrl: action.url }; - } - } - } - - const resp = await this.paymentService.initiatePayment({ - bookingId, - method: PaymentMethodTypeEnum.TELEBIRR, - platform: "web", - }); - - const action = resp.clientAction as { type?: string; url?: string } | undefined; - return { - redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "", - }; - } - - 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 index f9d8fb17e..c5e5b710e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -121,9 +121,18 @@ export class BookingPricingService { total += line.amount; } + // First / last mile trucking β€” billed per the rate's unit (km / container / + // ton / flat), only for legs the booking actually carries. + const { lineItems: mileLines, usedRates: mileRates } = + await this.computeFirstLastMileLines(booking, evalInput); + for (const line of mileLines) { + lineItems.push(line); + total += line.amount; + } + const liveRates = await this.ratesService.findLiveRates(); const rateById = new Map(liveRates.map((r) => [r.id, r])); - const usedRatesMap = new Map(baseRates.map((r) => [r.id, r])); + const usedRatesMap = new Map([...baseRates, ...mileRates].map((r) => [r.id, r])); for (const mod of ruleResult.appliedModifiers) { const usdAmount = mod.calculatedAmount; @@ -424,6 +433,97 @@ export class BookingPricingService { return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; } + /** + * First-mile (pick-up) and last-mile (delivery) trucking lines. Each leg is + * billed only when the booking carries that leg (an address is set) and a LIVE + * rate exists, scaled by the rate's own unit: + * PER_KM β†’ contract-route road distance (km) + * PER_CONTAINER β†’ total container count + * PER_TON β†’ total bulk tonnage + * FLAT β†’ once + * A leg whose rate value (or computed amount) is 0 contributes nothing. + */ + private async computeFirstLastMileLines( + booking: Booking, + evalInput: BookingEvaluationInput, + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> { + const legs: Array<{ rateType: 'FIRST_MILE' | 'LAST_MILE'; label: string; active: boolean }> = [ + { + rateType: 'FIRST_MILE', + label: 'First mile (pick-up)', + active: Boolean(booking.firstMilePickupAddress), + }, + { + rateType: 'LAST_MILE', + label: 'Last mile (delivery)', + active: Boolean(booking.lastMileDeliveryAddress), + }, + ]; + if (!legs.some((l) => l.active)) { + return { lineItems: [], usedRates: [] }; + } + + const liveRates = await this.ratesService.findLiveRates(); + const paymentCurrency = booking.paymentCurrency; + const isEtbBooking = paymentCurrency === 'ETB'; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; + + const containerCount = evalInput.containers.reduce( + (sum, c) => sum + Number(c.quantity || 0), + 0, + ); + const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0); + const routeKm = await this.bookingsRepository.getContractRouteKm(booking.contractRouteId); + + const lines: PriceLineItemDto[] = []; + const usedRatesMap = new Map(); + + for (const leg of legs) { + if (!leg.active) continue; + const rate = liveRates.find( + (r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE', + ); + if (!rate) continue; + + const value = Number(rate.rateValue); + let quantity: number; + switch (rate.rateUnit) { + case 'PER_KM': + quantity = routeKm; + break; + case 'PER_CONTAINER': + quantity = containerCount; + break; + case 'PER_TON': + quantity = bulkTons; + break; + case 'FLAT': + default: + quantity = 1; + break; + } + + const usdAmount = value * quantity; + // Skip legs that resolve to nothing (zero rate, or zero km / count / tons). + if (!(usdAmount > 0)) continue; + + const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount; + const unitUsd = value; + usedRatesMap.set(rate.id, rate); + lines.push({ + code: leg.rateType, + description: leg.label, + amount, + unitAmount: isEtbBooking ? Math.round(unitUsd * usdToEtb) : unitUsd, + unit: rate.rateUnit, + quantity, + currency: paymentCurrency, + }); + } + + return { lineItems: lines, usedRates: [...usedRatesMap.values()] }; + } + /** Friendly container-type label for the per-unit card; degrades to "Container". */ private async containerTypeLabel(containerTypeId: string): Promise { try { diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index e15fcba0d..6e96dc6f8 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -58,7 +58,6 @@ export function buildCargoTypeTree( id: child.id, name: child.cargoTypeName, code: child.code, - show_free_text_box: child.showFreeTextBox, unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 676d1b5d4..f9b160672 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -30,10 +30,13 @@ describe('BookingTransitionService β€” acceptIntake validity window', () => { ruleEngineService as never, {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index f9c7e182f..d62883d53 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -41,10 +41,13 @@ describe('BookingTransitionService β€” finalizeClearance gate', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, // bookingBatchService bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -122,10 +125,13 @@ describe('BookingTransitionService β€” finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository }; } @@ -189,10 +195,13 @@ describe('BookingTransitionService β€” submitClearanceDocuments required-fields {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 92ab8769e..66df02ac4 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service'; * - ACCEPT a train order β†’ FULLY_EXECUTED and enqueued into the batch pool. * - ACCEPT a road order β†’ ROAD_DISPATCH_PENDING, NOT enqueued. * - REQUEST_CHANGES requires a note β†’ OPERATION_CHANGES_REQUESTED. - * - ADJUST_PRICE sets the adjusted total β†’ OPERATION_PRICE_PENDING_CONFIRM. */ describe('BookingTransitionService β€” operation review', () => { function makeService(serviceTypeCode: string) { @@ -34,10 +33,13 @@ describe('BookingTransitionService β€” operation review', () => { {} as never, // ruleEngineService {} as never, // pricingService {} as never, // contractService + {} as never, // invoiceService {} as never, // filesService {} as never, // fileUploadSettingsService bookingBatchService as never, bookingsService as never, + { isPhasedGeneralCustomsBooking: () => false } as never, + {} as never, ); return { service, bookingsRepository, bookingBatchService }; } @@ -78,18 +80,4 @@ describe('BookingTransitionService β€” operation review', () => { expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }), ); }); - - it('ADJUST_PRICE sets the adjusted total β†’ OPERATION_PRICE_PENDING_CONFIRM', async () => { - const { service, bookingsRepository } = makeService('RAIL_CONTAINER'); - await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', { - amount: 1500, - }); - expect(bookingsRepository.update).toHaveBeenCalledWith( - 'b-1', - expect.objectContaining({ - adjustedTotalAmount: 1500, - status: 'OPERATION_PRICE_PENDING_CONFIRM', - }), - ); - }); }); 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 index d819ee527..031e521a9 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -3,13 +3,14 @@ import { forwardRef, Inject, Injectable, -} from '@nestjs/common'; -import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + Logger, +} 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 { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; -import { isRoadService } from '../booking-orders/road.util'; +import { isRoadService } from './road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -23,9 +24,17 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { BookingsService } from './bookings.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { ClearanceWorkflowService } from '../contracts/clearance-workflow.service'; +import { ContractDocPhase } from '@edr/types'; + + +import { Freight } from "@edr/types"; +import { BookingInvoiceService } from "./booking-invoice.service"; @Injectable() export class BookingTransitionService { + private readonly logger = new Logger(BookingTransitionService.name); constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, @@ -37,15 +46,25 @@ export class BookingTransitionService { private readonly bookingBatchService: BookingBatchService, @Inject(forwardRef(() => BookingsService)) private readonly bookingsService: BookingsService, + @Inject(forwardRef(() => BookingClearanceService)) + private readonly bookingClearanceService: BookingClearanceService, + @Inject(forwardRef(() => ClearanceWorkflowService)) + private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + ) {} + private isPhasedGeneralCustoms(booking: Booking): boolean { + return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']); + assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); if (Number(booking.totalAmount) <= 0) { throw new BadRequestException( - 'Generate a price before submitting (POST /bookings/:id/generate-price)', + "Generate a price before submitting (POST /bookings/:id/generate-price)", ); } @@ -64,7 +83,8 @@ export class BookingTransitionService { totalAmount?: number; } | null; const unchanged = this.pricingService.pricesMatch(stored, computed); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); if (unchanged) { await this.pricingService.createPricingSnapshots( @@ -74,7 +94,7 @@ export class BookingTransitionService { ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, } as never); @@ -104,7 +124,7 @@ export class BookingTransitionService { currency: computed.currency, generatedAt: new Date().toISOString(), }, - status: 'PRICE_CHANGED_PENDING_CONFIRM', + status: "PRICE_CHANGED_PENDING_CONFIRM", } as never); const updatedBooking = await this.bookingsService.findById(bookingId); @@ -116,16 +136,17 @@ export class BookingTransitionService { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, - message: 'Price has changed since preview. Confirm to submit with the updated price.', + message: + "Price has changed since preview. Confirm to submit with the updated price.", }; } async confirmSubmit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']); + assertBookingStatus(booking, ["PRICE_CHANGED_PENDING_CONFIRM"]); if (Number(booking.totalAmount) <= 0) { - throw new BadRequestException('No price to confirm'); + throw new BadRequestException("No price to confirm"); } const computed = await this.pricingService.computePriceForBooking(booking); @@ -144,9 +165,10 @@ export class BookingTransitionService { computed.appliedModifiers, ); - const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking); + const priorityScore = + await this.pricingService.computeSubmitPriorityScore(booking); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SUBMITTED', + status: "SUBMITTED", priorityScore, totalAmount: computed.totalAmount, pricingBreakdown: { @@ -168,7 +190,7 @@ export class BookingTransitionService { totalAmount: Number(finalBooking.totalAmount), currency: finalBooking.paymentCurrency, lineItems: computed.lineItems, - message: 'Booking submitted with confirmed price.', + message: "Booking submitted with confirmed price.", }; } @@ -178,17 +200,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); await this.bookingsRepository.createReviewNote( bookingId, note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CHANGES_REQUESTED', + status: "CHANGES_REQUESTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -198,7 +220,7 @@ export class BookingTransitionService { if ((booking.approvalSteps?.length ?? 0) > 0) return; await this.ruleEngineService.instantiateApprovalSteps(booking.id, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); } @@ -212,14 +234,14 @@ export class BookingTransitionService { // Only SUBMITTED bookings are acceptable. A booking that still needs // consolidation sits in PENDING_CONSOLIDATION (resolved at submit time) and // is therefore never offered for accept until a partner moves it to SUBMITTED. - assertBookingStatus(booking, ['SUBMITTED']); + assertBookingStatus(booking, ["SUBMITTED"]); // The backoffice must define how long the accepted contract stays valid. // Without a window the contract has no end date and cannot be relied on, so // accept is blocked until a positive number of days is supplied. if (!Number.isInteger(validityDays) || validityDays < 1) { throw new BadRequestException( - 'A contract validity (in days) is required to accept this booking.', + "A contract validity (in days) is required to accept this booking.", ); } @@ -229,12 +251,12 @@ export class BookingTransitionService { validUntil.setDate(validUntil.getDate() + validityDays); await this.ruleEngineService.instantiateApprovalSteps(bookingId, { - freightType: booking.freightType as 'CONTAINER' | 'BULK', + freightType: booking.freightType as "CONTAINER" | "BULK", cargoTypeId: booking.cargoTypeId, }); const updated = await this.bookingsRepository.update(bookingId, { - status: 'PENDING_APPROVAL', + status: "PENDING_APPROVAL", approvedByStaffId: actorId, approvedByStaffAt: validFrom, contractValidityDays: validityDays, @@ -250,17 +272,17 @@ export class BookingTransitionService { actorId: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']); + assertBookingStatus(booking, ["SUBMITTED", "PENDING_APPROVAL"]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } @@ -278,8 +300,8 @@ export class BookingTransitionService { let booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'PENDING_APPROVAL', - 'APPROVED_PENDING_SIGNATURE', + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", ]); if ((booking.approvalSteps?.length ?? 0) === 0) { @@ -291,14 +313,17 @@ export class BookingTransitionService { bookingId, stepId, ); - if (!step || step.status !== 'PENDING') { - throw new BadRequestException('Approval step not found or already actioned'); + if (!step || step.status !== "PENDING") { + throw new BadRequestException( + "Approval step not found or already actioned", + ); } - const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId); + const next = + await this.bookingsRepository.findNextPendingApprovalStep(bookingId); if (!next || next.id !== step.id) { throw new BadRequestException( - 'Approval steps must be completed in order', + "Approval steps must be completed in order", ); } @@ -310,29 +335,36 @@ export class BookingTransitionService { const blocksRole = step.blocksRole; if (blocksRole && blocksRole === requiredRole) { - throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + throw new BadRequestException( + `Role ${requiredRole} is blocked for this step`, + ); } - await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + await this.bookingsRepository.completeApprovalStep( + step.id, + actorId, + "APPROVED", + ); const updates: Record = {}; const now = new Date(); - if (requiredRole === 'LINE_STAFF') { - updates.status = 'APPROVED_PENDING_SIGNATURE'; + if (requiredRole === "LINE_STAFF") { + updates.status = "APPROVED_PENDING_SIGNATURE"; updates.approvedByStaffId = actorId; updates.approvedByStaffAt = now; - } else if (requiredRole === 'DIRECTOR') { + } else if (requiredRole === "DIRECTOR") { updates.signedByDirectorId = actorId; updates.signedByDirectorAt = now; - } else if (requiredRole === 'CEO') { + } else if (requiredRole === "CEO") { updates.signedByCeoId = actorId; updates.signedByCeoAt = now; } - const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId); + const allDone = + await this.bookingsRepository.allApprovalStepsComplete(bookingId); if (allDone) { - updates.status = 'APPROVED'; + updates.status = "APPROVED"; } if (Object.keys(updates).length > 0) { @@ -354,75 +386,64 @@ export class BookingTransitionService { reason: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + assertBookingStatus(booking, [ + "PENDING_APPROVAL", + "APPROVED_PENDING_SIGNATURE", + ]); const step = await this.bookingsRepository.findApprovalStepById( bookingId, stepId, ); - if (!step) throw new BadRequestException('Approval step not found'); + if (!step) throw new BadRequestException("Approval step not found"); await this.bookingsRepository.completeApprovalStep( step.id, actorId, - 'REJECTED', + "REJECTED", reason, ); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", actorId, ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + 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']); + assertBookingStatus(booking, ["CONTRACT_READY"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'SIGNED_CUSTOMER', + 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']); + assertBookingStatus(booking, ["PAID"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'IN_TRANSIT', + 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']); + assertBookingStatus(booking, ["IN_TRANSIT"]); const updated = await this.bookingsRepository.update(bookingId, { - status: 'COMPLETED', + status: "COMPLETED", endDate: new Date(), } as never); return this.bookingsService.findById(updated!.id); @@ -431,22 +452,23 @@ export class BookingTransitionService { async cancel(bookingId: string, reason: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'CHANGES_REQUESTED', - 'PENDING_APPROVAL', - 'CONTRACT_READY', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "CHANGES_REQUESTED", + "PENDING_APPROVAL", + "CONTRACT_READY", + "OPERATION_REQUEST_PENDING", ]); await this.bookingsRepository.createReviewNote( bookingId, reason, - 'REJECTION', + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'CANCELLED', + status: "CANCELLED", } as never); return this.bookingsService.findById(updated!.id); } @@ -459,48 +481,24 @@ export class BookingTransitionService { async reject(bookingId: string, reason?: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, [ - 'DRAFT', - 'SUBMITTED', - 'PRICE_CHANGED_PENDING_CONFIRM', - 'PENDING_CONSOLIDATION', + "DRAFT", + "SUBMITTED", + "PRICE_CHANGED_PENDING_CONFIRM", + "PENDING_CONSOLIDATION", ]); await this.bookingsRepository.createReviewNote( bookingId, - reason?.trim() || 'Customer rejected the price estimate.', - 'REJECTION', + reason?.trim() || "Customer rejected the price estimate.", + "REJECTION", ); const updated = await this.bookingsRepository.update(bookingId, { - status: 'REJECTED', + status: "REJECTED", } as never); return this.bookingsService.findById(updated!.id); } - /** - * Staff adjusts a booking's total price. Stores an override (with who/when/why) - * that supersedes the computed total for the customer, who sees an - * "Adjusted by EDR" badge. Passing null clears the adjustment. - */ - async adjustPrice( - bookingId: string, - amount: number | null, - staffId: string, - reason?: string, - ): Promise { - await this.bookingsService.findById(bookingId); - if (amount != null && amount < 0) { - throw new BadRequestException('Adjusted amount cannot be negative'); - } - await this.bookingsRepository.update(bookingId, { - adjustedTotalAmount: amount, - adjustedByStaffId: amount == null ? null : staffId, - adjustedAt: amount == null ? null : new Date(), - adjustmentReason: amount == null ? null : (reason ?? null), - } as never); - return this.bookingsService.findById(bookingId); - } - // ── Document clearance gate (post counter-sign) ─────────────────────────── /** @@ -517,32 +515,44 @@ export class BookingTransitionService { fileKey: string; label: string; required: boolean; - uploadedBy: 'customer' | 'gl'; + uploadedBy: "customer" | "gl"; settingCode: string; file: { id: string; name: string; url: string } | null; - reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + reviewStatus: "PENDING" | "APPROVED" | "QUERIED" | null; note: string | null; }>; allApproved: boolean; + phase?: string | null; + milestones?: unknown[]; + nextAction?: unknown; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + operationReady?: boolean; }> { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + return this.bookingClearanceService.getClearanceView(bookingId); + } const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const files = await this.filesService.findByResource(bookingId, "bookings"); const fileByCode = new Map(files.map((f) => [f.code, f])); - const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviews = + await this.bookingsRepository.findDocumentReviews(bookingId); const reviewByKey = new Map( reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]), ); const documents: Awaited< - ReturnType - >['documents'] = []; + ReturnType + >["documents"] = []; const pushSetting = async ( code: string | null, - uploadedBy: 'customer' | 'gl', + uploadedBy: "customer" | "gl", ) => { if (!code) return; let setting; @@ -560,28 +570,26 @@ export class BookingTransitionService { required: field.isRequired, uploadedBy, settingCode: code, - file: file - ? { id: file.id, name: file.name, url: file.url } - : null, + file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, }); } }; - await pushSetting(inputCode, 'customer'); - await pushSetting(outputCode, 'gl'); + await pushSetting(inputCode, "customer"); + await pushSetting(outputCode, "gl"); // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. for (const f of files) { - if (!f.code?.startsWith('custom_')) continue; + if (!f.code?.startsWith("custom_")) continue; const review = reviewByKey.get(`custom:${f.code}`) ?? null; documents.push({ fileKey: f.code, label: f.name, required: false, - uploadedBy: 'customer', - settingCode: 'custom', + uploadedBy: "customer", + settingCode: "custom", file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, @@ -615,13 +623,15 @@ export class BookingTransitionService { } const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return true; - const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + const reviews = await this.bookingsRepository.findDocumentReviews( + booking.id, + ); return required.every((field) => reviews.some( (r) => r.settingCode === inputCode && r.fileKey === field.fileKey && - r.status === 'APPROVED', + r.status === "APPROVED", ), ); } @@ -636,33 +646,38 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, [ + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + ]); const { inputCode } = clearanceCodesForBooking(booking); if (!inputCode) { - throw new BadRequestException('This booking has no document-clearance step'); + throw new BadRequestException( + "This booking has no document-clearance step", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } // First submission (nothing in review yet): every required input field must // be provided. Once review has started (DOCUMENTS_UNDER_REVIEW) the customer // is only fixing queried/pending docs, so the already-uploaded required docs // stay in place and we don't re-gate on the full required set. - if (booking.status === 'AWAITING_DOCUMENTS') { + if (booking.status === "AWAITING_DOCUMENTS") { await this.assertRequiredInputsPresent(bookingId, inputCode, files); } for (const file of files) { const record = await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); // Ad-hoc docs (custom_*) are not part of the required gate; still tracked. - const settingCode = file.fieldname.startsWith('custom_') - ? 'custom' + const settingCode = file.fieldname.startsWith("custom_") + ? "custom" : inputCode; await this.bookingsRepository.upsertDocumentReviewPending({ bookingId, @@ -673,8 +688,20 @@ export class BookingTransitionService { } await this.bookingsRepository.update(bookingId, { - status: 'DOCUMENTS_UNDER_REVIEW', + status: "DOCUMENTS_UNDER_REVIEW", } as never); + + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onCustomerDocsUploadedForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } + return this.bookingsService.findById(bookingId); } @@ -698,7 +725,10 @@ export class BookingTransitionService { const required = (setting.fields ?? []).filter((f) => f.isRequired); if (required.length === 0) return; - const existing = await this.filesService.findByResource(bookingId, 'bookings'); + const existing = await this.filesService.findByResource( + bookingId, + "bookings", + ); const presentKeys = new Set([ ...existing.map((f) => f.code), ...files.map((f) => f.fieldname), @@ -706,7 +736,7 @@ export class BookingTransitionService { const missing = required.filter((f) => !presentKeys.has(f.fileKey)); if (missing.length > 0) { - const labels = missing.map((f) => f.fileLabel).join(', '); + const labels = missing.map((f) => f.fileLabel).join(", "); throw new BadRequestException( `Please upload all required documents before submitting: ${labels}`, ); @@ -717,22 +747,36 @@ export class BookingTransitionService { async reviewDocument( bookingId: string, fileKey: string, - status: 'APPROVED' | 'QUERIED', + status: "APPROVED" | "QUERIED", staffId: string, note?: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { inputCode, outputCode } = clearanceCodesForBooking(booking); - const existing = await this.bookingsRepository.findDocumentReviews(bookingId); + const existing = + await this.bookingsRepository.findDocumentReviews(bookingId); const match = existing.find((r) => r.fileKey === fileKey); const settingCode = match?.settingCode ?? - (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + (fileKey.startsWith("custom_") + ? "custom" + : (inputCode ?? outputCode ?? "custom")); - if (status === 'QUERIED' && !note?.trim()) { - throw new BadRequestException('A note is required when querying a document'); + if (status === "QUERIED" && !note?.trim()) { + throw new BadRequestException( + "A note is required when querying a document", + ); + } + if ( + status === 'QUERIED' && + this.isPhasedGeneralCustoms(booking) && + booking.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); } await this.bookingsRepository.setDocumentReviewStatus( @@ -743,15 +787,37 @@ export class BookingTransitionService { staffId, note, ); - if (status === 'QUERIED') { + if (status === "QUERIED") { await this.bookingsRepository.createReviewNote( bookingId, `Document "${fileKey}" queried: ${note}`, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", staffId, ); + if (this.isPhasedGeneralCustoms(booking)) { + await this.workflowService.onDocumentReviewReopenedForBooking(bookingId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtReview, + } as never); + } } - return this.bookingsService.findById(bookingId); + + const updated = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(updated)) { + const allApproved = await this.isClearanceFullyApproved(updated); + if (allApproved) { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + const phase = + updated.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput; + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: phase, + } as never); + } + } + + return updated; } /** GL uploads the customs output documents (IM4/IM5/EX3/etc.). */ @@ -760,18 +826,20 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); + assertBookingStatus(booking, ["DOCUMENTS_UNDER_REVIEW"]); const { outputCode } = clearanceCodesForBooking(booking); if (!outputCode) { - throw new BadRequestException('This booking has no customs output documents'); + throw new BadRequestException( + "This booking has no customs output documents", + ); } if (files.length === 0) { - throw new BadRequestException('No documents uploaded'); + throw new BadRequestException("No documents uploaded"); } for (const file of files) { await this.filesService.upsertByCode({ resourceId: bookingId, - resource: 'bookings', + resource: "bookings", code: file.fieldname, file, }); @@ -785,19 +853,28 @@ export class BookingTransitionService { */ async finalizeClearance(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); + if (this.isPhasedGeneralCustoms(booking)) { + throw new BadRequestException( + 'General customs bookings use phased clearance β€” complete milestones via the phased actions instead of finalize.', + ); + } assertBookingStatus(booking, ['DOCUMENTS_UNDER_REVIEW']); const approved = await this.isClearanceFullyApproved(booking); if (!approved) { throw new BadRequestException( - 'All required documents must be approved before clearance can be finalized', + "All required documents must be approved before clearance can be finalized", ); } const { outputCode } = clearanceCodesForBooking(booking); if (outputCode) { - const setting = await this.fileUploadSettingsService.getByCode(outputCode); - const files = await this.filesService.findByResource(bookingId, 'bookings'); + const setting = + await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource( + bookingId, + "bookings", + ); const uploaded = new Set(files.map((f) => f.code)); const missing = (setting.fields ?? []).filter( (f) => f.isRequired && !uploaded.has(f.fileKey), @@ -806,13 +883,13 @@ export class BookingTransitionService { throw new BadRequestException( `Upload all required customs output documents first: ${missing .map((m) => m.fileLabel) - .join(', ')}`, + .join(", ")}`, ); } } await this.bookingsRepository.update(bookingId, { - status: 'CLEARANCE_READY', + status: "CLEARANCE_READY", } as never); return this.bookingsService.findById(bookingId); } @@ -831,11 +908,14 @@ export class BookingTransitionService { scheduledDate: string, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED']); + assertBookingStatus(booking, [ + "CLEARANCE_READY", + "OPERATION_CHANGES_REQUESTED", + ]); const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { - throw new BadRequestException('A valid schedule date is required'); + throw new BadRequestException("A valid schedule date is required"); } // The binding shipment day must have at least one OPEN departure on the @@ -848,12 +928,12 @@ export class BookingTransitionService { ); if (!hasDeparture) { throw new BadRequestException( - 'No departures available on the selected day for this route', + "No departures available on the selected day for this route", ); } await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_REQUEST_PENDING', + status: "OPERATION_REQUEST_PENDING", scheduledDate: date, } as never); return this.bookingsService.findById(bookingId); @@ -861,51 +941,35 @@ export class BookingTransitionService { /** * Operations team reviews a pending operation request (capacity, documents, - * route). Three outcomes: + * route). Two outcomes: * - ACCEPT β†’ booking enters the batch holding pool (FULLY_EXECUTED). * - REQUEST_CHANGES β†’ returned to the customer with a note to fix and resubmit. - * - ADJUST_PRICE β†’ a new total is set; the customer must re-confirm it - * before the booking can enter the pool. + * + * The booking price is computed from the contract and is never adjusted here. */ async reviewOperationRequest( bookingId: string, - decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE', + decision: "ACCEPT" | "REQUEST_CHANGES", actorId: string, - options: { note?: string; amount?: number } = {}, + options: { note?: string } = {}, ): Promise { const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']); + assertBookingStatus(booking, ["OPERATION_REQUEST_PENDING"]); - if (decision === 'REQUEST_CHANGES') { + if (decision === "REQUEST_CHANGES") { if (!options.note?.trim()) { throw new BadRequestException( - 'A note is required when requesting changes', + "A note is required when requesting changes", ); } await this.bookingsRepository.createReviewNote( bookingId, options.note, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", actorId, ); await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', - } as never); - return this.bookingsService.findById(bookingId); - } - - if (decision === 'ADJUST_PRICE') { - if (options.amount == null || options.amount < 0) { - throw new BadRequestException( - 'A non-negative adjusted amount is required to adjust the price', - ); - } - await this.bookingsRepository.update(bookingId, { - adjustedTotalAmount: options.amount, - adjustedByStaffId: actorId, - adjustedAt: new Date(), - adjustmentReason: options.note ?? null, - status: 'OPERATION_PRICE_PENDING_CONFIRM', + status: "OPERATION_CHANGES_REQUESTED", } as never); return this.bookingsService.findById(bookingId); } @@ -914,28 +978,6 @@ export class BookingTransitionService { return this.acceptOperationRequest(booking); } - /** - * Customer re-confirms (or rejects) an operations price adjustment. Accepting - * pushes the booking into the pool; rejecting returns it to the customer as an - * operation change request so they can resubmit or cancel. - */ - async confirmOperationPrice( - bookingId: string, - accept: boolean, - ): Promise { - const booking = await this.bookingsService.findById(bookingId); - assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']); - - if (!accept) { - await this.bookingsRepository.update(bookingId, { - status: 'OPERATION_CHANGES_REQUESTED', - } as never); - return this.bookingsService.findById(bookingId); - } - - return this.acceptOperationRequest(booking); - } - /** * Move a reviewed operation request forward after Marketing accepts. * @@ -949,9 +991,26 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity β€” nothing below runs and the request stays + // pending for staff to move/decline. + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + const exportScheduleId = isExportTrain + ? await this.bookingBatchService.pickExportSchedule(booking) + : null; + + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); + this.logger.log( + `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, + ); + await this.invoiceService.updateStatus( + invoice.id, + Freight.InvoiceStatus.Pending, + ); if (isRoadService(booking.serviceType)) { await this.bookingsRepository.update(booking.id, { - status: 'ROAD_DISPATCH_PENDING', + status: "ROAD_DISPATCH_PENDING", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); @@ -959,12 +1018,20 @@ export class BookingTransitionService { } await this.bookingsRepository.update(booking.id, { - status: 'FULLY_EXECUTED', + status: "FULLY_EXECUTED", fullyExecutedAt: now, lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { + if (exportScheduleId) { + // FCFS: reserve the slot and send the payment notification immediately; + // paid β†’ auto-allocated by the settle/paid pipeline. + const fresh = await this.bookingsService.findById(booking.id); + await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId); + } else if (booking.tradeDirection === "IMPORT") { + // Import bookings wait for their booking-day window cycle β€” the batch runs + // after staff document review, never at accept time. + } else if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, @@ -974,29 +1041,42 @@ export class BookingTransitionService { return this.bookingsService.findById(booking.id); } - async enrichBookingResponse(booking: Booking): Promise { + async enrichBookingResponse(booking: Booking): Promise< + Booking & { + latestChangeRequestNote?: string | null; + contractSummary?: string | null; + nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; + } + > { const note = await this.bookingsRepository.findLatestReviewNote( booking.id, - 'CHANGES_REQUESTED', + "CHANGES_REQUESTED", ); const summary = booking.contractSummary ?? this.contractService.buildContractSummary(booking); const nextPending = - booking.status === 'PENDING_APPROVAL' || - booking.status === 'APPROVED_PENDING_SIGNATURE' + booking.status === "PENDING_APPROVAL" || + booking.status === "APPROVED_PENDING_SIGNATURE" ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); + const activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, }; } -} \ No newline at end of file +} 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 331706c5a..c5574377a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -12,6 +12,7 @@ import { Request, Res, UnauthorizedException, + UploadedFile, UploadedFiles, UseInterceptors, } from '@nestjs/common'; @@ -19,7 +20,7 @@ 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 { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, @@ -27,12 +28,17 @@ import { ApiOkResponse, ApiOperation, ApiTags, -} from '@nestjs/swagger'; -import type { Response } from 'express'; +} from "@nestjs/swagger"; +import type { Response } from "express"; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingTransitionService } from './booking-transition.service'; +import { BookingClearanceService } from '../contracts/booking-clearance.service'; +import { + AdviseContractDutyDto, + RoAmendmentDto, +} from '../contracts/dto/phased-clearance.dto'; import { BookingReferenceDataService } from './booking-reference-data.service'; import { BookingsService } from './bookings.service'; import { BookingReferenceDataDto } from './dto/booking-reference-data.dto'; @@ -43,7 +49,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, - AdjustPriceDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, @@ -52,20 +57,23 @@ import { ReviewDocumentDto, RequestOperationDto, OperationReviewDto, - ConfirmOperationPriceDto, StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.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'; -import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; +} from "../../common/resolve-auth-user-id"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; -@ApiTags('bookings') -@Controller('bookings') +@ApiTags("bookings") +@Controller("bookings") @ApiBearerAuth() export class BookingsController { constructor( @@ -74,12 +82,13 @@ export class BookingsController { private readonly pricingService: BookingPricingService, private readonly transitionService: BookingTransitionService, private readonly contractService: BookingContractService, + private readonly bookingClearanceService: BookingClearanceService, ) {} @Post() @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Create a new freight booking (DRAFT)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Create a new freight booking (DRAFT)" }) @ApiBody({ type: CreateBookingDto }) async create( @Body() dto: CreateBookingDto, @@ -89,15 +98,24 @@ export class BookingsController { if (dto.isGovernment) { assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); } - const result = await this.bookingsService.create(dto, files ?? [], user?.id); + const result = await this.bookingsService.create( + dto, + files ?? [], + user?.id, + ); // Staff-created commercial bookings skip the draft stage: auto generate-price + submit. - const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept); + const isStaff = hasFreightPermission( + user, + FREIGHT_PERMS.bookings.staffAccept, + ); if (isStaff && !dto.isGovernment) { try { await this.pricingService.generatePrice(result.booking.id); await this.transitionService.submit(result.booking.id); - const submitted = await this.bookingsService.findById(result.booking.id); + const submitted = await this.bookingsService.findById( + result.booking.id, + ); return { booking: submitted, warnings: result.warnings }; } catch { // If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually. @@ -107,16 +125,16 @@ export class BookingsController { return result; } - @Patch(':id') + @Patch(":id") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Update booking', - description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + 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[], ) { @@ -124,7 +142,7 @@ export class BookingsController { } @Get() - @ApiOperation({ summary: 'List freight bookings (paginated)' }) + @ApiOperation({ summary: "List freight bookings (paginated)" }) async findAll( @Query() filter: FilterBookingDto, @CurrentUser() user: TCurrentUser, @@ -141,7 +159,7 @@ export class BookingsController { return this.bookingsService.findClearanceQueue(filter); } const userId = user?.id; - if (!userId) throw new UnauthorizedException('Authentication required'); + if (!userId) throw new UnauthorizedException("Authentication required"); const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet β†’ no bookings to show (avoids leaking all bookings). @@ -167,27 +185,29 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } - @Get('by-company/:companyId/customer-view') - @ApiOperation({ summary: 'List bookings for a company (customer-view shape, backoffice)' }) + @Get("by-company/:companyId/customer-view") + @ApiOperation({ + summary: "List bookings for a company (customer-view shape, backoffice)", + }) findByCompanyCustomerView( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ) { return this.bookingsService.findCustomerBookings(companyId); } - @Get('list-summary') - @ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' }) + @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('my') + @Get("my") @ApiOperation({ summary: "List the current customer's bookings ready for payment", description: - 'Bookings owned by the authenticated user\'s company that are payable ' + - '(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.', + "Bookings owned by the authenticated user's company that are payable " + + "(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.", }) findMyPayable( @CurrentUser() user: AuthUserPayload, @@ -196,32 +216,32 @@ export class BookingsController { return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter); } - @Get('queues/:queue') + @Get("queues/:queue") @ApiOperation({ - summary: 'List bookings for a dashboard queue', - description: 'Queues: intake, approval, signatures, marketing, finance', + summary: "List bookings for a dashboard queue", + description: "Queues: intake, approval, signatures, marketing, finance", }) findQueue( - @Param('queue') queue: string, + @Param("queue") queue: string, @Query() filter: FilterBookingDto, - @Query('excludeBulk') excludeBulk?: string, + @Query("excludeBulk") excludeBulk?: string, ) { return this.bookingsService.findQueue(queue, filter, { - excludeBulk: excludeBulk === 'true', + excludeBulk: excludeBulk === "true", }); } - @Get('reference-data') - @ApiOperation({ summary: 'Booking form catalog' }) + @Get("reference-data") + @ApiOperation({ summary: "Booking form catalog" }) @ApiOkResponse({ type: BookingReferenceDataDto }) getReferenceData(): Promise { return this.bookingReferenceDataService.getReferenceData(); } - @Get('by-reference/:reference') - @ApiOperation({ summary: 'Get booking by reference' }) + @Get("by-reference/:reference") + @ApiOperation({ summary: "Get booking by reference" }) async findByReference( - @Param('reference') reference: string, + @Param("reference") reference: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findByReference(reference); @@ -235,10 +255,10 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id') - @ApiOperation({ summary: 'Get booking by ID' }) + @Get(":id") + @ApiOperation({ summary: "Get booking by ID" }) async findOne( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -256,15 +276,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/customer-truck-assignment') + @ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' }) + async assignCustomerTruck( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CustomerTruckAssignmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const assigned = await this.bookingsService.assignCustomerTruck(id, dto); + return this.transitionService.enrichBookingResponse(assigned); + } + + @Get(':id/customer-truck-assignment/freight-order') + @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + async customerTruckFreightOrder( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + const { filename, buffer } = + await this.bookingsService.customerTruckFreightOrderCopies(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(buffer); + } + @Get(':id/tracking') @ApiOperation({ - summary: 'Shipment tracking timeline for a booking', + summary: "Shipment tracking timeline for a booking", description: "Returns the booking's consignment (once dispatched) and its ordered " + - 'tracking events. Scoped to the customer\'s own company.', + "tracking events. Scoped to the customer's own company.", }) async findTracking( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingsService.findById(id); @@ -278,66 +331,66 @@ export class BookingsController { return this.bookingsService.getBookingTracking(id); } - @Delete(':id') + @Delete(":id") @HttpCode(204) - @ApiOperation({ summary: 'Soft-delete DRAFT booking' }) - remove(@Param('id', ParseUUIDPipe) id: string) { + @ApiOperation({ summary: "Soft-delete DRAFT booking" }) + remove(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.remove(id); } - @Post(':id/documents') + @Post(":id/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a booking (DRAFT only)" }) async uploadDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.bookingsService.uploadDocuments(id, files ?? []); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/generate-price') + @Post(":id/generate-price") @ApiOperation({ - summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)', + summary: "Generate price preview (DRAFT or CHANGES_REQUESTED)", description: - 'Computes and stores a price preview on the booking. Does not create rate snapshots.', + "Computes and stores a price preview on the booking. Does not create rate snapshots.", }) @ApiOkResponse({ type: GeneratePriceResponseDto }) - generatePrice(@Param('id', ParseUUIDPipe) id: string) { + generatePrice(@Param("id", ParseUUIDPipe) id: string) { return this.pricingService.generatePrice(id); } - @Post(':id/submit') + @Post(":id/submit") @ApiOperation({ - summary: 'Customer submit booking', + summary: "Customer submit booking", description: - 'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.', + "Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - submit(@Param('id', ParseUUIDPipe) id: string) { + submit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.submit(id); } - @Post(':id/confirm-submit') + @Post(":id/confirm-submit") @ApiOperation({ - summary: 'Confirm submit after price change', + summary: "Confirm submit after price change", description: - 'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.', + "Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.", }) @ApiOkResponse({ type: SubmitBookingResponseDto }) - confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + confirmSubmit(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.confirmSubmit(id); } - @Post(':id/reject') + @Post(":id/reject") @ApiOperation({ - summary: 'Customer reject price estimate', + summary: "Customer reject price estimate", description: - 'Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.', + "Customer rejects the priced booking at the confirm step. The booking becomes REJECTED (terminal); the customer must create a new booking.", }) async reject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RejectBookingDto, ) { const booking = await this.transitionService.reject(id, dto.reason); @@ -346,22 +399,37 @@ export class BookingsController { // ── Document clearance (post counter-sign) ──────────────────────────────── + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET queue β€” general customs bookings awaiting ET action' }) + getBookingEtClearanceQueue() { + return this.bookingClearanceService.etQueue(); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ queue β€” general customs bookings awaiting DJ action' }) + getBookingDjClearanceQueue() { + return this.bookingClearanceService.djQueue(); + } + @Get(':id/clearance') @ApiOperation({ - summary: 'Document-clearance grid (required docs + upload + GL review status)', + summary: + "Document-clearance grid (required docs + upload + GL review status)", }) - getClearance(@Param('id', ParseUUIDPipe) id: string) { + getClearance(@Param("id", ParseUUIDPipe) id: string) { return this.transitionService.getClearanceView(id); } - @Post(':id/clearance/documents') + @Post(":id/clearance/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') + @ApiConsumes("multipart/form-data") @ApiOperation({ - summary: 'Customer uploads clearance documents (fieldname = document key)', + summary: "Customer uploads clearance documents (fieldname = document key)", }) async submitClearanceDocuments( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.submitClearanceDocuments( @@ -371,14 +439,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/proceed') + @Post(":id/clearance/proceed") @ApiOperation({ summary: - 'Customer requests operation with a schedule day ' + - '(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED β†’ OPERATION_REQUEST_PENDING)', + "Customer requests operation with a schedule day " + + "(CLEARANCE_READY | OPERATION_CHANGES_REQUESTED β†’ OPERATION_REQUEST_PENDING)", }) async proceedToOperation( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestOperationDto, ) { const booking = await this.transitionService.requestOperation( @@ -388,15 +456,15 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/review') + @Post(":id/operation/review") @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: - 'Operations reviews an operation request: ACCEPT (β†’ batch pool), ' + - 'REQUEST_CHANGES (β†’ back to customer), or ADJUST_PRICE (β†’ customer re-confirm)', + "Operations reviews an operation request: ACCEPT (β†’ batch pool), " + + "REQUEST_CHANGES (β†’ back to customer), or ADJUST_PRICE (β†’ customer re-confirm)", }) async reviewOperationRequest( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: OperationReviewDto, @CurrentUser() user: AuthUserPayload, ) { @@ -404,33 +472,18 @@ export class BookingsController { id, dto.decision, resolveAuthUserId(user), - { note: dto.note, amount: dto.amount }, + { note: dto.note }, ); return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operation/confirm-price') - @ApiOperation({ - summary: - 'Customer confirms or rejects an operations price adjustment ' + - '(OPERATION_PRICE_PENDING_CONFIRM β†’ batch pool | OPERATION_CHANGES_REQUESTED)', - }) - async confirmOperationPrice( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: ConfirmOperationPriceDto, - ) { - const booking = await this.transitionService.confirmOperationPrice( - id, - dto.accept, - ); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(':id/clearance/review') + @Post(":id/clearance/review") @BookingStaff(FREIGHT_PERMS.bookings.reviewDocuments) - @ApiOperation({ summary: 'GL reviews a clearance document (Approve | Query)' }) + @ApiOperation({ + summary: "GL reviews a clearance document (Approve | Query)", + }) async reviewClearanceDocument( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: ReviewDocumentDto, @CurrentUser() user: AuthUserPayload, ) { @@ -444,13 +497,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/output-documents') + @Post(":id/clearance/output-documents") @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "GL uploads customs output documents (IM4/EX3/…)" }) async uploadClearanceOutput( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @UploadedFiles() files: Express.Multer.File[], ) { const booking = await this.transitionService.uploadClearanceOutputDocuments( @@ -460,21 +513,178 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/clearance/finalize') + @Post(":id/clearance/finalize") @BookingStaff(FREIGHT_PERMS.bookings.finalizeClearance) @ApiOperation({ - summary: 'GL finalizes clearance (requires 100% approved) β†’ CLEARANCE_READY', + summary: + "GL finalizes clearance (requires 100% approved) β†’ CLEARANCE_READY", }) - async finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + async finalizeClearance(@Param("id", ParseUUIDPipe) id: string) { const booking = await this.transitionService.finalizeClearance(id); return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration on booking (GENERAL customs)' }) + async uploadBookingDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeclaration( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax on booking with notice attachment' }) + async adviseBookingDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + const booking = await this.bookingClearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance on booking' }) + async finalizeBookingPreClearance(@Param('id', ParseUUIDPipe) id: string) { + const booking = await this.bookingClearanceService.finalizePreClearance(id); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on booking' }) + async uploadBookingDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + const booking = await this.bookingClearanceService.uploadDutySlip(id, file); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + async uploadBookingTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadTransitPermit( + id, + files ?? [], + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + async uploadBookingReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: TCurrentUser, + ) { + const result = await this.bookingClearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + return { + ...this.transitionService.enrichBookingResponse(result.booking), + hold: result.hold, + holdReason: result.holdReason, + }; + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + async requestBookingRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.requestRoAmendment( + id, + dto.note, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + async confirmBookingExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingClearanceService.confirmExportRelease( + id, + resolveAuthUserId(user), + ); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.bookings.requestChanges) - @ApiOperation({ summary: 'Staff return booking for customer updates' }) + @ApiOperation({ summary: "Staff return booking for customer updates" }) async requestChanges( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, @CurrentUser() user: AuthUserPayload, ) { @@ -486,14 +696,14 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/accept') + @Post(":id/staff/accept") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ summary: - 'Staff accept intake β†’ set contract validity window + start approval chain', + "Staff accept intake β†’ set contract validity window + start approval chain", }) async acceptIntake( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: AcceptIntakeDto, @CurrentUser() user: AuthUserPayload, ) { @@ -505,11 +715,11 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/staff/reject') + @Post(":id/staff/reject") @BookingStaff(FREIGHT_PERMS.bookings.reject) - @ApiOperation({ summary: 'Staff final reject' }) + @ApiOperation({ summary: "Staff final reject" }) async staffReject( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: StaffRejectDto, @CurrentUser() user: AuthUserPayload, ) { @@ -521,30 +731,13 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/adjust-price') + @Post(":id/government-expedite") @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) @ApiOperation({ - summary: 'Staff adjust booking total price (override; null clears it)', + summary: "Expedite government booking to PAID / ELIGIBLE for scheduling", }) - async adjustPrice( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: AdjustPriceDto, - @CurrentUser() user: AuthUserPayload, - ) { - const booking = await this.transitionService.adjustPrice( - id, - dto.amount ?? null, - resolveAuthUserId(user), - dto.reason, - ); - return this.transitionService.enrichBookingResponse(booking); - } - - @Post(':id/government-expedite') - @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' }) async governmentExpedite( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @CurrentUser() user: AuthUserPayload, ) { const booking = await this.bookingsService.governmentExpedite( @@ -554,16 +747,16 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/approve') + @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' }) + @ApiOperation({ summary: "Approve one approval step in sequence" }) async approveStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: ApproveStepDto, @CurrentUser() user: TCurrentUser, ) { @@ -577,12 +770,12 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/approval-steps/:stepId/reject') + @Post(":id/approval-steps/:stepId/reject") @BookingStaff(FREIGHT_PERMS.bookings.rejectApproval) - @ApiOperation({ summary: 'Reject at approval step' }) + @ApiOperation({ summary: "Reject at approval step" }) async rejectStep( - @Param('id', ParseUUIDPipe) id: string, - @Param('stepId', ParseUUIDPipe) stepId: string, + @Param("id", ParseUUIDPipe) id: string, + @Param("stepId", ParseUUIDPipe) stepId: string, @Body() dto: RejectStepDto, @CurrentUser() user: AuthUserPayload, ) { @@ -595,53 +788,53 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/contract/generate') + @Post(":id/contract/generate") @BookingStaff(FREIGHT_PERMS.bookings.generateContract) - @ApiOperation({ summary: 'Generate contract PDF from template' }) - async generateContract(@Param('id', ParseUUIDPipe) id: string) { + @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') + @Get(":id/contract/view") @ApiOkResponse({ type: ContractViewDto }) - @ApiOperation({ summary: 'Contract HTML view for portal and backoffice' }) + @ApiOperation({ summary: "Contract HTML view for portal and backoffice" }) getContractView( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Request() req: { user?: { id?: string; sub?: string } }, ) { const userId = req.user?.id ?? req.user?.sub; return this.contractService.getContractView(id, userId); } - @Get(':id/contract/document') - @ApiOperation({ summary: 'Download contract PDF' }) + @Get(":id/contract/document") + @ApiOperation({ summary: "Download contract PDF" }) async downloadContractDocument( - @Param('id', ParseUUIDPipe) id: string, + @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-Type", record.mimeType ?? "application/pdf"); res.setHeader( - 'Content-Disposition', + "Content-Disposition", `attachment; filename="${record.name}"`, ); stream.pipe(res); } - @Get(':id/contract') - @ApiOperation({ summary: 'Download contract file (alias)' }) + @Get(":id/contract") + @ApiOperation({ summary: "Download contract file (alias)" }) async downloadContract( - @Param('id', ParseUUIDPipe) id: string, + @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)' }) + @Post(":id/contract/sign") + @ApiOperation({ summary: "Apply digital signature (customer or staff)" }) async signContract( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { @@ -653,28 +846,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Get(':id/contract/signatures') - @ApiOperation({ summary: 'List contract signatures' }) - getContractSignatures(@Param('id', ParseUUIDPipe) id: string) { + @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) { + @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') + @Post(":id/customer/sign") @ApiOperation({ - summary: 'Customer digital signature (deprecated β€” use POST contract/sign)', + summary: "Customer digital signature (deprecated β€” use POST contract/sign)", }) async customerSign( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @Request() req: { user?: { id?: string; sub?: string }; ip?: string }, ) { - const payload: SignContractDto = { ...dto, role: 'CUSTOMER' }; + const payload: SignContractDto = { ...dto, role: "CUSTOMER" }; const booking = await this.contractService.signContract(id, payload, { signerUserId: req.user?.id ?? req.user?.sub, ipAddress: req.ip, @@ -682,20 +875,21 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/marketing/approve') + @Post(":id/marketing/approve") @BookingStaff(FREIGHT_PERMS.bookings.signStaff) @ApiOperation({ - summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)', + summary: + "Staff contract signature and fully execute (use contract/sign STAFF preferred)", }) async marketingApprove( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: SignContractDto, @CurrentUser() user: AuthUserPayload, @Request() req: { ip?: string }, ) { const payload: SignContractDto = { ...dto, - role: 'STAFF', + role: "STAFF", }; const booking = await this.contractService.signContract(id, payload, { signerUserId: resolveAuthUserId(user), @@ -704,48 +898,48 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/operations/start-transit') + @Post(":id/operations/start-transit") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark in transit' }) - async startTransit(@Param('id', ParseUUIDPipe) id: string) { + @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') + @Post(":id/operations/complete") @BookingStaff(FREIGHT_PERMS.bookings.operations) - @ApiOperation({ summary: 'Mark completed' }) - async complete(@Param('id', ParseUUIDPipe) id: string) { + @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') + @Post(":id/cancel") @BookingStaff(FREIGHT_PERMS.bookings.cancel) - @ApiOperation({ summary: 'Cancel booking' }) + @ApiOperation({ summary: "Cancel booking" }) async cancel( - @Param('id', ParseUUIDPipe) id: string, + @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) { + @Post(":id/consolidation") + @ApiOperation({ summary: "Request freight consolidation" }) + requestConsolidation(@Param("id", ParseUUIDPipe) id: string) { return this.bookingsService.requestConsolidation(id); } - @Delete(':id/consolidation') - @ApiOperation({ summary: 'Remove consolidation pairing' }) - 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); } - @Get(':id/consolidation') - @ApiOperation({ summary: 'Get consolidation details' }) - 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 ded7d0239..b54b6b49e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,7 +1,7 @@ -import { Module, forwardRef } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { Module, forwardRef } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { ExchangeModule, ExchangeOptions } from "@edr/api-common"; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -10,13 +10,17 @@ import { MinioModule } from '../minio/minio.module'; import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { BillingModule } from '../billing/billing.module'; +import { FirstMileModule } from '../first-mile/first-mile.module'; import { BookingContractService } from './booking-contract.service'; -import { BookingPaymentService } from './booking-payment.service'; +import { BookingInvoiceService } from './booking-invoice.service'; +// import { BookingPaymentController } from './booking-payment.controller'; +// 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 { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; import { BookingsService } from './bookings.service'; @@ -29,12 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature. 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'; -import { PaymentModule } from '../payment/payment.module'; -import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { ContractsModule } from '../contracts/contracts.module'; +import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity"; +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"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { VehiclesModule } from "../vehicles/vehicles.module"; @Module({ imports: [ @@ -47,11 +53,16 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), - PaymentModule, + BillingModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), + forwardRef(() => ContractsModule), + forwardRef(() => ContractsModule), FilesModule, MinioModule, + VehiclesModule, CompaniesModule, // CustomersModule, RuleEngineModule, @@ -60,10 +71,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => - config.get('app.cbeExchange') ?? {}, + config.get("app.cbeExchange") ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController], providers: [ BookingsService, BookingsRepository, @@ -72,13 +83,18 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingPricingService, BookingTransitionService, BookingContractService, - BookingPaymentService, + BookingInvoiceService, ContractTemplateResolver, ContractViewModelBuilder, ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService], + exports: [ + BookingsService, + BookingsRepository, + BookingPricingService, + BookingInvoiceService, + ], }) -export class BookingsModule {} +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 e8f0a1393..b99611c35 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { @@ -34,7 +35,6 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; - bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -121,6 +121,8 @@ export class BookingsRepository extends BaseRepository { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + hazardousQuantity?: number; + reeferQuantity?: number; weightResult: ContainerWeightResult; }>, ): Promise { @@ -133,11 +135,16 @@ export class BookingsRepository extends BaseRepository { const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; const totalVgm = item.quantity * item.vgmPerUnitTons; const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit); + // A per-line breakdown can never exceed the line's own quantity. + const clamp = (v?: number) => + Math.max(0, Math.min(item.quantity, Math.floor(Number(v ?? 0)) || 0)); const row = containerRepo.create({ bookingId, containerTypeId: item.containerTypeId, quantity: item.quantity, + hazardousQuantity: clamp(item.hazardousQuantity), + reeferQuantity: clamp(item.reeferQuantity), vgmPerUnitTons: item.vgmPerUnitTons, totalVgmTons: totalVgm, wagonsRequired, @@ -164,6 +171,19 @@ export class BookingsRepository extends BaseRepository { return Number(result?.total ?? 0); } + /** + * The road billing distance (km) of a booking's contract route, used to price + * per-km first/last-mile trucking. Returns 0 when there is no route or no km + * recorded (rail-only lanes) so a PER_KM rate bills nothing. + */ + async getContractRouteKm(contractRouteId: string | null | undefined): Promise { + if (!contractRouteId) return 0; + const route = await this.dataSource + .getRepository(ContractRoute) + .findOne({ where: { id: contractRouteId }, select: { id: true, km: true } }); + return Number(route?.km ?? 0); + } + /** * Find another booking whose container quantity complements this one to fill whole wagon(s) * (same route, same container type, partial wagon on both sides). @@ -470,6 +490,15 @@ export class BookingsRepository extends BaseRepository { } as never); } + /** Bookings in any of the given statuses (clearance queue helpers). */ + async findByStatuses(statuses: string[]): Promise { + if (!statuses.length) return []; + return this.repository.find({ + where: { status: In(statuses) }, + order: { createdAt: 'DESC' }, + }); + } + /** Queue listing with optional bulk exclusion for LINE_STAFF. */ async findQueue(options: { status: string | string[]; @@ -693,11 +722,6 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } - if (options.bookingType) { - qb.andWhere('booking.booking_type = :bookingType', { - bookingType: options.bookingType, - }); - } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, 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 c47064014..ac639a4bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -3,6 +3,7 @@ import { ConflictException, ForbiddenException, forwardRef, + GoneException, Inject, Injectable, NotFoundException, @@ -11,7 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { ProfileType } from '../companies/entities/company-profile.entity'; -import { CompanyStatus } from '../companies/entities/company.entity'; +import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -27,10 +28,11 @@ import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; -import { ContractRouteLine } from '../booking-orders/entities/contract-route-line.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { VehiclesService } from '../vehicles/vehicles.service'; +import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { assertFreightShape } from './booking-freight.util'; import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto'; import { mapStatusCountsToTabs } from './booking-list-tabs.config'; @@ -43,7 +45,10 @@ import { FreightType, } from './entities/booking.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { FileRecord } from '../files/entities/file.entity'; +import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ export interface PaginatedBookings { @@ -66,6 +71,17 @@ const NEEDS_ACTION_STATUSES = [ 'APPROVED_PENDING_SIGNATURE', ] as const; +/** + * Clamp a bulk hazardous/reefer amount into 0..cargoAmount: it can never exceed + * the total cargo it's a portion of, and is never negative. + */ +function clampToCargo(value: number | undefined, cargoAmount: number): number { + const v = Number(value ?? 0); + if (!Number.isFinite(v) || v <= 0) return 0; + const cap = Number.isFinite(cargoAmount) && cargoAmount > 0 ? cargoAmount : 0; + return Math.min(v, cap); +} + @Injectable() export class BookingsService { constructor( @@ -80,8 +96,63 @@ export class BookingsService { private readonly ruleEngineService: RuleEngineService, private readonly containerTypesService: ContainerTypesService, private readonly consolidationService: ConsolidationService, + private readonly vehiclesService: VehiclesService, + private readonly contractPdfService: ContractPdfService, ) {} + async assignCustomerTruck( + bookingId: string, + dto: CustomerTruckAssignmentDto, + ): Promise { + const booking = await this.findById(bookingId); + const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim()); + const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim()); + const usesMileService = + booking.tradeDirection === 'IMPORT' + ? hasLastMile + : booking.tradeDirection === 'EXPORT' + ? hasFirstMile + : hasFirstMile || hasLastMile; + if (usesMileService) { + throw new BadRequestException( + 'Customer truck assignment is only allowed when first/last mile delivery is not selected', + ); + } + if (booking.customerTruckAssignedAt) { + throw new ConflictException('Customer truck assignment is already submitted and locked'); + } + if (booking.paymentStatus !== 'PAID') { + throw new BadRequestException('Booking must be paid before assigning an external customer truck'); + } + + await this.bookingsRepository.update(bookingId, { + status: 'TRUCK_ASSIGNED', + customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + customerTruckDriverName: dto.driverName.trim(), + customerTruckType: dto.truckType.trim(), + customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(), + customerTruckAssignedAt: new Date(), + }); + + return this.findById(bookingId); + } + + async customerTruckFreightOrderCopies( + bookingId: string, + ): Promise<{ filename: string; buffer: Buffer }> { + const booking = await this.findById(bookingId); + if (!booking.customerTruckAssignedAt) { + throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated'); + } + + const html = this.buildCustomerTruckFreightOrderHtml(booking); + const buffer = await this.contractPdfService.htmlToPdfBuffer(html); + return { + filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, + buffer, + }; + } + /** Resolve trade direction from yard countries; reject client mismatch. */ private async resolveTradeDirectionForBooking( originYardId: string, @@ -119,6 +190,79 @@ export class BookingsService { return `BK-${year}-${String(count + 1).padStart(6, '0')}`; } + private buildCustomerTruckFreightOrderHtml(booking: Booking): string { + const assignedAt = booking.customerTruckAssignedAt + ? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB') + : '-'; + const rows: Array<[string, string | null | undefined]> = [ + ['Booking Reference', booking.reference], + ['Client Name', booking.company?.name], + ['Client ID', booking.companyId], + ['Trade Direction', booking.tradeDirection], + ['Freight Type', booking.freightType], + ['Truck Plate Number', booking.customerTruckPlateNumber], + ['Driver Name', booking.customerTruckDriverName], + ['Truck Type', booking.customerTruckType], + ['Container Number to Load', booking.customerTruckContainerNumber], + ['Assigned At', assignedAt], + ['Booking Status', booking.status], + ]; + const rowHtml = rows + .map(([label, value]) => `${this.escapeHtml(label)}${this.escapeHtml(value || '-')}`) + .join(''); + const copy = (watermark: string) => ` +
+
${this.escapeHtml(watermark)}
+
+
+

Freight Order

+

Customer external truck assignment

+
+ ${this.escapeHtml(booking.reference)} +
+ ${rowHtml}
+
+
Customer / Carrier Signature
+
Port Operations Verification
+
Gate Security Verification
+
+
`; + + return ` + + + + + + + ${copy('Copy 1: Port Operations Copy')} + ${copy('Copy 2: Gate Security & Carrier Copy')} + + `; + } + + private escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + /** Build evaluation input from booking freight shape. */ /** * Whether a service type bundles customs clearance. This is the single source @@ -283,6 +427,15 @@ export class BookingsService { ): Promise<{ booking: Booking; warnings: string[] }> { const warnings: string[] = []; + // Contract–booking separation: contracts are no longer created through the + // booking endpoint. Legacy GENERAL_CONTRACT creation is deprecated β€” clients + // must use POST /contracts (and create shipments via POST /contracts/:id/bookings). + if (dto.bookingType === 'GENERAL_CONTRACT') { + throw new GoneException( + 'General contracts are no longer created here. Use POST /contracts instead.', + ); + } + // let customerId = dto.customerId; // if (!customerId) { // if (!userId) { @@ -295,14 +448,26 @@ export class BookingsService { // } const isGovernment = dto.isGovernment === true; - const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT'; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { - if (!dto.governmentInstitution?.trim()) { - throw new BadRequestException('governmentInstitution is required for government bookings'); + // Government bookings bill to a real seeded government company + an + // explicitly-chosen importer/exporter profile (no more null company + + // free-text institution). + if (!dto.companyId) { + throw new BadRequestException('A government company is required for government bookings'); } - companyId = dto.companyId ?? null; + const govCompany = await this.companiesService.findCompanyById(dto.companyId); + if (govCompany.kind !== CompanyKind.Government) { + throw new BadRequestException('Selected company is not a government entity'); + } + if (govCompany.status !== CompanyStatus.Active) { + throw new BadRequestException('Selected government company is not active'); + } + if (!dto.companyProfileId) { + throw new BadRequestException('A government company profile is required for government bookings'); + } + companyId = govCompany.id; } else if (!companyId) { if (!userId) { throw new BadRequestException( @@ -375,7 +540,16 @@ export class BookingsService { // so the customer portal can scope lists/KPIs to the active mode. Best-effort // for non-government bookings with a resolved company; never blocks creation. let companyProfileId: string | null = null; - if (!isGovernment && companyId) { + if (dto.companyProfileId && companyId) { + // Explicit profile pin (government booking, or staff booking on behalf): + // must belong to the chosen company and be active. + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else if (companyId) { let fallbackType: ProfileType | null = null; if (userId) { try { @@ -405,6 +579,16 @@ export class BookingsService { } } + // Every booking must link to a company and a company profile. + if (!companyId) { + throw new BadRequestException('A company is required to create a booking'); + } + if (!companyProfileId) { + throw new BadRequestException( + 'A company profile is required to create a booking β€” none could be resolved for this company', + ); + } + const needsConsolidation = dto.freightType === 'CONTAINER' ? await this.needsConsolidation(containers) @@ -435,14 +619,13 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, - companyId: companyId ?? null, + companyId, companyProfileId, isGovernment, - governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + governmentInstitution: dto.governmentInstitution?.trim() || null, trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, - previousContractId: dto.previousContractId, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? null, @@ -466,10 +649,19 @@ export class BookingsService { // the container type at pricing time, so the booking-level flag stays off // for container freight to avoid double-counting. isReefer: dto.freightType === 'BULK' ? (dto.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount. Container + // freight tracks this per line, so these are 0 for CONTAINER. + bulkHazardousQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkHazardousQuantity, dto.cargoTotalWeightVgm) + : 0, + bulkReeferQuantity: + dto.freightType === 'BULK' + ? clampToCargo(dto.bulkReeferQuantity, dto.cargoTotalWeightVgm) + : 0, paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, - bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, estimatedShipmentDate: dto.estimatedShipmentDate ? new Date(dto.estimatedShipmentDate) @@ -489,6 +681,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -496,27 +690,6 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } - // Multi-route general contracts: persist the contracted routes (lanes). Routes - // carry NO quantity β€” the contract has a single shared pool (the cargo-step - // total / container quantities). Each drawdown order picks one lane for - // scheduling + road billing and draws from that shared pool. `quantity` on the - // route line is retained for legacy rows but is no longer meaningful (0). - if (isGeneralContract && dto.routes?.length) { - const routeRepo = this.dataSource.getRepository(ContractRouteLine); - await routeRepo.save( - dto.routes.map((r) => - routeRepo.create({ - contractBookingId: booking.id, - originYardId: r.originYardId, - destinationYardId: r.destinationYardId, - containerTypeId: null, - quantity: 0, - km: r.km ?? null, - }), - ), - ); - } - if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); @@ -647,6 +820,8 @@ export class BookingsService { containers, ); + const cargoAmount = + dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0); const updates: Record = { ...dto, freightType, @@ -657,6 +832,22 @@ export class BookingsService { freightType === 'BULK' ? (dto.isReefer ?? existing.isReefer ?? false) : false, + // Bulk-only hazardous/reefer amount, clamped to the cargo amount; 0 for + // container freight (per-line on the containers instead). + bulkHazardousQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkHazardousQuantity ?? Number(existing.bulkHazardousQuantity ?? 0), + cargoAmount, + ) + : 0, + bulkReeferQuantity: + freightType === 'BULK' + ? clampToCargo( + dto.bulkReeferQuantity ?? Number(existing.bulkReeferQuantity ?? 0), + cargoAmount, + ) + : 0, priorityScore: ruleResult.priorityScore, tradeDirection, }; @@ -703,6 +894,8 @@ export class BookingsService { containerTypeId: c.containerTypeId, quantity: c.quantity, vgmPerUnitTons: c.vgmPerUnitTons, + hazardousQuantity: c.hazardousQuantity, + reeferQuantity: c.reeferQuantity, weightResult: ruleResult.containerWeightResults[i], })), ); @@ -808,7 +1001,6 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, - bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -1017,7 +1209,6 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, - bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -1322,4 +1513,55 @@ export class BookingsService { createdAt: b.createdAt, })); } + + async allocateContainers( + bookingId: string, + allocations: Array<{ containerId: string; vehicleId: string }>, + ) { + const booking = await this.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + + const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, { + where: { + bookingId, + containerId: In(allocations.map((a) => a.containerId)), + }, + }); + const previousVehicleIds = previousAllocations + .map((a) => a.vehicleId) + .filter((id): id is string => Boolean(id)); + + await this.dataSource.transaction(async (manager) => { + for (const allocation of allocations) { + await manager.delete(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + }); + await manager.insert(BookingContainerAllocation, { + bookingId, + containerId: allocation.containerId, + vehicleId: allocation.vehicleId, + containerType: 'CONTAINER', + quantity: 1, + }); + } + }); + + const vehicleIds = new Set(allocations.map((a) => a.vehicleId)); + await Promise.all( + [...vehicleIds].map((vehicleId) => + this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY), + ), + ); + await this.vehiclesService.releaseIfUnused( + previousVehicleIds.filter((id) => !vehicleIds.has(id)), + ); + + return { + success: true, + allocated: allocations.length, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 69a8232d7..2e2865d8b 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): { outputCode: string | null; includesCustoms: boolean; } { - const includesCustoms = booking.serviceType?.includesCustoms ?? false; + // Customs applies when EITHER the service type bundles it OR the booking was + // created with customsClearingEnabled (copied from the contract). Contract + // bookings carry customsClearingEnabled even when the serviceType relation + // isn't loaded / has includesCustoms=false β€” without this the per-booking + // clearance grid would resolve empty. + const includesCustoms = + Boolean(booking.serviceType?.includesCustoms) || + Boolean(booking.customsClearingEnabled); return { inputCode: clearanceSettingCode( booking.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts new file mode 100644 index 000000000..8b9b7da39 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/allocate-containers.dto.ts @@ -0,0 +1,8 @@ +export class ContainerAllocationDto { + containerId!: string; + vehicleId!: string; +} + +export class AllocateContainersDto { + allocations!: ContainerAllocationDto[]; +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 35df9dcd3..c930d7aa1 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -72,9 +72,6 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty({ example: 'BULK_COFFEE' }) code!: string; - @ApiProperty() - show_free_text_box!: boolean; - @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) unit_of_measure?: CargoUnitOfMeasure | null; } 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 677ef03fd..c4d971f51 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 @@ -14,7 +14,6 @@ import { Max, MaxLength, Min, - MinLength, Validate, ValidateIf, ValidateNested, @@ -53,6 +52,28 @@ export class CreateBookingContainerDto { @Min(0) @Transform(({ value }) => Number(value)) vgmPerUnitTons!: number; + + @ApiPropertyOptional({ + description: 'How many of this line are hazardous (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ + description: 'How many of this line are refrigerated (0..quantity)', + minimum: 0, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number; } /** @@ -104,19 +125,31 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isGovernment?: boolean; - @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) - @ValidateIf((o) => o.isGovernment === true) + /** @deprecated Government bookings now bill to a real government company. */ + @ApiPropertyOptional({ description: 'Deprecated: free-text institution (superseded by companyId)' }) + @IsOptional() @IsString() - @MinLength(2) @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) governmentInstitution?: string; - @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) - @ValidateIf((o) => o.isGovernment !== true) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Target company. Required for staff/government bookings; resolved from the auth token for customer self-bookings.', + }) @IsOptional() @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile (importer/exporter). Required for government bookings; commercial bookings auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() @@ -309,6 +342,25 @@ export class CreateBookingDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; + /** + * Bulk-only: how much of the cargo is hazardous / refrigerated, in the cargo's + * unit of measure (tons for PER_TON, item count for PER_ITEM). Must not exceed + * cargoTotalWeightVgm. Ignored for container freight (per-line on containers). + */ + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkHazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0, default: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + bulkReeferQuantity?: number; + @ApiProperty({ enum: PAYMENT_CURRENCIES }) @IsIn([...PAYMENT_CURRENCIES]) paymentCurrency!: string; diff --git a/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts new file mode 100644 index 000000000..9daf7523e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/customer-truck-assignment.dto.ts @@ -0,0 +1,34 @@ +import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'; + +export const CUSTOMER_TRUCK_TYPES = [ + 'Flatbed', + 'Container Chassis', + 'Lowboy', + 'Box Truck', + 'Tipper', +] as const; + +export class CustomerTruckAssignmentDto { + @IsString() + @IsNotEmpty() + @MaxLength(32) + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(120) + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(16) + @Matches(/^[A-Z]{4}\d{7}$/, { + message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567', + }) + containerNumberToLoad!: string; +} 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 index 217353987..f27b375bb 100644 --- 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 @@ -1,10 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { - IsBoolean, IsDateString, IsIn, IsInt, - IsNumber, IsOptional, IsString, Max, @@ -70,22 +68,6 @@ export class RejectBookingDto { reason?: string; } -export class AdjustPriceDto { - @ApiPropertyOptional({ - description: - 'New total price. Omit or send null to clear a previous adjustment.', - }) - @IsOptional() - @IsNumber() - @Min(0) - amount?: number | null; - - @ApiPropertyOptional({ description: 'Reason for the adjustment' }) - @IsOptional() - @IsString() - reason?: string; -} - export class ReviewDocumentDto { @ApiProperty({ description: 'The document fileKey being reviewed' }) @IsString() @@ -117,12 +99,12 @@ export class OperationReviewDto { @ApiProperty({ description: 'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' + - 'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' + - 'customer must re-confirm before it proceeds.', - enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'], + 'returns it to the customer with a note. The booking price is computed ' + + 'from the contract and cannot be adjusted by staff.', + enum: ['ACCEPT', 'REQUEST_CHANGES'], }) - @IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE']) - decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE'; + @IsIn(['ACCEPT', 'REQUEST_CHANGES']) + decision!: 'ACCEPT' | 'REQUEST_CHANGES'; @ApiPropertyOptional({ description: 'Required for REQUEST_CHANGES (what the customer must fix).', @@ -130,22 +112,4 @@ export class OperationReviewDto { @IsOptional() @IsString() note?: string; - - @ApiPropertyOptional({ - description: 'New total price β€” required for ADJUST_PRICE.', - }) - @IsOptional() - @IsNumber() - @Min(0) - amount?: number; -} - -export class ConfirmOperationPriceDto { - @ApiProperty({ - description: - 'true to accept the operations price adjustment and proceed to the ' + - 'batch pool; false to reject it (returns to operation changes requested).', - }) - @IsBoolean() - accept!: boolean; } diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts new file mode 100644 index 000000000..8cb186e09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-allocation.entity.ts @@ -0,0 +1,32 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from './booking.entity'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ schema: 'freight', name: 'booking_container_allocations' }) +@Index(['bookingId']) +@Index(['vehicleId']) +export class BookingContainerAllocation extends BaseEntity { + @ManyToOne(() => Booking, (b) => b.containerAllocations) + @JoinColumn({ name: 'booking_id' }) + booking!: Booking; + + @Column('uuid', { name: 'booking_id' }) + bookingId!: string; + + @Column('uuid', { name: 'container_id' }) + containerId!: string; + + @ManyToOne(() => Vehicle) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column('uuid', { name: 'vehicle_id', nullable: true }) + vehicleId?: string; + + @Column('text') + containerType!: string; // CONTAINER, BULK_DRY, etc + + @Column('integer', { default: 1 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts new file mode 100644 index 000000000..e8ef1b138 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { BookingContainer } from './booking-container.entity'; + +/** + * One physical container under a booking_container line β€” its number, seal, and + * per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET + * in Path B). See Β§5.10. + */ +@Entity({ schema: 'freight', name: 'booking_container_units' }) +@Index(['bookingContainerId']) +export class BookingContainerUnit extends BaseEntity { + @Column({ name: 'booking_container_id', type: 'uuid' }) + bookingContainerId!: string; + + @ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; + + @Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 }) + vgmTons!: number; + + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) + isHazardous!: boolean; + + @Column({ name: 'is_reefer', type: 'boolean', default: false }) + isReefer!: boolean; + + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) + sortOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index 8a09245ea..db9746c09 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) containerNumber?: string | null; + /** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */ + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + @Column({ name: 'quantity', type: 'smallint' }) quantity!: number; + /** How many units of this line are hazardous (≀ quantity). */ + @Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 }) + hazardousQuantity!: number; + + /** How many units of this line are refrigerated (≀ quantity). */ + @Column({ name: 'reefer_quantity', type: 'smallint', default: 0 }) + reeferQuantity!: number; + @Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }) vgmPerUnitTons!: number; 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 00f6e41c1..4b033bab5 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 @@ -13,6 +13,7 @@ import { FileRecord } from '../../files/entities/file.entity'; import { BookingApprovalStep } from './booking-approval-step.entity'; import { BookingCargoModifier } from './booking-cargo-modifier.entity'; import { BookingContainer } from './booking-container.entity'; +import { BookingContainerAllocation } from './booking-container-allocation.entity'; import { BookingRateSnapshot } from './booking-rate-snapshot.entity'; import { BookingReviewNote } from './booking-review-note.entity'; @@ -50,6 +51,7 @@ export const BOOKING_STATUSES = [ // Road (truck) drawdown orders skip the train batch pool and wait here for // truck dispatch after Marketing accepts; billed by KM, not wagons. 'ROAD_DISPATCH_PENDING', + 'TRUCK_ASSIGNED', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before @@ -105,8 +107,10 @@ export class Booking extends BaseEntity { // @JoinColumn({ name: 'customer_id' }) // customer?: Customer; - @Column({ name: 'company_id', type: 'uuid', nullable: true }) - companyId?: string | null; + // Every booking is billed to a company β€” government bookings bill to a seeded + // government company (companies.kind = 'government'). Enforced NOT NULL. + @Column({ name: 'company_id', type: 'uuid' }) + companyId!: string; @ManyToOne(() => Company, { nullable: true }) @JoinColumn({ name: 'company_id' }) @@ -116,11 +120,12 @@ export class Booking extends BaseEntity { * The operational profile (importer/exporter/forwarder) this booking belongs * to. Stamped at creation from the booking's trade direction (IMPORTβ†’importer, * EXPORTβ†’exporter) or the user's active profile for DOMESTIC/forwarder. - * Customer portal lists and dashboard KPIs are scoped by this. Nullable for - * legacy/government/staff-created bookings. + * Customer portal lists and dashboard KPIs are scoped by this. Required: + * commercial bookings resolve it from trade direction / active mode; + * government bookings carry the explicitly-picked government profile. */ - @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) - companyProfileId?: string | null; + @Column({ name: 'company_profile_id', type: 'uuid' }) + companyProfileId!: string; @ManyToOne(() => CompanyProfile, { nullable: true }) @JoinColumn({ name: 'company_profile_id' }) @@ -144,13 +149,24 @@ export class Booking extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - /** - * ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an - * umbrella contract that is signed/paid once and then drawn down by many - * orders (each order spawns its own ONE_TIME child booking). - */ - @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) - bookingType!: string; + /** The contract this shipment booking was created under (contract–booking split). */ + @Column({ name: 'contract_id', type: 'uuid', nullable: true }) + contractId?: string | null; + + /** The contract route (lane) this shipment uses. */ + @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) + contractRouteId?: string | null; + + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ + @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) + contractKind?: string | null; + + /** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */ + @Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true }) + createdByRole?: string | null; + + @Column({ name: 'created_by_user_id', type: 'uuid', nullable: true }) + createdByUserId?: string | null; /** * Nullable: general contracts have no shipment date at creation β€” the date is @@ -220,13 +236,6 @@ export class Booking extends BaseEntity { @Column({ name: 'contract_type', type: 'varchar', length: 20 }) contractType!: string; - @Column({ name: 'previous_contract_id', type: 'uuid', nullable: true }) - previousContractId?: string | null; - - @ManyToOne(() => Booking, { nullable: true }) - @JoinColumn({ name: 'previous_contract_id' }) - previousContract?: Booking | null; - @Column({ name: 'service_type_id', type: 'uuid' }) serviceTypeId!: string; @@ -252,6 +261,24 @@ export class Booking extends BaseEntity { @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) lastMileDeliveryLng?: number | null; + @Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true }) + customerTruckPlateNumber?: string | null; + + @Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true }) + customerTruckDriverName?: string | null; + + @Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true }) + customerTruckType?: string | null; + + @Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true }) + customerTruckContainerNumber?: string | null; + + @Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true }) + customerTruckAssignedAt?: Date | null; + + @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) + customerTruckArrivedAt?: Date | null; + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) customsClearingEnabled!: boolean; @@ -264,7 +291,7 @@ export class Booking extends BaseEntity { @Column({ name: 'origin_yard_id', type: 'uuid' }) originYardId!: string; - @ManyToOne(() => Yard) + @ManyToOne(() => Yard) @JoinColumn({ name: 'origin_yard_id' }) originYard?: Yard; @@ -313,6 +340,19 @@ export class Booking extends BaseEntity { @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; + /** + * Bulk-only hazardous / reefer amount, in the cargo's own unit of measure + * (tons for PER_TON commodities, item count for PER_ITEM) β€” i.e. how much of + * `cargoTotalWeightVgm` is hazardous / refrigerated. 0 when none. Container + * freight carries this per line on `booking_container` instead, so these stay + * 0 for CONTAINER bookings. The booleans above remain the surcharge trigger. + */ + @Column({ name: 'bulk_hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkHazardousQuantity!: number; + + @Column({ name: 'bulk_reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + bulkReeferQuantity!: number; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; @@ -422,9 +462,43 @@ export class Booking extends BaseEntity { @Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true }) selectedForBatchAt?: Date | null; + // ── Global Logistics station routing (GL Import/Export US-02) ────────────── + /** Origin-station yard the shipment is routed to for GL handling. */ + @Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true }) + glStationYardId?: string | null; + + /** Per-booking phased clearance (GENERAL + customs). */ + @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) + clearanceCurrentPhase?: string | null; + + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; + + /** GL staff user bound to this shipment by the station manager. */ + @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) + glAssignedStaffId?: string | null; + + @Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true }) + glAssignedAt?: Date | null; + @OneToMany(() => BookingContainer, (bc) => bc.booking) bookingContainers?: BookingContainer[]; + @OneToMany(() => BookingContainerAllocation, (ca) => ca.booking) + containerAllocations?: BookingContainerAllocation[]; + @OneToMany(() => BookingCargoModifier, (m) => m.booking) cargoModifiers?: BookingCargoModifier[]; diff --git a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts b/apps/edr-freight-api/src/modules/bookings/pay.controller.ts deleted file mode 100644 index 25f1927d3..000000000 --- a/apps/edr-freight-api/src/modules/bookings/pay.controller.ts +++ /dev/null @@ -1,27 +0,0 @@ -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) { - return 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/booking-orders/road.util.ts b/apps/edr-freight-api/src/modules/bookings/road.util.ts similarity index 100% rename from apps/edr-freight-api/src/modules/booking-orders/road.util.ts rename to apps/edr-freight-api/src/modules/bookings/road.util.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index b31f2939d..15ca85c73 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -38,7 +38,7 @@ export class CompaniesRepository extends BaseRepository { async findPaginated( query: ListCompaniesQueryDto, ): Promise<{ items: Company[]; total: number }> { - const { page = 1, pageSize = 20, search, type, status } = query; + const { page = 1, pageSize = 20, search, type, kind, status } = query; const qb = this.repository .createQueryBuilder('company') @@ -49,6 +49,10 @@ export class CompaniesRepository extends BaseRepository { qb.andWhere('company.type = :type', { type }); } + if (kind) { + qb.andWhere('company.kind = :kind', { kind }); + } + if (status) { qb.andWhere('company.status = :status', { status }); } 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 a838495d5..02f77b2e0 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -337,6 +337,29 @@ export class CompaniesService { return company; } + /** + * Validate an explicitly-chosen company profile for a booking: it must belong + * to the booking's company and be Active. Used for government bookings (staff + * pick the profile) and any staff booking that pins a profile directly. + */ + async getActiveCompanyProfileForBooking( + companyId: string, + profileId: string, + ): Promise { + const profile = await this.companyProfilesRepo.findById(profileId); + if (!profile || profile.companyId !== companyId) { + throw new BadRequestException( + "Selected company profile does not belong to the chosen company", + ); + } + if (profile.status !== ProfileStatus.Active) { + throw new BadRequestException( + "Selected company profile is not active", + ); + } + return profile; + } + async getCompanyInfoByUserId( userId: string, ): Promise<{ profile: ExternalProfile; company: Company }> { diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index c92592286..4dbb932cb 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -1,7 +1,7 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator"; import { Transform } from "class-transformer"; -import { CompanyStatus, CompanyType } from "../entities/company.entity"; +import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity"; export class ListCompaniesQueryDto { @ApiPropertyOptional({ default: 1 }) @@ -28,6 +28,11 @@ export class ListCompaniesQueryDto { @IsIn(Object.values(CompanyType)) type?: CompanyType; + @ApiPropertyOptional({ enum: CompanyKind }) + @IsOptional() + @IsIn(Object.values(CompanyKind)) + kind?: CompanyKind; + @ApiPropertyOptional({ enum: CompanyStatus }) @IsOptional() @IsIn(Object.values(CompanyStatus)) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 6702f9f7c..5fe3a3f67 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -10,6 +10,16 @@ export enum CompanyType { Transporter = "transporter", } +/** + * Sector of the company β€” orthogonal to {@link CompanyType} (the trade role). + * Government bookings are billed to a single seeded `GOVERNMENT` company instead + * of carrying a null company + free-text institution. + */ +export enum CompanyKind { + Commercial = "commercial", + Government = "government", +} + export enum CompanyStatus { Active = "active", Pending = "pending", @@ -25,6 +35,7 @@ export enum CompanyNationality { @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) +@Index(["kind"]) export class Company extends BaseEntity { @Column({ name: "name", type: "varchar", length: 200 }) name!: string; @@ -32,6 +43,16 @@ export class Company extends BaseEntity { @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) type!: CompanyType; + /** Commercial customer vs. the seeded government entity. */ + @Column({ + name: "kind", + type: "varchar", + length: 20, + default: CompanyKind.Commercial, + enum: CompanyKind, + }) + kind!: CompanyKind; + @Column({ name: "status", type: "varchar", diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts new file mode 100644 index 000000000..2b0126003 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -0,0 +1,199 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { BookingClearanceService } from './booking-clearance.service'; +import type { Booking } from '../bookings/entities/booking.entity'; + +const generalImportBooking = { + id: 'b-general', + status: 'DOCUMENTS_UNDER_REVIEW', + tradeDirection: 'IMPORT', + freightType: 'CONTAINER', + customsClearingEnabled: true, + contractKind: 'GENERAL', + contractId: 'c-1', + dutyRequired: true, + roHoldReason: null, + vesselDepartureDate: null, +} as Booking; + +const generalExportBooking = { + ...generalImportBooking, + id: 'b-export', + tradeDirection: 'EXPORT', + dutyRequired: null, +} as Booking; + +function makeService(overrides?: { + booking?: Booking; + workflowThrows?: boolean; +}) { + const booking = overrides?.booking ?? generalImportBooking; + const bookingsRepository = { + findDocumentReviews: jest.fn().mockResolvedValue([]), + update: jest.fn().mockResolvedValue(booking), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const filesService = { + upsertByCode: jest.fn().mockResolvedValue({}), + findByResource: jest.fn().mockResolvedValue([]), + }; + const fileUploadSettingsService = { + getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + }; + const workflowService = { + assertPriorCompleteForBooking: overrides?.workflowThrows + ? jest.fn().mockRejectedValue(new BadRequestException('Prior milestone incomplete')) + : jest.fn().mockResolvedValue(undefined), + completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), + onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), + listMilestonesForBooking: jest.fn().mockResolvedValue([]), + resolvePhaseForBooking: jest.fn().mockReturnValue(null), + computeNextActionForBooking: jest.fn().mockReturnValue(null), + isBoundaryCompleteForBooking: jest.fn().mockResolvedValue(false), + markReadyForOperation: jest.fn().mockResolvedValue(undefined), + onExportReleasedForBooking: jest.fn().mockResolvedValue(undefined), + }; + const milestoneService = { + adviseDuty: jest.fn().mockResolvedValue(undefined), + }; + const dropdownSettingsService = { + getByCode: jest.fn().mockResolvedValue({ + children: [{ value: '2' }], + }), + }; + const glOperationsService = { + t1State: jest.fn().mockResolvedValue({ + bookingId: 'b-general', + wagonAllocated: false, + trainDepartedAt: null, + trainArrivedAt: null, + closed: false, + closedAt: null, + }), + }; + + const service = new BookingClearanceService( + bookingsRepository as never, + bookingsService as never, + filesService as never, + fileUploadSettingsService as never, + workflowService as never, + milestoneService as never, + dropdownSettingsService as never, + glOperationsService as never, + ); + + return { + service, + bookingsRepository, + bookingsService, + filesService, + workflowService, + milestoneService, + }; +} + +describe('BookingClearanceService', () => { + describe('adviseDuty', () => { + it('skips duty milestones when duty is not required', async () => { + const { service, workflowService, bookingsRepository } = makeService(); + await service.adviseDuty('b-general', { dutyRequired: false }); + + expect(workflowService.onDutySkippedForBooking).toHaveBeenCalledWith('b-general'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + dutyRequired: false, + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + + it('records duty advice when duty applies', async () => { + const { service, milestoneService } = makeService(); + await service.adviseDuty('b-general', { + dutyRequired: true, + amount: 1500, + currency: 'ETB', + declarationSerial: 'DS-1', + }); + + expect(milestoneService.adviseDuty).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ amount: 1500, currency: 'ETB', declarationSerial: 'DS-1' }), + undefined, + ); + }); + }); + + describe('uploadDutySlip', () => { + it('rejects when duty is not required', async () => { + const { service } = makeService({ + booking: { ...generalImportBooking, dutyRequired: false } as Booking, + }); + await expect( + service.uploadDutySlip('b-general', { fieldname: 'file' } as Express.Multer.File), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('uploads slip and completes DUTY_TAX_PAID on happy path', async () => { + const { service, filesService, workflowService, bookingsRepository } = makeService(); + const file = { fieldname: 'file' } as Express.Multer.File; + + await service.uploadDutySlip('b-general', file); + + expect(filesService.upsertByCode).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'b-general', + code: 'duty_tax_receipt', + file, + }), + ); + expect(workflowService.completeMilestoneForBooking).toHaveBeenCalledWith( + 'b-general', + 'DUTY_TAX_PAID', + ); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + }), + ); + }); + }); + + describe('uploadDeclaration', () => { + it('rejects when a prior milestone is incomplete', async () => { + const { service } = makeService({ workflowThrows: true }); + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('uploadReleaseOrder', () => { + it('places RO on hold when vessel departs too soon', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().slice(0, 10); + + const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); + const result = await service.uploadReleaseOrder( + 'b-export', + { fieldname: 'ro' } as Express.Multer.File, + dateStr, + ); + + expect(result.hold).toBe(true); + expect(result.holdReason).toMatch(/minimum lead time/i); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-export', + expect.objectContaining({ roHoldReason: expect.any(String) }), + ); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts new file mode 100644 index 000000000..3f169c49c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -0,0 +1,712 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; + +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { FilesService } from '../files/files.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; + +export interface BookingClearanceView { + bookingId: string; + status: string; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: Array<{ + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null; + note: string | null; + }>; + allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + operationReady?: boolean; + preClearanceFinalized?: boolean; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until wagon allocation). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; +} + +@Injectable() +export class BookingClearanceService { + constructor( + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, + ) {} + + private async assertPhasedGeneralCustoms(booking: Booking): Promise { + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Phased clearance applies only to customs bookings.'); + } + if (booking.contractKind !== 'GENERAL') { + throw new BadRequestException('Per-booking phased clearance applies to general contracts.'); + } + if (!booking.contractId) { + throw new BadRequestException('Booking is not linked to a contract.'); + } + } + + private async loadBooking(bookingId: string): Promise { + const booking = await this.bookingsService.findById(bookingId); + await this.assertPhasedGeneralCustoms(booking); + return booking; + } + + async getClearanceView(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + const { inputCode, outputCode, includesCustoms } = clearanceCodesForBooking(booking); + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.bookingsRepository.findDocumentReviews(bookingId); + const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); + + const documents: BookingClearanceView['documents'] = []; + + const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(booking); + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const phase = this.workflowService.resolvePhaseForBooking(booking, milestones); + const nextAction = this.workflowService.computeNextActionForBooking(booking, milestones); + const boundary = await this.workflowService.isBoundaryCompleteForBooking( + bookingId, + booking.tradeDirection ?? 'IMPORT', + ); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + const workflowFiles = buildWorkflowFiles( + files, + booking.tradeDirection ?? 'IMPORT', + ); + + let t1: ClearanceT1State | null = null; + if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(bookingId); + } catch { + t1 = null; + } + } + + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + + return { + bookingId, + status: booking.status, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: booking.dutyRequired ?? null, + roHold: Boolean(booking.roHoldReason), + roHoldReason: booking.roHoldReason ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt + ? booking.roAmendmentRequestedAt.toISOString() + : null, + operationReady: boundary, + preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + dutyAdvice, + workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): BookingClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, + }; + } + + private async isClearanceFullyApproved(booking: Booking): Promise { + const { inputCode } = clearanceCodesForBooking(booking); + if (!inputCode) return true; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + const reviews = await this.bookingsRepository.findDocumentReviews(booking.id); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + isPhasedGeneralCustomsBooking(booking: Booking): boolean { + return ( + Boolean(booking.customsClearingEnabled) && + booking.contractKind === 'GENERAL' && + Boolean(booking.contractId) + ); + } + + async uploadDeclaration( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const allApproved = await this.isClearanceFullyApproved(booking); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestonesForBooking(bookingId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApprovedForBooking(bookingId); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.onDeclarationUploadedForBooking(bookingId, userId); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: + tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + } as never); + + // Export: the declaration is the last GL ET pre-operation action β€” release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + + return this.bookingsService.findById(bookingId); + } + + async adviseDuty( + bookingId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'DUTY_TAXES_ADVISED', + ); + + await this.bookingsRepository.update(bookingId, { + dutyRequired: dto.dutyRequired, + clearanceCurrentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + } as never); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkippedForBooking(bookingId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDuty( + bookingId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import bookings.'); + } + if (!booking.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestoneForBooking(bookingId, 'DUTY_TAX_PAID'); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async uploadTransitPermit( + bookingId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads(this.filesService, bookingId, 'bookings', files); + + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'TRANSIT_PERMIT_UPLOADED', + userId, + ); + await this.bookingsRepository.update(bookingId, { + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + + return this.bookingsService.findById(bookingId); + } + + async finalizePreClearance(bookingId: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import bookings.'); + } + + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (booking.preClearanceFinalizedAt) { + return this.bookingsService.findById(bookingId); + } + + await this.bookingsRepository.update(bookingId, { + preClearanceFinalizedAt: new Date(), + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + // GL Djibouti may have uploaded the DO early (un-gated) β€” count it now. + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); + await this.workflowService.markReadyForOperation(bookingId); + } + + return this.bookingsService.findById(bookingId); + } + + async uploadDeliveryOrder( + bookingId: string, + file: Express.Multer.File, + userId?: string, + vesselDepartureDate?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import bookings.'); + } + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and operation readiness) still + // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'delivery_order', + file, + }); + + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + + if (booking.preClearanceFinalizedAt) { + await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForOperation(bookingId); + } + + return this.bookingsService.findById(bookingId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + bookingId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'release_order', + file, + }); + + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + } as never); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) β€” minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.bookingsRepository.update(bookingId, { + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + return { + booking: await this.bookingsService.findById(bookingId), + hold: true, + holdReason: reason, + }; + } + + await this.bookingsRepository.update(bookingId, { + roHoldReason: null, + clearanceCurrentPhase: ContractDocPhase.GlEtOutput, + } as never); + await this.workflowService.completeMilestoneForBooking( + bookingId, + 'RELEASE_ORDER_SECURED', + userId, + ); + + return { booking: await this.bookingsService.findById(bookingId), hold: false }; + } + + async requestRoAmendment( + bookingId: string, + note?: string, + userId?: string, + ): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export bookings.'); + } + + const reason = + note?.trim() || + 'Port amendment requested β€” vessel departure window is too short. A new Release Order will be required.'; + + await this.bookingsRepository.update(bookingId, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + clearanceCurrentPhase: ContractDocPhase.GlDjCollection, + } as never); + + if (userId) { + await this.bookingsRepository.createReviewNote( + bookingId, + reason, + 'CHANGES_REQUESTED', + userId, + ); + } + + return this.bookingsService.findById(bookingId); + } + + async confirmExportRelease(bookingId: string, userId?: string): Promise { + const booking = await this.loadBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export bookings.'); + } + await this.workflowService.assertPriorCompleteForBooking( + bookingId, + 'EXPORT', + 'EXPORT_RELEASED', + ); + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + return this.bookingsService.findById(bookingId); + } + + async etQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); + } + return filtered; + } + + async djQueue(): Promise { + const candidates = await this.bookingsRepository.findByStatuses([ + ...DJ_BOOKING_QUEUE_STATUSES, + ]); + const filtered: Booking[] = []; + for (const b of candidates) { + if (!this.isPhasedGeneralCustomsBooking(b)) continue; + const milestones = await this.workflowService.listMilestonesForBooking(b.id); + if ( + belongsOnDjClearanceQueue(b.tradeDirection, null, milestones, { + roHoldReason: b.roHoldReason, + preClearanceFinalizedAt: b.preClearanceFinalizedAt, + }) + ) { + filtered.push(b); + } + } + return filtered; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts new file mode 100644 index 000000000..7a3695768 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -0,0 +1,55 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { BookingRequest } from './entities/booking-request.entity'; + +@Injectable() +export class BookingRequestRepository extends BaseRepository { + constructor( + @InjectRepository(BookingRequest) + repository: Repository, + ) { + super(repository); + } + + /** All requests on a contract, newest first. */ + async findForContract(contractId: string): Promise { + return this.repository.find({ + where: { contractId }, + order: { createdAt: 'DESC' }, + }); + } + + /** GL queue: pending requests across all contracts, oldest first. */ + async findPending(): Promise { + return this.repository.find({ + where: { status: 'PENDING' }, + order: { createdAt: 'ASC' }, + relations: { contract: true }, + }); + } + + async findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, + }); + } + + /** Total rows β€” used to mint the next sequential reference. */ + async count(): Promise { + return this.repository.count(); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts new file mode 100644 index 000000000..199276862 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -0,0 +1,199 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import type { Freight } from '@edr/types'; + +import { BookingRequestRepository } from './booking-request.repository'; +import { ContractsService } from './contracts.service'; +import { ContractBookingService } from './contract-booking.service'; +import { BookingRequest } from './entities/booking-request.entity'; +import { Contract } from './entities/contract.entity'; +import { CreateBookingRequestDto } from './dto/create-booking-request.dto'; + +/** + * Customer shipment requests on GENERAL customs (Path B) contracts. The customer + * submits a request (date + quantities); GL reviews the queue and, on accept, + * creates the booking β€” after which per-booking clearance begins. ONE_TIME and + * Path A do not use this flow. + */ +@Injectable() +export class BookingRequestService { + constructor( + private readonly repo: BookingRequestRepository, + private readonly contractsService: ContractsService, + private readonly contractBookingService: ContractBookingService, + ) {} + + /** Only GENERAL contracts that bundle customs use the request β†’ GL β†’ clearance flow. */ + private assertGeneralCustoms(contract: Contract): void { + if ( + contract.contractKind !== 'GENERAL' || + !contract.customsClearingEnabled + ) { + throw new BadRequestException( + 'Shipment requests apply only to general customs-clearance contracts.', + ); + } + } + + /** Customer submits a shipment request. */ + async submit( + contractId: string, + dto: CreateBookingRequestDto, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + await this.contractsService.assertCustomerCanAccessContract(userId, contract); + this.assertGeneralCustoms(contract); + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new ConflictException( + 'The contract must be active before requesting a shipment.', + ); + } + + const isContainer = contract.freightType === 'CONTAINER'; + const hasLines = isContainer + ? (dto.containers?.length ?? 0) > 0 + : Boolean(dto.bulk); + if (!hasLines) { + throw new BadRequestException( + isContainer + ? 'Add at least one container line.' + : 'Enter the bulk cargo amount.', + ); + } + + // Validate requested container sizes against the contract cargo scope and + // remaining draw-down capacity (reuses the booking quantity-cap check). + if (isContainer) { + const allowed = new Set( + (contract.cargoScope ?? []) + .map((s) => s.containerSize) + .filter((s): s is string => !!s), + ); + for (const line of dto.containers ?? []) { + if (allowed.size && !allowed.has(line.containerSize)) { + throw new BadRequestException( + `Container size ${line.containerSize} is not in this contract's scope.`, + ); + } + } + } + await this.contractBookingService.assertRequestWithinCapacity(contract, { + containers: dto.containers, + bulk: dto.bulk, + }); + + const requestedLines: Freight.RequestedShipmentLines = isContainer + ? { + containers: (dto.containers ?? []).map((l) => ({ + containerSize: l.containerSize, + quantity: l.quantity, + hazardousQuantity: l.hazardousQuantity, + reeferQuantity: l.reeferQuantity, + })), + } + : { + bulk: { + cargoTypeId: dto.bulk?.cargoTypeId ?? null, + cargoWeightTons: dto.bulk?.cargoWeightTons, + itemCount: dto.bulk?.itemCount, + hazardousQuantity: dto.bulk?.hazardousQuantity, + }, + }; + + const reference = await this.generateReference(); + return this.repo.create({ + reference, + contractId, + requestedByUserId: userId ?? null, + contractRouteId: dto.contractRouteId ?? null, + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + status: 'PENDING', + requestedLines, + notes: dto.notes ?? null, + } as never); + } + + listForContract(contractId: string): Promise { + return this.repo.findForContract(contractId); + } + + async findOne(requestId: string): Promise { + const request = await this.repo.findById(requestId); + if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); + return request; + } + + queue(): Promise { + return this.repo.findPending(); + } + + private async findPending(requestId: string): Promise { + const request = await this.repo.findById(requestId); + if (!request) throw new NotFoundException(`Booking request ${requestId} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException( + `This request is already ${request.status.toLowerCase()}.`, + ); + } + return request; + } + + /** + * GL accepts a request. The booking itself is created via the GL booking form + * (POST /contracts/:id/bookings) which carries the per-unit container data the + * request omits; this endpoint records the acceptance + links the created + * booking. `bookingId` is supplied by the GL form on success. + */ + async accept( + requestId: string, + bookingId: string, + staffId?: string, + ): Promise { + const request = await this.findPending(requestId); + await this.repo.update(requestId, { + status: 'ACCEPTED', + createdBookingId: bookingId, + reviewedByStaffId: staffId ?? null, + reviewedAt: new Date(), + } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + /** GL rejects a request with a note. */ + async reject( + requestId: string, + note?: string, + staffId?: string, + ): Promise { + const request = await this.findPending(requestId); + await this.repo.update(requestId, { + status: 'REJECTED', + reviewNote: note ?? null, + reviewedByStaffId: staffId ?? null, + reviewedAt: new Date(), + } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + /** Customer cancels their own pending request. */ + async cancel(requestId: string, userId?: string): Promise { + const request = await this.findPending(requestId); + if (request.requestedByUserId && request.requestedByUserId !== userId) { + throw new ForbiddenException('You can only cancel your own requests.'); + } + await this.repo.update(requestId, { status: 'CANCELLED' } as never); + return (await this.repo.findById(requestId)) ?? request; + } + + private async generateReference(): Promise { + const count = await this.repo.count(); + const seq = String(count + 1).padStart(6, '0'); + return `SR-${seq}`; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts new file mode 100644 index 000000000..648d9666f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -0,0 +1,121 @@ +import { IMPORT_MILESTONES, EXPORT_MILESTONES } from '@edr/types'; +import type { MilestoneOwnerRegion } from './entities/clearance-milestone.entity'; + +/** + * Static catalog of clearance milestones per trade direction (doc Β§11.3, Β§12.2). + * Drives the rows seeded onto a contract clearance cycle (pre-booking) and + * booking (post-booking). `phaseBoundaryAfter` marks the last pre-booking + * milestone β€” everything after it tracks on the booking. + */ +export interface MilestoneDef { + code: string; + label: string; + ownerRegion: MilestoneOwnerRegion; + triggeredByDoc: boolean; +} + +const IMPORT_DEFS: Record> = { + IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false }, + PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true }, + DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false }, + UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false }, + DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, + DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false }, + DUTY_TAX_PAID: { label: 'Duty and Tax Paid', ownerRegion: 'CUST', triggeredByDoc: true }, + TRANSIT_PERMIT_UPLOADED: { + label: 'Transit Permit Uploaded', + ownerRegion: 'ET', + triggeredByDoc: true, + }, + DO_COLLECTED: { label: 'DO Collected', ownerRegion: 'DJ', triggeredByDoc: true }, + WAGON_REQUESTED: { label: 'Wagon Allocation Requested', ownerRegion: 'ET', triggeredByDoc: false }, + FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled (freight)', ownerRegion: 'CUST', triggeredByDoc: true }, + WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'DJ', triggeredByDoc: false }, + LOADED: { label: 'Loaded', ownerRegion: 'DJ', triggeredByDoc: false }, + DEPARTED_FROM_DJIBOUTI: { label: 'Departed from Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, + ARRIVED_ETHIOPIA: { label: 'Arrived at Port in Ethiopia', ownerRegion: 'OPS', triggeredByDoc: false }, + OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, + RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, + IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, + IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, + STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, + EXIT_NOTE_GENERATED: { label: 'Exit Note Generated', ownerRegion: 'OPS', triggeredByDoc: true }, +}; + +const EXPORT_DEFS: Record> = { + EXPORT_DOCS_UPLOADED: { label: 'Export Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false }, + PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true }, + DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false }, + RELEASE_ORDER_SECURED: { label: 'Release Order Secured', ownerRegion: 'DJ', triggeredByDoc: true }, + UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false }, + DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true }, + EXPORT_RELEASED: { label: 'Export Released', ownerRegion: 'ET', triggeredByDoc: false }, + WAGON_REQUESTED: { label: 'Wagon Requested', ownerRegion: 'ET', triggeredByDoc: false }, + FREIGHT_PAYMENT_PENDING: { label: 'Pending Payment', ownerRegion: 'CUST', triggeredByDoc: false }, + FREIGHT_PAYMENT_SETTLED: { label: 'Payment Settled', ownerRegion: 'CUST', triggeredByDoc: true }, + WAGON_ALLOCATED: { label: 'Wagon Allocated', ownerRegion: 'OPS', triggeredByDoc: false }, + EXPORT_TRANSPORT_ISSUED: { + label: 'Export Transport Document Issued', + ownerRegion: 'ET', + triggeredByDoc: true, + }, + CARGO_ARRIVED: { label: 'Cargo Arrived', ownerRegion: 'OPS', triggeredByDoc: false }, + READY_FOR_LOADING: { label: 'Ready for Loading', ownerRegion: 'OPS', triggeredByDoc: false }, + LOADED: { label: 'Loaded', ownerRegion: 'OPS', triggeredByDoc: false }, + DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, + ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, + GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, + OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, +}; + +/** + * Codes BEFORE (and including) this one are pre-booking β€” they attach to the + * contract clearance cycle. From the next code onward, milestones attach to the + * booking GL creates. Per doc Β§11.3 the booking is created right after DO_COLLECTED + * (import) / EXPORT_RELEASED (export), i.e. before WAGON_REQUESTED. + */ +const IMPORT_PRE_BOOKING_LAST = 'DO_COLLECTED'; +const EXPORT_PRE_BOOKING_LAST = 'EXPORT_RELEASED'; + +function buildDefs( + codes: readonly string[], + defs: Record>, +): MilestoneDef[] { + return codes.map((code) => ({ code, ...defs[code] })); +} + +export function milestonesForDirection(tradeDirection: string): MilestoneDef[] { + if (tradeDirection === 'IMPORT') return buildDefs(IMPORT_MILESTONES, IMPORT_DEFS); + if (tradeDirection === 'EXPORT') return buildDefs(EXPORT_MILESTONES, EXPORT_DEFS); + return []; +} + +/** Split the milestone list into pre-booking (contract) and post-booking (booking). */ +export function splitMilestones(tradeDirection: string): { + preBooking: MilestoneDef[]; + postBooking: MilestoneDef[]; +} { + const all = milestonesForDirection(tradeDirection); + const boundary = + tradeDirection === 'IMPORT' ? IMPORT_PRE_BOOKING_LAST : EXPORT_PRE_BOOKING_LAST; + const idx = all.findIndex((m) => m.code === boundary); + if (idx < 0) return { preBooking: all, postBooking: [] }; + return { preBooking: all.slice(0, idx + 1), postBooking: all.slice(idx + 1) }; +} + +/** The handoff milestone that flips primary ownership ET ↔ DJ (doc Β§11.5/Β§12.3). */ +export const HANDOFF_MILESTONES = ['DEPARTED_FROM_DJIBOUTI', 'DEPARTED_TO_DJIBOUTI']; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts new file mode 100644 index 000000000..e033f8e9b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -0,0 +1,358 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { + ClearanceMilestone, + CustomsRiskLevel, + MilestoneMetadata, +} from './entities/clearance-milestone.entity'; +import { Contract } from './entities/contract.entity'; +import { + HANDOFF_MILESTONES, + MilestoneDef, + milestonesForDirection, + splitMilestones, +} from './clearance-milestone.catalog'; + +/** + * Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking + * milestones attach to the contract clearance cycle; post-booking milestones attach + * to the booking. See docs/new-doc.md Β§5.12, Β§5.16, Β§11.3, Β§12.2. + */ +@Injectable() +export class ClearanceMilestoneService { + constructor(private readonly dataSource: DataSource) {} + + private get repo() { + return this.dataSource.getRepository(ClearanceMilestone); + } + + /** Seed the pre-booking milestones onto a contract's current clearance cycle. */ + async seedPreBookingMilestones( + contract: Contract, + clearanceCycleId: string, + ): Promise { + const { preBooking } = splitMilestones(contract.tradeDirection); + await this.seed(preBooking, { + contractId: contract.id, + clearanceCycleId, + }); + } + + /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */ + async seedPreBookingMilestonesOnBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + await this.seed(preBooking, { bookingId }); + } + + /** Seed the post-booking milestones onto a freshly created booking. */ + async seedPostBookingMilestones( + bookingId: string, + tradeDirection: string, + ): Promise { + const { postBooking } = splitMilestones(tradeDirection); + await this.seed(postBooking, { bookingId }); + } + + private async seed( + defs: MilestoneDef[], + scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string }, + ): Promise { + if (!defs.length) return; + const rows = defs.map((def, i) => + this.repo.create({ + ...scope, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: i, + }), + ); + await this.repo.save(rows); + } + + /** List milestones for a contract cycle or a booking. */ + async listForContract(contractId: string): Promise { + return this.repo.find({ + where: { contractId }, + order: { sortOrder: 'ASC' }, + }); + } + + async listForBooking(bookingId: string): Promise { + return this.repo.find({ + where: { bookingId }, + order: { sortOrder: 'ASC' }, + }); + } + + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); + } + + /** Mark a milestone complete (by code) on a booking. */ + async completeForBooking( + bookingId: string, + code: string, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + if (note) milestone.note = note; + const saved = await this.repo.save(milestone); + + if (HANDOFF_MILESTONES.includes(code)) { + await this.onHandoff(bookingId, code); + } + return saved; + } + + /** + * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED + * milestone on a booking (GL Import US-04 / Β§11.3 #19). Stores the level in the + * milestone metadata so the timeline shows it. + */ + async assignRisk( + bookingId: string, + riskLevel: CustomsRiskLevel, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); + } + + /** + * Advise duty & tax (amount + declaration serial) and complete the + * DUTY_TAXES_ADVISED milestone (Β§11.3 #6). The customer then uploads the + * payment slip, which doc-triggers DUTY_TAX_PAID. + */ + async adviseDuty( + bookingId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata( + bookingId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + note, + ); + } + + /** Complete a milestone and merge structured metadata onto it. */ + private async completeWithMetadata( + bookingId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + /** Mark a pre-booking milestone complete (by code) on a contract cycle. */ + async completeForContract( + contractId: string, + code: string, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + /** Skip optional milestones (e.g. duty when not required). */ + /** Reopen a completed contract milestone so review can continue after a query. */ + async reopenForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + /** Reopen a completed booking milestone so review can continue after a query. */ + async reopenForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone || milestone.status !== 'COMPLETED') return; + milestone.status = 'PENDING'; + milestone.triggeredAt = null; + milestone.triggeredByUserId = null; + await this.repo.save(milestone); + } + + async skipForContract(contractId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async skipForBooking(bookingId: string, code: string): Promise { + const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); + } + if (milestone.status === 'COMPLETED') return milestone; + milestone.status = 'SKIPPED'; + milestone.triggeredAt = new Date(); + return this.repo.save(milestone); + } + + async completeWithMetadataForBooking( + bookingId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + return this.completeWithMetadata(bookingId, code, metadata, userId, note); + } + + /** Complete a contract milestone with structured metadata (duty advice, etc.). */ + async completeWithMetadataForContract( + contractId: string, + code: string, + metadata: MilestoneMetadata, + userId?: string, + note?: string, + ): Promise { + const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); + if (!milestone) { + throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); + } + if (milestone.status === 'COMPLETED') { + return milestone; + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + async adviseDutyForContract( + contractId: string, + input: { amount: number; currency: string; declarationSerial?: string }, + userId?: string, + ): Promise { + return this.completeWithMetadataForContract( + contractId, + 'DUTY_TAXES_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency, + declarationSerial: input.declarationSerial, + }, + userId, + ); + } + + /** Complete a doc-triggered milestone when its document is uploaded/approved. */ + async completeByDocTrigger( + scope: { bookingId?: string; contractId?: string }, + code: string, + ): Promise { + const where = scope.bookingId + ? { bookingId: scope.bookingId, milestoneCode: code } + : { contractId: scope.contractId, milestoneCode: code }; + const milestone = await this.repo.findOne({ where }); + if (!milestone || milestone.status === 'COMPLETED') return; + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + await this.repo.save(milestone); + } + + /** + * ET ↔ DJ ownership handoff (doc Β§11.5/Β§12.3). On DEPARTED_FROM_DJIBOUTI the + * lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL + * Djibouti. Notifications are handled by the notification layer (out of scope); + * here we only record the ownership flip on subsequent pending milestones. + */ + private async onHandoff(bookingId: string, code: string): Promise { + void bookingId; + void code; + // Ownership region is already encoded per-milestone in the catalog; no + // mutation is required. This hook exists for the notification dispatch that + // the GL US-09 handoff requires once the notification module lands. + } + + /** Catalog passthrough for the frontend timeline (labels + owners). */ + catalogForDirection(tradeDirection: string): MilestoneDef[] { + return milestonesForDirection(tradeDirection); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts new file mode 100644 index 000000000..f30b64597 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.spec.ts @@ -0,0 +1,331 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import type { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import type { Contract } from './entities/contract.entity'; +import type { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; + +function ms( + code: string, + status: 'PENDING' | 'COMPLETED' | 'SKIPPED', + ownerRegion: 'ET' | 'DJ' | 'CUST' | 'OPS' = 'ET', +): ClearanceMilestone { + return { milestoneCode: code, status, ownerRegion } as ClearanceMilestone; +} + +function importThroughDeclaration(): ClearanceMilestone[] { + return [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'PENDING', 'ET'), + ms('DECLARED', 'PENDING', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; +} + +function makeService(milestones: ClearanceMilestone[]) { + const contractsRepository = { + currentCycle: jest.fn(), + update: jest.fn(), + setCycleStatus: jest.fn(), + updateCycle: jest.fn(), + }; + const milestoneService = { + listForContract: jest.fn().mockResolvedValue(milestones), + listForBooking: jest.fn().mockResolvedValue(milestones), + skipForContract: jest.fn(), + completeForContract: jest.fn(), + completeWithMetadataForContract: jest.fn(), + }; + const bookingsRepository = { update: jest.fn() }; + const service = new ClearanceWorkflowService( + contractsRepository as never, + milestoneService as never, + bookingsRepository as never, + ); + return { service, milestoneService, contractsRepository, bookingsRepository }; +} + +const importContract = { + id: 'c-import', + tradeDirection: 'IMPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +const exportContract = { + id: 'c-export', + tradeDirection: 'EXPORT', + customsClearingEnabled: true, + contractKind: 'ONE_TIME', +} as Contract; + +describe('ClearanceWorkflowService', () => { + describe('boundaryMilestone', () => { + it('uses DO_COLLECTED for import and EXPORT_RELEASED for export', () => { + const { service } = makeService([]); + expect(service.boundaryMilestone('IMPORT')).toBe('DO_COLLECTED'); + expect(service.boundaryMilestone('EXPORT')).toBe('EXPORT_RELEASED'); + }); + }); + + describe('assertPriorComplete', () => { + it('rejects when a prior milestone is still pending', async () => { + const milestones = importThroughDeclaration().map((m) => + m.milestoneCode === 'DOCUMENTS_APPROVED' + ? ms('DOCUMENTS_APPROVED', 'PENDING', 'ET') + : m, + ); + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'DECLARED'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows proceeding when prior milestones are completed or skipped', async () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('PENDING_DOCUMENT_REVIEW', 'COMPLETED', 'ET'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('UNDER_CUSTOMS_CLEARANCE', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const { service } = makeService(milestones); + await expect( + service.assertPriorComplete('c-import', 'IMPORT', 'TRANSIT_PERMIT_UPLOADED'), + ).resolves.toBeUndefined(); + }); + }); + + describe('isBoundaryComplete', () => { + it('returns true only when boundary milestone is completed', async () => { + const done = [ + ...importThroughDeclaration().slice(0, -1), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service: doneSvc } = makeService(done); + await expect(doneSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(true); + + const pending = importThroughDeclaration(); + const { service: pendingSvc } = makeService(pending); + await expect(pendingSvc.isBoundaryComplete('c-import', 'IMPORT')).resolves.toBe(false); + }); + }); + + describe('onDutySkipped', () => { + it('skips duty milestones on the contract', async () => { + const { service, milestoneService } = makeService([]); + await service.onDutySkipped('c-import'); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAXES_ADVISED', + ); + expect(milestoneService.skipForContract).toHaveBeenCalledWith( + 'c-import', + 'DUTY_TAX_PAID', + ); + }); + }); + + describe('computeNextAction β€” import happy path', () => { + it('prompts customer to upload docs first', () => { + const { service } = makeService([ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST')]); + const next = service.computeNextAction(importContract, null, [ + ms('IMPORT_DOCS_UPLOADED', 'PENDING', 'CUST'), + ]); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('IMPORT_DOCS_UPLOADED'); + }); + + it('prompts ET review after customer docs', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, null, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/Review/i); + }); + + it('prompts duty toggle when declaration done and duty unset', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: null } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/duty/i); + }); + + it('prompts customer duty slip when duty required and advised', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.milestoneCode).toBe('DUTY_TAX_PAID'); + }); + + it('skips duty path when duty not required', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'PENDING', 'ET'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.milestoneCode).toBe('TRANSIT_PERMIT_UPLOADED'); + }); + + it('prompts ET to finalize pre-clearance after transit permit', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { dutyRequired: false } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(importContract, cycle, milestones); + expect(next?.actor).toBe('GL_ET'); + expect(next?.action).toMatch(/finalize pre-clearance/i); + }); + + it('prompts DJ for DO then ET booking when pre-booking complete', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]; + const cycle = { + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const djNext = service.computeNextAction(importContract, cycle, milestones); + expect(djNext?.actor).toBe('GL_DJ'); + + const booked = milestones.map((m) => + m.milestoneCode === 'DO_COLLECTED' ? ms('DO_COLLECTED', 'COMPLETED', 'DJ') : m, + ); + const etNext = service.computeNextAction(importContract, cycle, booked); + expect(etNext?.actor).toBe('GL_ET'); + expect(etNext?.action).toMatch(/booking/i); + }); + }); + + describe('computeNextAction β€” export RO hold', () => { + it('surfaces DJ action when RO is on hold', () => { + const milestones = [ + ms('EXPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('RELEASE_ORDER_SECURED', 'PENDING', 'DJ'), + ]; + const cycle = { + roHoldReason: 'Vessel departs in 1 day(s) β€” minimum lead time is 2 day(s).', + } as ContractClearanceCycle; + const { service } = makeService(milestones); + const next = service.computeNextAction(exportContract, cycle, milestones); + expect(next?.actor).toBe('GL_DJ'); + expect(next?.blockedReason).toMatch(/minimum lead time/i); + }); + }); + + describe('inferPhase', () => { + it('places import contract in customer duty phase when duty outstanding', () => { + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAX_PAID', 'PENDING', 'CUST'), + ]; + const cycle = { dutyRequired: true } as ContractClearanceCycle; + const { service } = makeService(milestones); + const phase = service.inferPhase(importContract, cycle, milestones); + expect(phase).toBe(ContractDocPhase.CustomerDuty); + }); + }); + + describe('queue helpers', () => { + it('returns first pending ET-owned milestone code', () => { + const { service } = makeService([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]); + expect(service.etPendingMilestoneCodes([])).toBeNull(); + expect( + service.etPendingMilestoneCodes([ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'PENDING', 'ET'), + ]), + ).toBe('DOCUMENTS_APPROVED'); + }); + + it('returns first pending DJ-owned milestone code', () => { + const { service } = makeService([]); + expect( + service.djPendingMilestoneCodes([ + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'PENDING', 'DJ'), + ]), + ).toBe('DO_COLLECTED'); + }); + }); + + describe('computeNextActionForBooking', () => { + it('prompts customer to proceed after import boundary on booking', () => { + const booking = { + tradeDirection: 'IMPORT', + dutyRequired: false, + preClearanceFinalizedAt: new Date(), + } as Booking; + const milestones = [ + ms('IMPORT_DOCS_UPLOADED', 'COMPLETED', 'CUST'), + ms('DOCUMENTS_APPROVED', 'COMPLETED', 'ET'), + ms('DECLARED', 'COMPLETED', 'ET'), + ms('DUTY_TAXES_ADVISED', 'SKIPPED', 'ET'), + ms('DUTY_TAX_PAID', 'SKIPPED', 'CUST'), + ms('TRANSIT_PERMIT_UPLOADED', 'COMPLETED', 'ET'), + ms('DO_COLLECTED', 'COMPLETED', 'DJ'), + ]; + const { service } = makeService(milestones); + const next = service.computeNextActionForBooking(booking, milestones); + expect(next?.actor).toBe('CUSTOMER'); + expect(next?.action).toMatch(/operation/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts new file mode 100644 index 000000000..758da27ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.service.ts @@ -0,0 +1,566 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ContractDocPhase } from '@edr/types'; + +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { MilestoneMetadata } from './entities/clearance-milestone.entity'; +import { splitMilestones } from './clearance-milestone.catalog'; +import { Contract } from './entities/contract.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import type { ClearanceMetaState } from './clearance-workflow.types'; +import { metaFromBooking } from './clearance-workflow.types'; + +export type ClearanceActorRole = 'CUSTOMER' | 'GL_ET' | 'GL_DJ' | 'OPERATIONS'; + +export interface ClearanceNextAction { + actor: ClearanceActorRole; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; +} + +const IMPORT_BOUNDARY = 'DO_COLLECTED'; +const EXPORT_BOUNDARY = 'EXPORT_RELEASED'; + +const IMPORT_DOC_UPLOADED = 'IMPORT_DOCS_UPLOADED'; +const EXPORT_DOC_UPLOADED = 'EXPORT_DOCS_UPLOADED'; + +@Injectable() +export class ClearanceWorkflowService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly milestoneService: ClearanceMilestoneService, + private readonly bookingsRepository: BookingsRepository, + ) {} + + boundaryMilestone(tradeDirection: string): string { + return tradeDirection === 'IMPORT' ? IMPORT_BOUNDARY : EXPORT_BOUNDARY; + } + + // ── Contract scope (ONE_TIME) ───────────────────────────────────────────── + + async listMilestones(contractId: string): Promise { + return this.milestoneService.listForContract(contractId); + } + + async listMilestonesForBooking(bookingId: string): Promise { + return this.milestoneService.listForBooking(bookingId); + } + + async isBoundaryComplete(contractId: string, tradeDirection: string): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestones(contractId), + tradeDirection, + ); + } + + async isBoundaryCompleteForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + return this.isBoundaryCompleteForMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + ); + } + + private isBoundaryCompleteForMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + ): boolean { + const code = this.boundaryMilestone(tradeDirection); + const m = milestones.find((x) => x.milestoneCode === code); + return m?.status === 'COMPLETED'; + } + + async assertBoundaryComplete(contract: Contract): Promise { + const ok = await this.isBoundaryComplete(contract.id, contract.tradeDirection); + if (!ok) { + throw new BadRequestException( + `Pre-booking clearance is not complete β€” ${this.boundaryMilestone(contract.tradeDirection)} must be finished before booking.`, + ); + } + } + + async assertPriorComplete( + contractId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestones(contractId), + tradeDirection, + targetCode, + ); + } + + async assertPriorCompleteForBooking( + bookingId: string, + tradeDirection: string, + targetCode: string, + ): Promise { + await this.assertPriorCompleteOnMilestones( + await this.listMilestonesForBooking(bookingId), + tradeDirection, + targetCode, + ); + } + + private async assertPriorCompleteOnMilestones( + milestones: ClearanceMilestone[], + tradeDirection: string, + targetCode: string, + ): Promise { + const { preBooking } = splitMilestones(tradeDirection); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const targetIdx = preBooking.findIndex((d) => d.code === targetCode); + if (targetIdx < 0) return; + + for (let i = 0; i < targetIdx; i++) { + + const code = preBooking[i]!.code; + const m = byCode.get(code); + if (!m) continue; + if (m.status === 'SKIPPED') continue; + if (m.status !== 'COMPLETED') { + throw new BadRequestException( + `Complete "${preBooking[i]!.label}" before proceeding.`, + ); + } + } + } + + async skipMilestones(contractId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForContract(contractId, code); + } + } + + async skipMilestonesForBooking(bookingId: string, codes: string[]): Promise { + for (const code of codes) { + await this.milestoneService.skipForBooking(bookingId, code); + } + } + + async completeMilestone( + contractId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForContract( + contractId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForContract(contractId, code, userId); + } + + async completeMilestoneForBooking( + bookingId: string, + code: string, + userId?: string, + metadata?: MilestoneMetadata, + ): Promise { + if (metadata && Object.keys(metadata).length > 0) { + return this.milestoneService.completeWithMetadataForBooking( + bookingId, + code, + metadata, + userId, + ); + } + return this.milestoneService.completeForBooking(bookingId, code, userId); + } + + async onCustomerDocsUploaded(contractId: string, tradeDirection: string): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestone(contractId, uploaded); + await this.completeMilestone(contractId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onCustomerDocsUploadedForBooking( + bookingId: string, + tradeDirection: string, + ): Promise { + const uploaded = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + await this.completeMilestoneForBooking(bookingId, uploaded); + await this.completeMilestoneForBooking(bookingId, 'PENDING_DOCUMENT_REVIEW'); + } + + async onAllDocsApproved(contractId: string): Promise { + await this.completeMilestone(contractId, 'DOCUMENTS_APPROVED'); + } + + async onAllDocsApprovedForBooking(bookingId: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + /** Customer doc queried or re-uploaded β€” document approval milestone must reopen. */ + async onDocumentReviewReopened(contractId: string): Promise { + await this.milestoneService.reopenForContract(contractId, 'DOCUMENTS_APPROVED'); + } + + async onDocumentReviewReopenedForBooking(bookingId: string): Promise { + await this.milestoneService.reopenForBooking(bookingId, 'DOCUMENTS_APPROVED'); + } + + async onDeclarationUploaded(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestone(contractId, 'DECLARED', userId); + } + + async onDeclarationUploadedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'UNDER_CUSTOMS_CLEARANCE'); + await this.completeMilestoneForBooking(bookingId, 'DECLARED', userId); + } + + async onDutySkipped(contractId: string): Promise { + await this.skipMilestones(contractId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onDutySkippedForBooking(bookingId: string): Promise { + await this.skipMilestonesForBooking(bookingId, ['DUTY_TAXES_ADVISED', 'DUTY_TAX_PAID']); + } + + async onExportReleased(contractId: string, userId?: string): Promise { + await this.completeMilestone(contractId, 'EXPORT_RELEASED', userId); + await this.markReadyForBooking(contractId); + } + + async onExportReleasedForBooking(bookingId: string, userId?: string): Promise { + await this.completeMilestoneForBooking(bookingId, 'EXPORT_RELEASED', userId); + await this.markReadyForOperation(bookingId); + } + + async markReadyForBooking(contractId: string): Promise { + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + } + + /** GENERAL per-booking: boundary complete β†’ customer may proceed to operations. */ + async markReadyForOperation(bookingId: string): Promise { + await this.bookingsRepository.update(bookingId, { + status: 'CLEARANCE_READY', + clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance, + } as never); + } + + resolvePhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const meta: ClearanceMetaState = { + dutyRequired: cycle?.dutyRequired ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + currentPhase: cycle?.currentPhase ?? null, + }; + return this.resolvePhaseFromMeta(contract.tradeDirection, meta, milestones); + } + + resolvePhaseForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.resolvePhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private resolvePhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + if (meta.currentPhase) { + return meta.currentPhase as ContractDocPhase; + } + return this.inferPhaseFromMeta(tradeDirection, meta, milestones); + } + + inferPhase( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + return this.inferPhaseFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + }, + milestones, + ); + } + + inferPhaseForBooking(booking: Booking, milestones: ClearanceMilestone[]): ContractDocPhase { + return this.inferPhaseFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + ); + } + + private inferPhaseFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + ): ContractDocPhase { + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const isDone = (code: string) => + byCode.get(code)?.status === 'COMPLETED' || byCode.get(code)?.status === 'SKIPPED'; + + const docUploaded = + tradeDirection === 'IMPORT' + ? isDone(IMPORT_DOC_UPLOADED) + : isDone(EXPORT_DOC_UPLOADED); + + if (!docUploaded) return ContractDocPhase.CustomerIntake; + if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return ContractDocPhase.GlDjCollection; + } + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance; + return ContractDocPhase.GlEtPostClearance; + } + + if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput; + if (meta.dutyRequired === true && !isDone('DUTY_TAX_PAID')) { + return ContractDocPhase.CustomerDuty; + } + if (!isDone('TRANSIT_PERMIT_UPLOADED')) return ContractDocPhase.GlEtPostClearance; + if (!meta.preClearanceFinalizedAt) return ContractDocPhase.GlEtPostClearance; + if (!isDone(IMPORT_BOUNDARY)) return ContractDocPhase.GlDjCollection; + return ContractDocPhase.GlEtPostClearance; + } + + computeNextAction( + contract: Contract, + cycle: ContractClearanceCycle | null, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + contract.tradeDirection, + { + dutyRequired: cycle?.dutyRequired ?? null, + roHoldReason: cycle?.roHoldReason ?? null, + preClearanceFinalizedAt: cycle?.preClearanceFinalizedAt ?? null, + }, + milestones, + 'contract', + ); + } + + computeNextActionForBooking( + booking: Booking, + milestones: ClearanceMilestone[], + ): ClearanceNextAction | null { + return this.computeNextActionFromMeta( + booking.tradeDirection ?? 'IMPORT', + metaFromBooking(booking), + milestones, + 'booking', + ); + } + + private computeNextActionFromMeta( + tradeDirection: string, + meta: ClearanceMetaState, + milestones: ClearanceMilestone[], + terminalScope: 'contract' | 'booking', + ): ClearanceNextAction | null { + if (meta.roHoldReason) { + return { + actor: 'GL_DJ', + action: 'Re-upload Release Order or request port amendment', + milestoneCode: 'RELEASE_ORDER_SECURED', + blockedReason: meta.roHoldReason, + }; + } + + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + const pending = (code: string) => { + const m = byCode.get(code); + return m && m.status === 'PENDING'; + }; + const isDone = (code: string) => { + const m = byCode.get(code); + return m?.status === 'COMPLETED' || m?.status === 'SKIPPED'; + }; + + const docCode = + tradeDirection === 'IMPORT' ? IMPORT_DOC_UPLOADED : EXPORT_DOC_UPLOADED; + + if (pending(docCode) || !isDone(docCode)) { + return { + actor: 'CUSTOMER', + action: 'Upload clearance documents', + milestoneCode: docCode, + }; + } + + if (!isDone('DOCUMENTS_APPROVED')) { + return { + actor: 'GL_ET', + action: 'Review and approve customer documents', + milestoneCode: 'DOCUMENTS_APPROVED', + }; + } + + const terminalAction = + terminalScope === 'contract' + ? 'Create shipment booking' + : 'Proceed to request operation'; + + if (tradeDirection === 'EXPORT') { + if (!isDone('RELEASE_ORDER_SECURED')) { + return { + actor: 'GL_DJ', + action: 'Upload Release Order and vessel departure date', + milestoneCode: 'RELEASE_ORDER_SECURED', + }; + } + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + if (!isDone(EXPORT_BOUNDARY)) { + return { + actor: 'GL_ET', + action: 'Confirm export release', + milestoneCode: EXPORT_BOUNDARY, + }; + } + if (terminalScope === 'booking') { + if (!isDone('FREIGHT_PAYMENT_SETTLED')) { + return { + actor: 'CUSTOMER', + action: 'Pay freight charges', + milestoneCode: 'FREIGHT_PAYMENT_SETTLED', + }; + } + if (!isDone('WAGON_ALLOCATED')) { + return { + actor: 'OPERATIONS', + action: 'Allocate wagon', + milestoneCode: 'WAGON_ALLOCATED', + }; + } + if (!isDone('EXPORT_TRANSPORT_ISSUED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit', + milestoneCode: 'EXPORT_TRANSPORT_ISSUED', + }; + } + return null; + } + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: EXPORT_BOUNDARY, + }; + } + + if (!isDone('DECLARED')) { + return { + actor: 'GL_ET', + action: 'Upload customs declaration documents', + milestoneCode: 'DECLARED', + }; + } + + if (meta.dutyRequired === null || meta.dutyRequired === undefined) { + return { + actor: 'GL_ET', + action: 'Set whether duty/tax applies', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + + if (meta.dutyRequired && !isDone('DUTY_TAX_PAID')) { + if (!isDone('DUTY_TAXES_ADVISED')) { + return { + actor: 'GL_ET', + action: 'Advise duty and tax amount', + milestoneCode: 'DUTY_TAXES_ADVISED', + }; + } + return { + actor: 'CUSTOMER', + action: 'Upload duty/tax payment slip', + milestoneCode: 'DUTY_TAX_PAID', + }; + } + + if (!isDone('TRANSIT_PERMIT_UPLOADED')) { + return { + actor: 'GL_ET', + action: 'Upload transit permit screenshot', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!meta.preClearanceFinalizedAt) { + return { + actor: 'GL_ET', + action: 'Finalize pre-clearance', + milestoneCode: 'TRANSIT_PERMIT_UPLOADED', + }; + } + + if (!isDone(IMPORT_BOUNDARY)) { + return { + actor: 'GL_DJ', + action: 'Upload Delivery Order', + milestoneCode: IMPORT_BOUNDARY, + }; + } + + return { + actor: terminalScope === 'contract' ? 'GL_ET' : 'CUSTOMER', + action: terminalAction, + milestoneCode: IMPORT_BOUNDARY, + }; + } + + etPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'ET'); + return pending?.milestoneCode ?? null; + } + + djPendingMilestoneCodes(milestones: ClearanceMilestone[]): string | null { + const pending = milestones.find((m) => m.status === 'PENDING' && m.ownerRegion === 'DJ'); + return pending?.milestoneCode ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts new file mode 100644 index 000000000..25d01b17e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-workflow.types.ts @@ -0,0 +1,33 @@ +import type { ContractDocPhase } from '@edr/types'; + +/** Shared clearance metadata for contract cycles and per-booking GENERAL clearance. */ +export interface ClearanceMetaState { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + currentPhase?: ContractDocPhase | string | null; + preClearanceFinalizedAt?: Date | null; +} + +export type ClearanceScope = + | { kind: 'contract'; contractId: string } + | { kind: 'booking'; bookingId: string }; + +export function metaFromBooking(booking: { + dutyRequired?: boolean | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: Date | null; + roHoldReason?: string | null; + clearanceCurrentPhase?: string | null; + preClearanceFinalizedAt?: Date | null; +}): ClearanceMetaState { + return { + dutyRequired: booking.dutyRequired ?? null, + vesselDepartureDate: booking.vesselDepartureDate ?? null, + roAmendmentRequestedAt: booking.roAmendmentRequestedAt ?? null, + roHoldReason: booking.roHoldReason ?? null, + currentPhase: booking.clearanceCurrentPhase ?? null, + preClearanceFinalizedAt: booking.preClearanceFinalizedAt ?? null, + }; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts new file mode 100644 index 000000000..b0a5cc636 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -0,0 +1,590 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { RuleEngineService } from '../rule-engine/rule-engine.service'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { hasFreightPermission } from '../../common/freight-permission.util'; + +import { Contract } from './entities/contract.entity'; +import { ContractRoute } from './entities/contract-route.entity'; +import { ContractsRepository } from './contracts.repository'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; + +/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */ +const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED']; + +export interface CreateBookingUnderContractResult { + booking: Booking; + warnings: string[]; +} + +/** + * The single create path for shipment bookings under a contract. + * + * - Path A (transport only): the customer creates the booking once the contract + * is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled. + * - Path B (customs clearance): only GL Ethiopia creates the booking, once the + * contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters + * shipment data. + * + * From booking creation onward the existing batch/payment/allocation pipeline + * runs unchanged. See docs/new-doc.md Β§8, Β§13. + */ +@Injectable() +export class ContractBookingService { + private readonly logger = new Logger(ContractBookingService.name); + + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingPricingService: BookingPricingService, + private readonly containerTypesService: ContainerTypesService, + private readonly ruleEngineService: RuleEngineService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly workflowService: ClearanceWorkflowService, + private readonly invoiceService: BookingInvoiceService, + private readonly dataSource: DataSource, + ) {} + + async createUnderContract( + contractId: string, + dto: CreateBookingUnderContractDto, + user?: { id?: string } | null, + actorPermissions?: unknown, + ): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + // GL Ethiopia is identified by the dedicated contract create-booking permission + // (granted to the edr_gl_ethiopia preset). + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + + const createdByRole = await this.assertGate(contract, isGlActor); + + // Validity window must still be open. + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired β€” no new bookings.'); + } + + // ONE_TIME: a single shipment at a time. The slot frees only if the prior + // booking reached a terminal state (e.g. payment expired without shipping), + // letting the customer re-book within contract validity (doc Β§10.4). + if (contract.contractKind === 'ONE_TIME') { + const active = await this.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + 'This one-time contract already has an active booking.', + ); + } + } else { + // GENERAL: draw down against the cargo quantity cap until it is full. + await this.assertWithinQuantityCap(contract, dto); + } + + const route = await this.resolveRoute(contract, dto.contractRouteId); + const warnings: string[] = []; + + const reference = await this.generateReference(); + const freightType = contract.freightType; + + // GENERAL + customs (Path B) runs per-booking clearance: the booking starts + // in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to + // operations, and there is NO contract-level clearance cycle to link. + const generalCustoms = + contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + + // Denormalize route/direction/freight onto the booking for the scheduling engine. + const booking = await this.bookingsRepository.create({ + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING', + bookingType: 'ONE_TIME', + contractId: contract.id, + contractRouteId: route?.id ?? null, + contractKind: contract.contractKind, + createdByRole, + createdByUserId: user?.id ?? null, + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: 'NEW', + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? null, + equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + tradeDirection: contract.tradeDirection, + freightType, + cargoTypeId: this.resolveCargoTypeId(contract, dto), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + cargoTotalWeightVgm: this.resolveBulkTons(dto), + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + firstMilePickupLat: contract.firstMilePickupLat ?? null, + firstMilePickupLng: contract.firstMilePickupLng ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, + } as never); + + // Persist container lines + per-unit container numbers (container freight only). + if (freightType === 'CONTAINER') { + await this.persistContainers(booking.id, contract, dto); + } + + // Reload with containers to compute the total from contract unit rates Γ— qty. + const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id); + if (loaded) { + if (freightType === 'CONTAINER') { + await this.applyWeightResults(loaded); + } + const computed = await this.bookingPricingService.computePriceForBooking(loaded); + await this.bookingsRepository.update(booking.id, { + totalAmount: computed.totalAmount, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems: computed.lineItems, + totalAmount: computed.totalAmount, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + await this.bookingPricingService.createPricingSnapshots( + booking.id, + computed.usedRates, + computed.appliedModifiers, + ); + warnings.push(...computed.warnings); + } + + // ONE_TIME customs (legacy contract-cycle path): link the contract clearance + // cycle to this booking, seed post-booking milestones, and lock the contract + // to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL β€” it has no contract cycle + // and must stay CONTRACT_ACTIVE so further shipment requests can be accepted. + if (contract.customsClearingEnabled && !generalCustoms) { + const cycle = await this.contractsRepository.currentCycle(contract.id); + if (cycle) { + await this.contractsRepository.linkBooking(cycle.id, booking.id); + } + await this.milestoneService.seedPostBookingMilestones( + booking.id, + contract.tradeDirection, + ); + await this.contractsRepository.update(contract.id, { + status: 'ACTIVE_SHIPMENT_IN_PROGRESS', + clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', + } as never); + } else if (generalCustoms) { + // Per-booking clearance: seed full milestone timeline on the booking. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); + await this.milestoneService.seedPostBookingMilestones( + booking.id, + contract.tradeDirection, + ); + } + + const result = await this.bookingsRepository.findByIdWithFiles(booking.id); + + // Contract bookings are born past the billable gate (the contract is already + // executed), so the invoice is generated here β€” they never pass through the + // legacy marketingApprove β†’ FULLY_EXECUTED path that invoices direct bookings. + // Idempotent and non-blocking: a billing hiccup must not undo the booking. + // Skips silently when unbillable (no company / no priced amount). + await this.invoiceService + .ensureInvoiceForBooking(result ?? booking) + .catch((err) => + this.logger.error( + `Failed to generate invoice for contract booking ${booking.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + + return { booking: result ?? booking, warnings }; + } + + /** + * Returns the role to stamp on the booking, or throws if the caller is not + * allowed to create one for this contract's execution path. + */ + private async assertGate(contract: Contract, isGlActor: boolean): Promise { + if (contract.customsClearingEnabled) { + // Path B β€” Global Logistics creates the booking ON BEHALF OF the customer. + // The customer never books a customs contract himself. + if (!isGlActor) { + throw new ForbiddenException( + 'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.', + ); + } + if (contract.contractKind === 'GENERAL') { + // GENERAL customs has NO contract clearance cycle β€” GL books per accepted + // shipment request while the contract is active; clearance is per booking. + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new BadRequestException( + 'Contract must be active to book a shipment.', + ); + } + return 'GL_ET'; + } + // ONE_TIME customs β€” pre-booking boundary milestone must be complete. + const boundaryOk = await this.workflowService.isBoundaryComplete( + contract.id, + contract.tradeDirection, + ); + if (!boundaryOk) { + throw new BadRequestException( + 'Pre-booking clearance is not complete β€” booking cannot be created yet.', + ); + } + return 'GL_ET'; + } + + // Path A β€” customer (or staff) once the contract is executed. + if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) { + throw new BadRequestException( + 'Contract must be fully executed before booking a shipment.', + ); + } + return isGlActor ? 'STAFF' : 'CUSTOMER'; + } + + private async countActiveBookings(contractId: string): Promise { + return this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .where('b.contract_id = :contractId', { contractId }) + .andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES }) + .getCount(); + } + + // ── GENERAL contract quantity cap (draw-down) ────────────────────────────── + + /** + * Reject a GENERAL booking whose cargo would exceed the contract's quantity + * cap. Container caps are per size; bulk is a single tons/items cap. Bookings + * that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold. + */ + /** + * Capacity check for a SHIPMENT REQUEST (no per-unit data) β€” mirrors + * {@link assertWithinQuantityCap} but reads the request's quantity shape. + */ + async assertRequestWithinCapacity( + contract: Contract, + lines: { + containers?: Array<{ containerSize: string; quantity: number }>; + bulk?: { cargoWeightTons?: number; itemCount?: number }; + }, + ): Promise { + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped contract + + if (contract.freightType === 'CONTAINER') { + for (const line of lines.containers ?? []) { + const cap = capacity.find((c) => c.containerSize === line.containerSize); + if (!cap || cap.remaining == null) continue; + if (line.quantity > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`, + ); + } + } + } else { + const requested = + (lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0; + const cap = capacity.find((c) => c.cap != null); + if (cap && cap.remaining != null && requested > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} remain on this contract.`, + ); + } + } + } + + private async assertWithinQuantityCap( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const capacity = await this.computeCapacity(contract); + if (capacity.length === 0) return; // uncapped contract + + if (contract.freightType === 'CONTAINER') { + for (const line of dto.containers ?? []) { + const cap = capacity.find((c) => c.containerSize === line.containerSize); + if (!cap || cap.remaining == null) continue; // size uncapped + if (line.quantity > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`, + ); + } + } + } else { + const requested = + (dto.bulkLines ?? []).reduce( + (sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0), + 0, + ) || this.resolveBulkTons(dto) || 0; + const cap = capacity.find((c) => c.cap != null); + if (cap && cap.remaining != null && requested > cap.remaining) { + throw new BadRequestException( + `Only ${cap.remaining} of ${cap.cap} remain on this contract.`, + ); + } + } + } + + /** + * Remaining bookable quantity per cargo-scope line: cap minus what prior + * bookings already consumed. Returns [] when the contract has no caps. + */ + async computeCapacity( + contract: Contract, + ): Promise< + Array<{ + containerSize?: string | null; + cargoTypeId?: string | null; + cap: number | null; + booked: number; + remaining: number | null; + }> + > { + const scope = contract.cargoScope ?? []; + const capped = scope.filter((s) => s.quantityCap != null); + if (capped.length === 0) return []; + + const booked = await this.bookedQuantities(contract); + return capped.map((s) => { + const cap = Number(s.quantityCap); + const used = + contract.freightType === 'CONTAINER' + ? (booked.bySize.get(s.containerSize ?? '') ?? 0) + : booked.bulk; + return { + containerSize: s.containerSize, + cargoTypeId: s.cargoTypeId, + cap, + booked: used, + remaining: Math.max(0, cap - used), + }; + }); + } + + /** + * Quantities already booked under a contract that still hold capacity. Excludes + * bookings that never shipped (CANCELLED / REJECTED / EXPIRED). + */ + private async bookedQuantities( + contract: Contract, + ): Promise<{ bySize: Map; bulk: number }> { + const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED']; + if (contract.freightType === 'CONTAINER') { + const rows = await this.dataSource + .getRepository(BookingContainer) + .createQueryBuilder('bc') + .innerJoin(Booking, 'b', 'b.id = bc.booking_id') + .select('bc.container_size', 'size') + .addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty') + .where('b.contract_id = :contractId', { contractId: contract.id }) + .andWhere('b.status NOT IN (:...releasing)', { releasing }) + .groupBy('bc.container_size') + .getRawMany<{ size: string | null; qty: string }>(); + const bySize = new Map(); + for (const r of rows) bySize.set(r.size ?? '', Number(r.qty)); + return { bySize, bulk: 0 }; + } + + const row = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons') + .where('b.contract_id = :contractId', { contractId: contract.id }) + .andWhere('b.status NOT IN (:...releasing)', { releasing }) + .getRawOne<{ tons: string }>(); + return { bySize: new Map(), bulk: Number(row?.tons ?? 0) }; + } + + private async resolveRoute( + contract: Contract, + contractRouteId?: string, + ): Promise { + const routes = contract.routes ?? []; + if (contractRouteId) { + const found = routes.find((r) => r.id === contractRouteId); + if (!found) { + throw new BadRequestException('Selected route is not part of this contract.'); + } + return found; + } + // ONE_TIME (or single-route GENERAL): auto-select the only route. + if (routes.length === 1) return routes[0]; + if (routes.length === 0) return null; + throw new BadRequestException( + 'contractRouteId is required for multi-route general contracts.', + ); + } + + private resolveCargoTypeId( + contract: Contract, + dto: CreateBookingUnderContractDto, + ): string | null { + if (contract.freightType === 'BULK') { + const bulk = dto.bulkLines?.[0]; + if (bulk?.cargoTypeId) return bulk.cargoTypeId; + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + return scope?.cargoTypeId ?? null; + } + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + return scope?.cargoTypeId ?? null; + } + + private resolveBulkTons(dto: CreateBookingUnderContractDto): number { + if (!dto.bulkLines?.length) return 0; + return dto.bulkLines.reduce( + (sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0), + 0, + ); + } + + /** + * Map each contract-scope container size to a concrete container type and + * persist the booking_container line + its per-unit container numbers. Weight + * rule results are filled in afterward by {@link applyWeightResults} once all + * lines exist (a single rule-engine pass over the booking). + */ + private async persistContainers( + bookingId: string, + contract: Contract, + dto: CreateBookingUnderContractDto, + ): Promise { + const lines = dto.containers ?? []; + if (!lines.length) { + throw new BadRequestException('At least one container line is required.'); + } + + const allowedSizes = new Set( + (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s), + ); + + const containerRepo = this.dataSource.getRepository(BookingContainer); + const unitRepo = this.dataSource.getRepository(BookingContainerUnit); + + for (const line of lines) { + if (allowedSizes.size && !allowedSizes.has(line.containerSize)) { + throw new BadRequestException( + `Container size ${line.containerSize} is outside the contract scope.`, + ); + } + + const containerType = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + + const vgmPerUnit = line.units.length + ? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length + : 0; + const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0); + + const containerRow = await containerRepo.save( + containerRepo.create({ + bookingId, + containerTypeId: containerType.id, + containerSize: line.containerSize, + quantity: line.quantity, + hazardousQuantity: line.hazardousQuantity ?? 0, + reeferQuantity: line.reeferQuantity ?? 0, + vgmPerUnitTons: vgmPerUnit, + totalVgmTons: totalVgm, + wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)), + isOverweight: false, + overweightExcessTons: null, + } as Partial), + ); + + let sortOrder = 0; + for (const unit of line.units) { + await unitRepo.save( + unitRepo.create({ + bookingContainerId: containerRow.id, + containerNumber: unit.containerNumber, + sealNumber: unit.sealNumber ?? null, + vgmTons: unit.vgmTons, + isHazardous: unit.isHazardous ?? false, + isReefer: unit.isReefer ?? false, + sortOrder: sortOrder++, + }), + ); + } + } + } + + /** + * Run the rule engine once over the freshly-created booking and persist the + * overweight result per container line (same ordering the engine returns). + */ + private async applyWeightResults(booking: Booking): Promise { + const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking); + const ruleResult = await this.ruleEngineService.evaluate(evalInput); + const containers = booking.bookingContainers ?? []; + const containerRepo = this.dataSource.getRepository(BookingContainer); + for (let i = 0; i < containers.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr) continue; + await containerRepo.update(containers[i].id, { + weightLimitRuleId: wr.weightLimitRuleId, + isOverweight: wr.isOverweight, + overweightExcessTons: wr.overweightExcessTons, + }); + } + } + + /** Pick the default container type for a size; prefer reefer when requested. */ + private async resolveContainerTypeForSize( + size: string, + preferReefer: boolean, + ): Promise { + const sizeFt = parseInt(size, 10); + const { data } = await this.containerTypesService.findAll({ pageSize: 200 }); + const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false); + if (!types.length) { + throw new BadRequestException(`No container type configured for size ${size}.`); + } + if (preferReefer) { + const reefer = types.find((t) => t.isReefer); + if (reefer) return reefer; + } + return types.find((t) => !t.isReefer) ?? types[0]; + } + + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + const seq = String(count + 1).padStart(6, '0'); + return `BK-${year}-${seq}`; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts new file mode 100644 index 000000000..73a4fe117 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -0,0 +1,1392 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; + +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; +import { FilesService } from '../files/files.service'; +import { ContractsRepository } from './contracts.repository'; +import { ContractsService, PaginatedContracts } from './contracts.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { contractClearanceCodes } from './contract-clearance.util'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { Contract } from './entities/contract.entity'; +import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; +import { FilterContractDto } from './dto/filter-contract.dto'; +import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_CONTRACT_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; + +const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; + +export interface ContractClearanceDocument { + fileKey: string; + label: string; + required: boolean; + uploadedBy: 'customer' | 'gl'; + settingCode: string; + file: { id: string; name: string; url: string } | null; + reviewStatus: ContractDocReviewStatus | null; + note: string | null; + /** When the review decision (approve/query) was recorded. */ + reviewedAt: string | null; + /** Staff id that recorded the decision (no user directory to resolve names). */ + reviewedByStaffId: string | null; +} + +export interface ContractClearanceView { + contractId: string; + status: string; + clearanceStatus: string; + cycleNumber: number; + includesCustoms: boolean; + inputCode: string | null; + outputCode: string | null; + documents: ContractClearanceDocument[]; + allApproved: boolean; + phase?: string | null; + milestones?: Array<{ + id: string; + milestoneCode: string; + milestoneLabel: string; + status: string; + ownerRegion?: string | null; + metadata?: Record | null; + sortOrder: number; + }>; + nextAction?: { + actor: string; + action: string; + milestoneCode?: string | null; + blockedReason?: string | null; + } | null; + dutyRequired?: boolean | null; + roHold?: boolean; + roHoldReason?: string | null; + vesselDepartureDate?: string | null; + roAmendmentRequestedAt?: string | null; + bookingReady?: boolean; + preClearanceFinalized?: boolean; + /** Export post-booking clearance finalized (transit permit uploaded + GL confirmed). */ + exportClearanceFinalized?: boolean; + linkedBookingId?: string | null; + dutyAdvice?: { + amount: number; + currency: string; + declarationSerial?: string | null; + noticeFile?: { id: string; name: string; url: string } | null; + } | null; + workflowFiles?: ReturnType; + /** Import post-allocation T1 transit document state (null until a booking is linked). */ + t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; +} + +@Injectable() +export class ContractClearanceService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly contractsService: ContractsService, + private readonly bookingsService: BookingsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + private readonly workflowService: ClearanceWorkflowService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly dropdownSettingsService: DropdownSettingsService, + private readonly glOperationsService: GlOperationsService, + ) {} + + private isPhasedCustoms(contract: Contract): boolean { + return contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME'; + } + + private assertPhasedCustoms(contract: Contract): void { + if (!this.isPhasedCustoms(contract)) { + throw new BadRequestException( + 'Phased clearance (Phase 1) applies to one-time customs contracts.', + ); + } + } + + /** + * Legacy finalize() used to set CLEARANCE_READY_FOR_BOOKING without completing + * phased milestones. Revert that state so declaration / DO steps can proceed. + */ + private async reconcilePrematureBookingReady( + contractId: string, + contract: Contract, + bookingReady: boolean, + ): Promise { + if ( + !this.isPhasedCustoms(contract) || + contract.status !== 'CLEARANCE_READY_FOR_BOOKING' || + bookingReady + ) { + return contract; + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + if (cycle?.status === 'CLEARANCE_READY_FOR_BOOKING') { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + return this.contractsService.findById(contractId); + } + + /** The pre-booking clearance document grid for a contract (Path B). */ + async getClearanceView(contractId: string): Promise { + let contract = await this.contractsService.findById(contractId); + const { inputCode, outputCode, includesCustoms } = contractClearanceCodes(contract); + const cycle = await this.contractsRepository.currentCycle(contractId); + + const files = await this.filesService.findByResource(contractId, 'contracts'); + const fileByCode = new Map(files.map((f) => [f.code, f])); + const reviews = await this.contractsRepository.findDocumentReviews( + contractId, + cycle?.id ?? null, + ); + const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r])); + + const documents: ContractClearanceDocument[] = []; + + const pushSetting = async (code: string | null, uploadedBy: 'customer' | 'gl') => { + if (!code) return; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(code); + } catch { + return; // setting not seeded β€” skip gracefully + } + for (const field of setting.fields ?? []) { + const file = fileByCode.get(field.fileKey) ?? null; + const review = reviewByKey.get(`${code}:${field.fileKey}`) ?? null; + documents.push({ + fileKey: field.fileKey, + label: field.fileLabel, + required: field.isRequired, + uploadedBy, + settingCode: code, + file: file ? { id: file.id, name: file.name, url: file.url } : null, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, + reviewedByStaffId: review?.reviewedByStaffId ?? null, + }); + } + }; + + await pushSetting(inputCode, 'customer'); + await pushSetting(outputCode, 'gl'); + + // Ad-hoc / unknown documents (code custom_*) appear alongside the seeded set. + for (const f of files) { + if (!f.code?.startsWith('custom_')) continue; + const review = reviewByKey.get(`custom:${f.code}`) ?? null; + documents.push({ + fileKey: f.code, + label: f.name, + required: false, + uploadedBy: 'customer', + settingCode: 'custom', + file: { id: f.id, name: f.name, url: f.url }, + reviewStatus: review?.status ?? null, + note: review?.note ?? null, + reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, + reviewedByStaffId: review?.reviewedByStaffId ?? null, + }); + } + + const allApproved = await this.isClearanceFullyApproved(contract); + const milestones = await this.workflowService.listMilestones(contractId); + let boundary = await this.workflowService.isBoundaryComplete( + contractId, + contract.tradeDirection, + ); + contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary); + const phase = this.workflowService.resolvePhase(contract, cycle, milestones); + const dutyAdvice = this.buildDutyAdvice(files, milestones); + let workflowFiles = buildWorkflowFiles( + files, + contract.tradeDirection ?? 'IMPORT', + ); + let bookingFiles: Awaited> = []; + if (cycle?.bookingId) { + bookingFiles = await this.filesService.findByResource( + cycle.bookingId, + 'bookings', + ); + const bookingWorkflow = buildWorkflowFiles( + bookingFiles, + contract.tradeDirection ?? 'IMPORT', + ); + const byCode = new Map(workflowFiles.map((f) => [f.code, f])); + for (const row of bookingWorkflow) { + if (row.file) byCode.set(row.code, row); + } + workflowFiles = [...byCode.values()]; + } + + let t1: ClearanceT1State | null = null; + if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') { + try { + t1 = await this.glOperationsService.t1State(cycle.bookingId); + } catch { + t1 = null; // linked booking missing β€” view stays usable + } + } + + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { + const booking = await this.bookingsService.findById(cycle.bookingId); + if (booking) { + nextAction = this.workflowService.computeNextActionForBooking( + booking, + bookingMilestones, + ); + } + } + + return { + contractId, + status: contract.status, + clearanceStatus: contract.clearanceStatus, + cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + phase, + milestones: milestones.map((m) => ({ + id: m.id, + milestoneCode: m.milestoneCode, + milestoneLabel: m.milestoneLabel, + status: m.status, + ownerRegion: m.ownerRegion, + metadata: (m.metadata ?? null) as Record | null, + sortOrder: m.sortOrder, + })), + nextAction, + dutyRequired: cycle?.dutyRequired ?? null, + roHold: Boolean(cycle?.roHoldReason), + roHoldReason: cycle?.roHoldReason ?? null, + vesselDepartureDate: cycle?.vesselDepartureDate ?? null, + roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt + ? cycle.roAmendmentRequestedAt.toISOString() + : null, + bookingReady: boundary, + preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt), + exportClearanceFinalized: Boolean(cycle?.completedAt), + linkedBookingId: cycle?.bookingId ?? null, + dutyAdvice, + workflowFiles, + t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', + }; + } + + private buildDutyAdvice( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + milestones: ClearanceMilestone[], + ): ContractClearanceView['dutyAdvice'] { + const advised = milestones.find( + (m) => m.milestoneCode === 'DUTY_TAXES_ADVISED' && m.status === 'COMPLETED', + ); + if (!advised?.metadata) return null; + const amount = advised.metadata.dutyAmount; + const currency = advised.metadata.dutyCurrency; + if (typeof amount !== 'number' || typeof currency !== 'string') return null; + const notice = files.find((f) => f.code === 'duty_tax_notice'); + return { + amount, + currency, + declarationSerial: + typeof advised.metadata.declarationSerial === 'string' + ? advised.metadata.declarationSerial + : null, + noticeFile: notice + ? { id: notice.id, name: notice.name, url: notice.url } + : null, + }; + } + + /** + * True when every REQUIRED customer-input field has an APPROVED review row in + * the current cycle. The 100% gate before clearance can be finalized. + */ + private async isClearanceFullyApproved(contract: Contract): Promise { + const { inputCode } = contractClearanceCodes(contract); + if (!inputCode) return true; + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return false; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return true; + + const cycle = await this.contractsRepository.currentCycle(contract.id); + const reviews = await this.contractsRepository.findDocumentReviews( + contract.id, + cycle?.id ?? null, + ); + return required.every((field) => + reviews.some( + (r) => + r.settingCode === inputCode && + r.fileKey === field.fileKey && + r.status === 'APPROVED', + ), + ); + } + + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + + /** + * Customer uploads clearance documents on the contract. When every required + * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. + */ + async uploadDocuments( + contractId: string, + files: Express.Multer.File[], + ): Promise { + const contract = await this.contractsService.findById(contractId); + if ( + contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + contract.status !== 'CLEARANCE_UNDER_REVIEW' + ) { + throw new ConflictException( + `Cannot upload clearance documents on status "${contract.status}".`, + ); + } + const { inputCode } = contractClearanceCodes(contract); + if (!inputCode) { + throw new BadRequestException('This contract has no document-clearance step'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + + // First submission: every required input field must be present. + if (contract.status === 'AWAITING_CLEARANCE_DOCUMENTS') { + await this.assertRequiredInputsPresent(contractId, inputCode, files); + } + + for (const file of files) { + const record = await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: file.fieldname, + file, + }); + const settingCode = file.fieldname.startsWith('custom_') ? 'custom' : inputCode; + await this.contractsRepository.upsertDocumentReviewPending({ + contractId, + clearanceCycleId: cycle?.id ?? null, + settingCode, + fileKey: file.fieldname, + fileRecordId: record.id, + uploadedByRole: 'CUSTOMER', + }); + } + + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW', { + currentPhase: ContractDocPhase.GlEtReview, + }); + } + + if (contract.customsClearingEnabled && contract.contractKind === 'ONE_TIME') { + await this.workflowService.onCustomerDocsUploaded(contractId, contract.tradeDirection); + await this.workflowService.onDocumentReviewReopened(contractId); + } + + return this.contractsService.findById(contractId); + } + + private async assertRequiredInputsPresent( + contractId: string, + inputCode: string, + files: Express.Multer.File[], + ): Promise { + let setting; + try { + setting = await this.fileUploadSettingsService.getByCode(inputCode); + } catch { + return; + } + const required = (setting.fields ?? []).filter((f) => f.isRequired); + if (required.length === 0) return; + + const existing = await this.filesService.findByResource(contractId, 'contracts'); + const presentKeys = new Set([ + ...existing.map((f) => f.code), + ...files.map((f) => f.fieldname), + ]); + + const missing = required.filter((f) => !presentKeys.has(f.fileKey)); + if (missing.length > 0) { + const labels = missing.map((f) => f.fileLabel).join(', '); + throw new BadRequestException( + `Please upload all required documents before submitting: ${labels}`, + ); + } + } + + /** + * Path A (no customs) β€” the customer clears the cargo himself and uploads his + * own clearance proof, reviewed by Operations rather than GL. True when a + * clearance doc set resolves for a non-customs contract. + */ + private isSelfClear(contract: Contract): boolean { + if (contract.customsClearingEnabled) return false; + return contractClearanceCodes(contract).inputCode != null; + } + + /** GL ET (Path B) reviews a single document: APPROVED or QUERIED. */ + async reviewDocument( + contractId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + note?: string, + ): Promise { + return this.applyReview(contractId, fileKey, status, staffId, 'GL_ET', note); + } + + /** + * Operations (Path A) reviews a customer self-clearance document. Identical + * approve/query loop to {@link reviewDocument}; rejects customs (Path B) + * contracts, which are GL-reviewed. + */ + async opsReviewDocument( + contractId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + note?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + if (!this.isSelfClear(contract)) { + throw new ConflictException( + 'Operations review applies only to self-clearance (non-customs) contracts.', + ); + } + return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note); + } + + private async applyReview( + contractId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + reviewerRole: 'GL_ET' | 'OPERATIONS', + note?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertClearanceReviewableStatus(contract); + if (status === 'QUERIED' && !note?.trim()) { + throw new BadRequestException('A note is required when querying a document'); + } + + const { inputCode, outputCode } = contractClearanceCodes(contract); + const cycle = await this.contractsRepository.currentCycle(contractId); + if ( + status === 'QUERIED' && + this.isPhasedCustoms(contract) && + cycle?.preClearanceFinalizedAt + ) { + throw new BadRequestException( + 'Customer documents cannot be queried after pre-clearance is finalized.', + ); + } + const reviews = await this.contractsRepository.findDocumentReviews( + contractId, + cycle?.id ?? null, + ); + const match = reviews.find((r) => r.fileKey === fileKey); + const settingCode = + match?.settingCode ?? + (fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom')); + + await this.contractsRepository.setDocumentReviewStatus({ + contractId, + clearanceCycleId: cycle?.id ?? null, + settingCode, + fileKey, + status, + staffId, + note, + }); + + if (status === 'QUERIED') { + await this.contractsRepository.createReviewNote( + contractId, + `Document "${fileKey}" queried: ${note}`, + 'CHANGES_REQUESTED', + staffId, + reviewerRole, + ); + // Return the contract to the customer to re-upload the queried document. + await this.contractsRepository.update(contractId, { + status: 'AWAITING_CLEARANCE_DOCUMENTS', + clearanceStatus: 'AWAITING_DOCUMENTS', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); + } + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onDocumentReviewReopened(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtReview, + }); + } + } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); + const refreshed = await this.contractsService.findById(contractId); + if ( + refreshed.customsClearingEnabled && + refreshed.contractKind === 'ONE_TIME' && + (await this.isClearanceFullyApproved(refreshed)) + ) { + await this.workflowService.onAllDocsApproved(contractId); + const c = await this.contractsRepository.currentCycle(contractId); + if (c) { + await this.contractsRepository.updateCycle(c.id, { + currentPhase: + refreshed.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + } + } + + return this.contractsService.findById(contractId); + } + + /** GL uploads customs output documents (IM4/IM5/EX3/etc.) during clearance. */ + async uploadOutputDocuments( + contractId: string, + files: Express.Multer.File[], + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertClearanceOutputUploadableStatus(contract); + const { outputCode } = contractClearanceCodes(contract); + if (!outputCode) { + throw new BadRequestException('This contract has no customs output documents'); + } + if (files.length === 0) { + throw new BadRequestException('No documents uploaded'); + } + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: file.fieldname, + file, + }); + } + return this.contractsService.findById(contractId); + } + + /** + * GL ET finalizes Path B pre-booking clearance: requires every customer + * document APPROVED. For phased customs (ONE_TIME), document review completes + * here β€” booking readiness is set only after delivery order (import) or export + * release via the milestone workflow. Non-phased customs still jump straight to + * CLEARANCE_READY_FOR_BOOKING. Rejects self-clearance (Path A) contracts. + */ + async finalize(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + if (this.isSelfClear(contract)) { + throw new ConflictException( + 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', + ); + } + this.assertClearanceFinalizableStatus(contract); + + const approved = await this.isClearanceFullyApproved(contract); + if (!approved) { + throw new BadRequestException( + 'All required documents must be approved before clearance can be finalized', + ); + } + + if (this.isPhasedCustoms(contract)) { + await this.workflowService.onAllDocsApproved(contractId); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlDjCollection + : ContractDocPhase.GlEtOutput, + }); + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + return this.contractsService.findById(contractId); + } + + const { outputCode } = contractClearanceCodes(contract); + if (outputCode) { + const setting = await this.fileUploadSettingsService.getByCode(outputCode); + const files = await this.filesService.findByResource(contractId, 'contracts'); + const uploaded = new Set(files.map((f) => f.code)); + const missing = (setting.fields ?? []).filter( + (f) => f.isRequired && !uploaded.has(f.fileKey), + ); + if (missing.length > 0) { + throw new BadRequestException( + `Upload all required customs output documents first: ${missing + .map((m) => m.fileLabel) + .join(', ')}`, + ); + } + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_READY_FOR_BOOKING', + clearanceStatus: 'CLEARANCE_READY_FOR_BOOKING', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus( + cycle.id, + 'CLEARANCE_READY_FOR_BOOKING', + { clearanceReadyAt: new Date() }, + ); + } + return this.contractsService.findById(contractId); + } + + /** + * Operations finalizes Path A self-clearance: requires every customer document + * APPROVED, then the contract becomes bookable BY THE CUSTOMER. There is no GL + * output phase on Path A, so the contract goes straight to FULLY_EXECUTED + * (ONE_TIME) / CONTRACT_ACTIVE (GENERAL). + */ + async opsFinalize(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + if (!this.isSelfClear(contract)) { + throw new ConflictException( + 'Operations finalize applies only to self-clearance (non-customs) contracts.', + ); + } + this.assertClearanceFinalizableStatus(contract); + + const approved = await this.isClearanceFullyApproved(contract); + if (!approved) { + throw new BadRequestException( + 'All required documents must be approved before clearance can be finalized', + ); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + await this.contractsRepository.update(contractId, { + status: contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED', + clearanceStatus: 'SELF_CLEARED', + } as never); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'CLEARANCE_READY_FOR_BOOKING', { + clearanceReadyAt: new Date(), + }); + } + return this.contractsService.findById(contractId); + } + + /** + * GL ET clearance hub: every customs (Path B) contract in phased clearance, + * including after booking is created. + */ + async queue(filter: FilterContractDto): Promise { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 100, + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + /** + * Operations queue: self-clearance (Path A) contracts awaiting Operations + * review of the customer's own clearance documents. + */ + async opsQueue(filter: FilterContractDto): Promise { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 100, + statuses: ['CLEARANCE_UNDER_REVIEW'], + customsClearingEnabled: false, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + /** GL ET history: contracts that completed Path B clearance. */ + async history(filter: FilterContractDto): Promise { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 50, + statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], + customsClearingEnabled: true, + sortBy: filter.sortBy ?? 'createdAt', + sortOrder: filter.sortOrder ?? 'DESC', + }); + } + + /** Operations history: contracts that completed Path A self-clearance review. */ + async opsHistory(filter: FilterContractDto): Promise { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 50, + statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], + customsClearingEnabled: false, + sortBy: filter.sortBy ?? 'createdAt', + sortOrder: filter.sortOrder ?? 'DESC', + }); + } + + // ── Phased clearance actions (ONE_TIME customs, Phase 1) ─────────────────── + + /** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */ + private async ensureDeclarationPrerequisites( + contractId: string, + contract: Contract, + ): Promise { + const allApproved = await this.isClearanceFullyApproved(contract); + if (!allApproved) { + throw new BadRequestException( + 'All required customer documents must be approved before uploading a declaration.', + ); + } + const milestones = await this.workflowService.listMilestones(contractId); + const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); + if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { + await this.workflowService.onAllDocsApproved(contractId); + } + } + + async uploadDeclaration( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + await this.ensureDeclarationPrerequisites(contractId, contract); + await this.workflowService.assertPriorComplete( + contractId, + contract.tradeDirection, + 'UNDER_CUSTOMS_CLEARANCE', + ); + + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } + + await persistDeclarationUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.onDeclarationUploaded(contractId, userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: + contract.tradeDirection === 'EXPORT' + ? ContractDocPhase.GlEtPostClearance + : ContractDocPhase.CustomerDuty, + }); + } + + // Export: the declaration is the last GL ET pre-booking action β€” release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + + return this.contractsService.findById(contractId); + } + + async adviseDuty( + contractId: string, + dto: AdviseContractDutyDto, + userId?: string, + attachment?: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty advice applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DUTY_TAXES_ADVISED'); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.contractsRepository.updateCycle(cycle.id, { + dutyRequired: dto.dutyRequired, + currentPhase: dto.dutyRequired + ? ContractDocPhase.CustomerDuty + : ContractDocPhase.GlEtPostClearance, + }); + + if (!dto.dutyRequired) { + await this.workflowService.onDutySkipped(contractId); + } else { + if (dto.amount == null || dto.amount < 0) { + throw new BadRequestException('Duty amount is required when duty applies.'); + } + if (!attachment) { + throw new BadRequestException('Duty notice attachment is required when duty applies.'); + } + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_notice', + file: attachment, + }); + await this.milestoneService.adviseDutyForContract( + contractId, + { + amount: dto.amount, + currency: dto.currency ?? 'ETB', + declarationSerial: dto.declarationSerial, + }, + userId, + ); + } + + return this.contractsService.findById(contractId); + } + + async uploadDutySlip( + contractId: string, + file: Express.Multer.File, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Duty slip upload applies only to import contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.dutyRequired) { + throw new BadRequestException('Duty/tax is not required for this clearance.'); + } + + if (!file) throw new BadRequestException('No payment slip uploaded'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'duty_tax_receipt', + file, + }); + + await this.workflowService.completeMilestone(contractId, 'DUTY_TAX_PAID'); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async uploadTransitPermit( + contractId: string, + files: Express.Multer.File[], + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Transit permit applies only to import contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistTransitPermitUploads( + this.filesService, + contractId, + 'contracts', + files, + ); + + await this.workflowService.completeMilestone(contractId, 'TRANSIT_PERMIT_UPLOADED', userId); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.updateCycle(cycle.id, { + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + } + + return this.contractsService.findById(contractId); + } + + async finalizePreClearance(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Pre-clearance finalize applies only to import contracts.'); + } + + await this.workflowService.assertPriorComplete( + contractId, + 'IMPORT', + 'TRANSIT_PERMIT_UPLOADED', + ); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + if (cycle.preClearanceFinalizedAt) { + return this.contractsService.findById(contractId); + } + + await this.contractsRepository.updateCycle(cycle.id, { + preClearanceFinalizedAt: new Date(), + currentPhase: ContractDocPhase.GlDjCollection, + }); + + // GL Djibouti may have uploaded the DO early (un-gated) β€” count it now. + const files = await this.filesService.findByResource(contractId, 'contracts'); + if (files.some((f) => f.code === 'delivery_order')) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); + await this.workflowService.markReadyForBooking(contractId); + } + + return this.contractsService.findById(contractId); + } + + async uploadDeliveryOrder( + contractId: string, + file: Express.Multer.File, + userId?: string, + vesselDepartureDate?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'IMPORT') { + throw new BadRequestException('Delivery Order applies only to import contracts.'); + } + + if (!file) throw new BadRequestException('No Delivery Order uploaded'); + + // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, + // any file type. The DO_COLLECTED milestone (and booking readiness) still waits + // for GL Ethiopia to finalize pre-clearance so the workflow order holds. + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'delivery_order', + file, + }); + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } + if (cycle?.preClearanceFinalizedAt) { + await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); + await this.workflowService.markReadyForBooking(contractId); + } + + return this.contractsService.findById(contractId); + } + + private async resolveRoMinDays(): Promise { + try { + const setting = await this.dropdownSettingsService.getByCode(RO_VESSEL_MIN_DAYS_CODE); + const first = setting.children?.[0]; + const n = Number(first?.value); + return Number.isFinite(n) && n > 0 ? n : 2; + } catch { + return 2; + } + } + + private daysUntil(dateStr: string): number { + const target = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + target.setHours(0, 0, 0, 0); + return Math.floor((target.getTime() - today.getTime()) / (24 * 60 * 60 * 1000)); + } + + async uploadReleaseOrder( + contractId: string, + file: Express.Multer.File, + vesselDepartureDate: string, + userId?: string, + ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Release Order applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete( + contractId, + 'EXPORT', + 'RELEASE_ORDER_SECURED', + ); + + if (!file) throw new BadRequestException('No Release Order uploaded'); + if (!vesselDepartureDate?.trim()) { + throw new BadRequestException('Vessel departure date is required'); + } + + const minDays = await this.resolveRoMinDays(); + const leadDays = this.daysUntil(vesselDepartureDate); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + await this.filesService.upsertByCode({ + resourceId: contractId, + resource: 'contracts', + code: 'release_order', + file, + }); + + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate, + roAmendmentRequestedAt: null, + }); + + if (leadDays < minDays) { + const reason = `Vessel departs in ${leadDays} day(s) β€” minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`; + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + return { contract: await this.contractsService.findById(contractId), hold: true, holdReason: reason }; + } + + await this.contractsRepository.updateCycle(cycle.id, { + roHoldReason: null, + currentPhase: ContractDocPhase.GlEtOutput, + }); + await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId); + + return { contract: await this.contractsService.findById(contractId), hold: false }; + } + + async requestRoAmendment( + contractId: string, + note?: string, + userId?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('RO amendment applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle) throw new BadRequestException('No clearance cycle found'); + + const reason = + note?.trim() || + 'Port amendment requested β€” vessel departure window is too short. A new Release Order will be required.'; + + await this.contractsRepository.updateCycle(cycle.id, { + roAmendmentRequestedAt: new Date(), + roHoldReason: reason, + currentPhase: ContractDocPhase.GlDjCollection, + }); + + if (userId) { + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CHANGES_REQUESTED', + userId, + 'GL_DJ', + ); + } + + return this.contractsService.findById(contractId); + } + + async confirmExportRelease(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export release applies only to export contracts.'); + } + await this.workflowService.assertPriorComplete(contractId, 'EXPORT', 'EXPORT_RELEASED'); + + await this.workflowService.onExportReleased(contractId, userId); + return this.contractsService.findById(contractId); + } + + /** GL ET finalizes export clearance after post-booking transit permit is uploaded. */ + async finalizeExportClearance(contractId: string, userId?: string): Promise { + const contract = await this.contractsService.findById(contractId); + this.assertPhasedCustoms(contract); + if (contract.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Export clearance finalize applies only to export contracts.'); + } + + const cycle = await this.contractsRepository.currentCycle(contractId); + if (!cycle?.bookingId) { + throw new BadRequestException( + 'A shipment booking must exist before export clearance can be finalized.', + ); + } + if (cycle.completedAt) { + return this.contractsService.findById(contractId); + } + + const bookingMilestones = await this.workflowService.listMilestonesForBooking( + cycle.bookingId, + ); + const transportDone = bookingMilestones.some( + (m) => m.milestoneCode === 'EXPORT_TRANSPORT_ISSUED' && m.status === 'COMPLETED', + ); + if (!transportDone) { + throw new BadRequestException( + 'Upload the transit permit before finalizing export clearance.', + ); + } + + await this.contractsRepository.updateCycle(cycle.id, { + completedAt: new Date(), + currentPhase: ContractDocPhase.GlEtPostClearance, + }); + + void userId; + return this.contractsService.findById(contractId); + } + + /** GL ET queue: customs ONE_TIME contracts in phased clearance (persistent after booking). */ + async etQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnEtClearanceQueue(milestones)) filtered.push(c); + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } + + /** GL DJ queue: customs ONE_TIME contracts handed off to or handled by Djibouti GL. */ + async djQueue(filter: FilterContractDto): Promise { + const base = await this.contractsRepository.findAllPaginated({ + page: 1, + pageSize: 500, + statuses: [...DJ_CONTRACT_QUEUE_STATUSES], + customsClearingEnabled: true, + contractKind: 'ONE_TIME', + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + + const filtered: typeof base.items = []; + for (const c of base.items) { + const cycle = await this.contractsRepository.currentCycle(c.id); + const milestones = await this.workflowService.listMilestones(c.id); + if (belongsOnDjClearanceQueue(c.tradeDirection, cycle, milestones)) { + filtered.push(c); + } + } + + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 50; + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return { + items, + total: filtered.length, + meta: { + page, + pageSize, + total: filtered.length, + totalPages: Math.ceil(filtered.length / pageSize) || 1, + hasNextPage: start + pageSize < filtered.length, + hasPreviousPage: page > 1, + }, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts new file mode 100644 index 000000000..9b108ce75 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -0,0 +1,81 @@ +import { Contract } from './entities/contract.entity'; + +/** + * Resolves which seeded clearance FileUploadSetting applies to a contract during + * the CONTRACT pre-booking phase (Path B). Mirrors clearance.util.ts but emits + * `contract_clearance_*` codes keyed on (tradeDirection, freightType, customs). + */ + +type Op = 'import' | 'export'; +type Freight = 'container' | 'bulk'; + +/** Trade direction β†’ clearance operation. DOMESTIC has no customs clearance. */ +function operationFor(tradeDirection: string): Op | null { + if (tradeDirection === 'IMPORT') return 'import'; + if (tradeDirection === 'EXPORT') return 'export'; + return null; // DOMESTIC / intercity β€” no clearance gate +} + +function freightFor(freightType: string): Freight { + return freightType === 'BULK' ? 'bulk' : 'container'; +} + +/** + * The customer-input clearance setting code, or null when no gate applies. + * + * - Path B (customs bundled): the customer uploads the documents GL needs to do + * the clearance work β†’ `contract_clearance_{op}_{freight}`. + * - Path A (no customs): the customer clears the cargo himself and uploads his + * own (smaller) clearance proof set β†’ `contract_clearance_selfclear_{op}_{freight}`, + * reviewed by Operations rather than GL. + * + * DOMESTIC/intercity has no border, so no clearance gate applies on either path. + */ +export function contractClearanceSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + const op = operationFor(tradeDirection); + if (!op) return null; + const freight = freightFor(freightType); + if (!includesCustoms) { + return `contract_clearance_selfclear_${op}_${freight}`; + } + return `contract_clearance_${op}_${freight}`; +} + +/** The GL-output (customs output) setting code; only container customs sets exist. */ +export function contractClearanceOutputSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + if (!includesCustoms) return null; + const op = operationFor(tradeDirection); + if (!op) return null; + if (freightFor(freightType) !== 'container') return null; + return `contract_clearance_output_${op}_container`; +} + +/** Convenience: resolve both codes for a loaded contract. */ +export function contractClearanceCodes(contract: Contract): { + inputCode: string | null; + outputCode: string | null; + includesCustoms: boolean; +} { + const includesCustoms = contract.customsClearingEnabled ?? false; + return { + inputCode: contractClearanceSettingCode( + contract.tradeDirection, + contract.freightType, + includesCustoms, + ), + outputCode: contractClearanceOutputSettingCode( + contract.tradeDirection, + contract.freightType, + includesCustoms, + ), + includesCustoms, + }; +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts new file mode 100644 index 000000000..8ce7ad2ea --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -0,0 +1,235 @@ +import { Injectable } from '@nestjs/common'; + +import { RatesService } from '../rule-engine/services/rates.service'; +import { ContainerTypesService } from '../rule-engine/services/container-types.service'; +import { ExchangeService } from '@edr/api-common'; +import { ContractsRepository } from './contracts.repository'; +import { Contract } from './entities/contract.entity'; + +/** A single unit-rate line at contract phase β€” NO quantities, NO totals. */ +export interface ContractUnitRateLineItem { + code: string; + label: string; + unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; + unitPrice: number; + containerSize?: string | null; + conditionalOn?: string | null; + cargoTypeCode?: string | null; +} + +/** The contract `pricing_breakdown` shape (doc Β§9.1). */ +export interface ContractPricingBreakdown { + displayMode: 'UNIT_RATES'; + currency: string; + lineItems: ContractUnitRateLineItem[]; + generatedAt: string; +} + +/** Map a rate's storage unit to the contract-display unit. */ +function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { + switch (rateUnit) { + case 'PER_TON': + return 'per_ton'; + case 'PER_KM': + return 'per_km'; + case 'PER_CONTAINER': + case 'PER_WAGON': + return 'per_container'; + default: + return 'flat'; + } +} + +@Injectable() +export class ContractPricingService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly ratesService: RatesService, + private readonly containerTypesService: ContainerTypesService, + private readonly exchangeService: ExchangeService, + ) {} + + /** Base rail rate type for the contract's direction + freight. */ + private baseRateType(contract: Contract): string { + const isBulk = contract.freightType === 'BULK'; + if (contract.tradeDirection === 'IMPORT') { + return isBulk ? 'BULK_IMPORT' : 'CONTAINER_IMPORT'; + } + if (contract.tradeDirection === 'EXPORT') { + return isBulk ? 'BULK_EXPORT' : 'CONTAINER_EXPORT'; + } + return isBulk ? 'INTERCITY_BULK' : 'INTERCITY_CONTAINER'; + } + + /** + * Build the unit-rate breakdown from live rates. Emits per-unit prices only + * (one per container size, conditional hazard/reefer surcharges, and bulk + * commodity rate) β€” NO totals or quantities (doc Β§9.1). + */ + async buildBreakdown(contract: Contract): Promise { + const liveRates = await this.ratesService.findLiveRates(); + const currency = contract.paymentCurrency; + const isEtb = currency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + + const lineItems: ContractUnitRateLineItem[] = []; + const baseType = this.baseRateType(contract); + + if (contract.freightType === 'CONTAINER') { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { data: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 500, + }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedTypes = containerTypes.filter((ct) => ct.sizeFt === sizeFt); + const matchedIds = new Set(matchedTypes.map((ct) => ct.id)); + const rate = + liveRates.find( + (r) => + r.rateType === baseType && + r.currency === 'USD' && + r.containerTypeId && + matchedIds.has(r.containerTypeId), + ) ?? + liveRates.find( + (r) => r.rateType === baseType && r.currency === 'USD' && !r.containerTypeId, + ); + if (!rate) continue; + lineItems.push({ + code: `CONTAINER_${size.toUpperCase()}`, + label: `${size} container`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + }); + } + } else { + const bulkRate = + liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null; + const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + if (bulkRate) { + lineItems.push({ + code: 'BULK_FREIGHT', + label: cargoScope?.cargoType?.cargoTypeName ?? 'Bulk cargo', + unit: toContractUnit(bulkRate.rateUnit), + unitPrice: convert(Number(bulkRate.rateValue)), + cargoTypeCode: cargoScope?.cargoType?.code ?? null, + }); + } + } + + // First / last mile trucking unit rates β€” shown when the contract carries + // that leg. Per-unit prices only; the actual amount (Γ— km / containers / + // tons / flat) is computed at booking time. + if (contract.firstMilePickupAddress) { + const fm = liveRates.find( + (r) => r.rateType === 'FIRST_MILE' && r.currency === 'USD', + ); + if (fm && Number(fm.rateValue) > 0) { + lineItems.push({ + code: 'FIRST_MILE', + label: 'First mile (pick-up)', + unit: toContractUnit(fm.rateUnit), + unitPrice: convert(Number(fm.rateValue)), + }); + } + } + if (contract.lastMileDeliveryAddress) { + const lm = liveRates.find( + (r) => r.rateType === 'LAST_MILE' && r.currency === 'USD', + ); + if (lm && Number(lm.rateValue) > 0) { + lineItems.push({ + code: 'LAST_MILE', + label: 'Last mile (delivery)', + unit: toContractUnit(lm.rateUnit), + unitPrice: convert(Number(lm.rateValue)), + }); + } + } + + // Conditional surcharges β€” shown only when the contract toggles them on AND + // the rate has a non-zero value (a 0 rate means "no surcharge"). + if (contract.isHazardous) { + const hazard = liveRates.find( + (r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD', + ); + if (hazard && Number(hazard.rateValue) > 0) { + lineItems.push({ + code: 'HAZARD_SURCHARGE', + label: 'Hazardous surcharge', + unit: toContractUnit(hazard.rateUnit), + unitPrice: convert(Number(hazard.rateValue)), + conditionalOn: 'is_hazardous', + }); + } + } + if (contract.isReefer) { + const reefer = liveRates.find( + (r) => r.rateType === 'REEFER_SURCHARGE' && r.currency === 'USD', + ); + if (reefer && Number(reefer.rateValue) > 0) { + lineItems.push({ + code: 'REEFER_SURCHARGE', + label: 'Reefer surcharge', + unit: toContractUnit(reefer.rateUnit), + unitPrice: convert(Number(reefer.rateValue)), + conditionalOn: 'is_reefer', + }); + } + } + + return { + displayMode: 'UNIT_RATES', + currency, + lineItems, + generatedAt: new Date().toISOString(), + }; + } + + /** Generate (and persist) the unit-rate breakdown for a contract. */ + async generatePrice(contractId: string): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) { + throw new Error(`Contract ${contractId} not found`); + } + const breakdown = await this.buildBreakdown(contract); + await this.contractsRepository.update(contractId, { + pricingBreakdown: breakdown as never, + pricingDisplayMode: 'UNIT_RATES', + } as never); + return breakdown; + } + + /** + * Freeze the contract's unit rates into contract_rate_snapshots (one row per + * rate line) at submit time. The booking later computes totals from these. + */ + async freezeRateSnapshots(contractId: string): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) return; + const breakdown = + (contract.pricingBreakdown as ContractPricingBreakdown | null) ?? + (await this.buildBreakdown(contract)); + + await this.contractsRepository.clearRateSnapshots(contractId); + for (const line of breakdown.lineItems) { + await this.contractsRepository.createRateSnapshot({ + contractId, + rateCode: line.code, + description: line.label, + unitPrice: line.unitPrice, + unitOfMeasure: line.unit, + currency: breakdown.currency, + containerSize: line.containerSize ?? null, + isSurcharge: !!line.conditionalOn, + conditionalOn: line.conditionalOn ?? null, + }); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts new file mode 100644 index 000000000..e87112bc2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -0,0 +1,646 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, +} from '@nestjs/common'; +import { Readable } from 'stream'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractViewModel } from '../../contracts/contract-view-model.builder'; +import { MinioService } from '../minio/minio.service'; +import { FileRecord } from '../files/entities/file.entity'; +import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; +import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; +import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { FilesService } from '../files/files.service'; +import { SignaturesService } from '../signatures/signatures.service'; +import { ContractPricingService } from './contract-pricing.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { ContractsRepository } from './contracts.repository'; +import { ContractsService } from './contracts.service'; +import { contractClearanceSettingCode } from './contract-clearance.util'; +import { Contract } from './entities/contract.entity'; +import { ContractSignerRole } from './entities/contract-signature.entity'; +import { SignContractDto } from './dto/sign-contract.dto'; + +/** + * Dropdown-settings code holding the admin-configured contract validity options + * (each option's `value` is a day count). The staff accept dialog reads the same + * code, so accept can only use a configured duration. See the seed migration + * `SeedContractValidityPeriods`. + */ +const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods'; + +/** Status-machine guard mirroring booking-status.util. */ +function assertContractStatus(contract: Contract, allowed: string[]): void { + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot perform this action on status "${contract.status}". Allowed: ${allowed.join(', ')}`, + ); + } +} + +@Injectable() +export class ContractTransitionService { + private readonly logger = new Logger(ContractTransitionService.name); + + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly contractsService: ContractsService, + private readonly pricingService: ContractPricingService, + private readonly approvalRulesService: ApprovalRulesService, + private readonly cargoTypesService: CargoTypesService, + private readonly dropdownSettingsService: DropdownSettingsService, + private readonly filesService: FilesService, + private readonly signaturesService: SignaturesService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly documentViewModelBuilder: ContractDocumentViewModelBuilder, + private readonly renderer: ContractRendererService, + private readonly pdfService: ContractPdfService, + private readonly minioService: MinioService, + ) {} + + /** Customer submits the contract for approval β†’ SUBMITTED; freeze unit rates. */ + async submit(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['DRAFT', 'CHANGES_REQUESTED']); + + await this.pricingService.generatePrice(contractId); + await this.pricingService.freezeRateSnapshots(contractId); + + await this.contractsRepository.update(contractId, { + status: 'SUBMITTED', + } as never); + return this.contractsService.findById(contractId); + } + + /** Confirm a price change before submit (mirrors booking confirm-submit). */ + async confirmSubmit(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PRICE_CHANGED_PENDING_CONFIRM']); + + await this.pricingService.generatePrice(contractId); + await this.pricingService.freezeRateSnapshots(contractId); + + await this.contractsRepository.update(contractId, { + status: 'SUBMITTED', + } as never); + return this.contractsService.findById(contractId); + } + + /** + * Line staff accepts intake: set the validity window from validityDays and + * instantiate the approval steps from approval_rules β†’ PENDING_APPROVAL. + */ + async staffAccept( + contractId: string, + actorId: string, + validityDays: number, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['SUBMITTED']); + + if (!Number.isInteger(validityDays) || validityDays < 1) { + throw new BadRequestException( + 'A contract validity (in days) is required to accept this contract.', + ); + } + + await this.assertValidityDaysConfigured(validityDays); + + const validFrom = new Date(); + const validUntil = new Date(validFrom); + validUntil.setDate(validUntil.getDate() + validityDays); + + await this.instantiateApprovalSteps(contract); + + await this.contractsRepository.update(contractId, { + status: 'PENDING_APPROVAL', + approvedByStaffId: actorId, + approvedByStaffAt: validFrom, + contractValidityDays: validityDays, + contractValidFrom: validFrom, + contractValidUntil: validUntil, + } as never); + return this.contractsService.findById(contractId); + } + + /** + * Ensure the chosen validity (days) is one of the admin-configured options in + * the `contract_validity_periods` dropdown setting. If the setting is missing + * or has no options yet, fall back to the DTO range check (already applied) so + * acceptance is never hard-blocked before an admin configures the list. + */ + private async assertValidityDaysConfigured(validityDays: number): Promise { + let setting; + try { + setting = await this.dropdownSettingsService.getByCode( + CONTRACT_VALIDITY_PERIODS_CODE, + ); + } catch { + // Not configured yet β€” keep the flow working with the DTO range only. + return; + } + + const allowed = (setting.children ?? []) + .map((o) => Number(o.value)) + .filter((n) => Number.isFinite(n)); + if (allowed.length === 0) return; + + if (!allowed.includes(validityDays)) { + throw new BadRequestException( + `Validity ${validityDays} days is not a configured option. Allowed: ${allowed + .sort((a, b) => a - b) + .join(', ')} days.`, + ); + } + } + + /** + * Build contract approval steps from the system approval_rules chain (US-06: + * container β†’ line staff + director; bulk β†’ directors + CEO). Mirrors the + * booking transition's instantiateApprovalSteps but writes contract steps. + */ + private async instantiateApprovalSteps(contract: Contract): Promise { + if ((contract.approvalSteps?.length ?? 0) > 0) return; + + const cargoTypeId = + (contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId ?? null; + + // Resolve the chain from the cargo type flag only. + let requiresDirectorApproval = false; + if (cargoTypeId) { + const cargoType = await this.cargoTypesService.findById(cargoTypeId); + requiresDirectorApproval = cargoType?.requiresDirectorApproval ?? false; + } + + const chain = await this.approvalRulesService.findChain(requiresDirectorApproval); + if (chain.length === 0) { + throw new BadRequestException( + `Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`, + ); + } + + for (const rule of chain) { + await this.contractsRepository.createApprovalStep({ + contractId: contract.id, + stepOrder: rule.stepOrder, + requiredRole: rule.requiredRole, + blocksRole: rule.blocksRole ?? null, + status: 'PENDING', + }); + } + } + + async requestChanges( + contractId: string, + note: string, + actorId: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['SUBMITTED']); + + await this.contractsRepository.createReviewNote( + contractId, + note, + 'CHANGES_REQUESTED', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'CHANGES_REQUESTED', + } as never); + return this.contractsService.findById(contractId); + } + + async reject(contractId: string, reason: string, actorId: string): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'REJECTION', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'REJECTED', + } as never); + return this.contractsService.findById(contractId); + } + + /** Approve one approval step in sequence; β†’ APPROVED when all complete. */ + async approveStep( + contractId: string, + stepId: string, + actorId: string, + requiredRole: string, + authUser?: TCurrentUser, + ): Promise { + if (authUser) { + assertCanApproveBookingStep(authUser, requiredRole); + } + + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + + const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); + if (!step || step.status !== 'PENDING') { + throw new BadRequestException('Approval step not found or already actioned'); + } + + const next = await this.contractsRepository.findNextPendingApprovalStep(contractId); + 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}`, + ); + } + if (step.blocksRole && step.blocksRole === requiredRole) { + throw new BadRequestException(`Role ${requiredRole} is blocked for this step`); + } + + await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); + + // Record who acted on this step, but DO NOT advance the contract status here β€” + // approving one step (e.g. LINE_STAFF) must not finalize the chain while later + // steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once + // every step in the chain is complete; until then the contract stays in + // PENDING_APPROVAL so the next required role can act. + const updates: Record = {}; + const now = new Date(); + if (requiredRole === 'LINE_STAFF') { + 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.contractsRepository.allApprovalStepsComplete(contractId); + updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL'; + + if (Object.keys(updates).length > 0) { + await this.contractsRepository.update(contractId, updates as never); + } + return this.contractsService.findById(contractId); + } + + /** + * Render the contract PDF from the Contract aggregate, store it via FilesService, + * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/ + * Chromium) is best-effort and must NOT block the contract from becoming ready β€” + * the document is (re)rendered lazily on view/download once Chromium is available. + */ + async generateContract(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); + + const { view } = await this.documentViewModelBuilder.build(contractId); + + try { + await this.upsertContractPdf(contractId, contract.reference, view); + } catch (err) { + this.logger.warn( + `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, + ); + } + + await this.contractsRepository.update(contractId, { + status: 'CONTRACT_READY', + contractTemplateKey: view.templateKey, + contractGeneratedAt: new Date(), + } as never); + return this.contractsService.findById(contractId); + } + + /** + * Build the contract PDF view-model and rendered HTML for portal/backoffice + * signing. Sourced entirely from the Contract aggregate (unit-rate schedule, no + * totals). Returns the view-model, the rendered HTML and the signature rows. + */ + async getContractDocumentView(contractId: string): Promise<{ + view: ContractViewModel; + html: string; + signatures: ContractViewModel['signatures']; + }> { + const { view } = await this.documentViewModelBuilder.build(contractId); + await this.inlineSignatureImages(view.signatures); + const html = this.renderer.render(view); + return { view, html, signatures: view.signatures }; + } + + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + + /** + * Rebuild the stored `contract` PDF from the current aggregate (now including + * the latest signatures) so the downloaded/viewed file matches the live HTML + * view. Best-effort β€” a Chromium hiccup must never fail the signing + * transaction; the doc still re-renders live on /contract/view. + */ + private async regenerateContractPdf( + contractId: string, + reference: string, + ): Promise { + try { + const { view } = await this.documentViewModelBuilder.build(contractId); + await this.upsertContractPdf(contractId, reference, view); + } catch (err) { + this.logger.warn( + `Signed contract PDF regen deferred for ${reference}: ${err}. It re-renders live on view.`, + ); + } + } + + /** Render the contract PDF and upsert it as the `contract` file on the contract. */ + private async upsertContractPdf( + contractId: string, + reference: string, + view: ContractViewModel, + ): Promise { + 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: contractId, + resource: 'contracts', + code: 'contract', + file, + }); + } + + /** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */ + 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))); + }); + } + + /** Apply a digital signature row (mirrors booking-contract.service). */ + private async applySignature( + contract: Contract, + dto: SignContractDto, + options: { signerUserId?: string }, + ): Promise { + const role = dto.role as ContractSignerRole; + + // Resolve the signature image. The client may send a freshly-drawn image, or + // omit it to reuse the signer's saved profile signature. Fall back to the + // saved one whenever no image is supplied. + let imageBase64 = dto.signatureImageBase64; + let signerDisplayName = dto.signerDisplayName; + if (!imageBase64 && options.signerUserId) { + const saved = await this.signaturesService.getForUser(options.signerUserId); + if (saved?.signatureImageUrl) { + imageBase64 = saved.signatureImageUrl; + signerDisplayName = signerDisplayName || saved.signerDisplayName; + } + } + if (!imageBase64) { + throw new BadRequestException( + 'No signature provided and no saved signature found on the profile.', + ); + } + + const raw = imageBase64.includes(',') + ? imageBase64.split(',')[1]! + : imageBase64; + const buffer = Buffer.from(raw, 'base64'); + const sigFile: Express.Multer.File = { + fieldname: `signature_${role.toLowerCase()}`, + originalname: `signature-${role.toLowerCase()}-${contract.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: contract.id, + resource: 'contracts', + code: `signature_${role.toLowerCase()}`, + file: sigFile, + }); + + await this.contractsRepository.saveSignature({ + contractId: contract.id, + role, + signerDisplayName, + signedAt: new Date(), + signatureFileId: fileRecord.id, + consentText: dto.consentText ?? null, + }); + + // Only (re)save the reusable profile signature when the signer drew a NEW + // image. Reusing the saved signature must not rewrite it with itself. + if (options.signerUserId && dto.signatureImageBase64) { + try { + await this.signaturesService.upsertForUser({ + userId: options.signerUserId, + signerDisplayName, + signatureImageBase64: dto.signatureImageBase64, + }); + } catch (err) { + this.logger.warn( + `Could not save reusable signature for user ${options.signerUserId}: ${err}`, + ); + } + } + } + + /** Customer signs the ready contract β†’ SIGNED_CUSTOMER. */ + async sign( + contractId: string, + dto: SignContractDto, + options: { signerUserId?: string }, + ): Promise { + const contract = await this.contractsService.findById(contractId); + + if (dto.role === 'CUSTOMER') { + assertContractStatus(contract, ['CONTRACT_READY']); + const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); + if (existing) { + throw new BadRequestException('Customer has already signed this contract'); + } + await this.applySignature(contract, dto, options); + await this.contractsRepository.update(contractId, { + status: 'SIGNED_CUSTOMER', + customerSignedAt: new Date(), + } as never); + await this.regenerateContractPdf(contractId, contract.reference); + return this.contractsService.findById(contractId); + } + + return this.counterSign(contractId, dto, options); + } + + /** + * Staff/Director/CEO counter-sign β†’ branch on the execution path. A customs + * border (IMPORT/EXPORT) always requires a clearance gate before any shipment; + * who reviews differs: + * - Path B (customs bundled): customer uploads GL-input docs, GL reviews, GL + * uploads output, then GL creates the booking. + * - Path A (no customs): the customer clears the cargo himself and uploads his + * own clearance proof; Operations reviews it; then the CUSTOMER books. + * DOMESTIC/intercity has no border, so it goes straight to executed. + */ + async counterSign( + contractId: string, + dto: SignContractDto, + options: { signerUserId?: string }, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['SIGNED_CUSTOMER']); + + await this.applySignature(contract, dto, options); + + const now = new Date(); + const updates: Record = { + fullyExecutedAt: now, + lockedAt: now, + }; + + // A clearance gate applies whenever a clearance doc set resolves β€” Path B + // (customs) or Path A self-clearance (IMPORT/EXPORT without customs). DOMESTIC + // resolves to null on both paths and skips straight to executed. + const clearanceCode = contractClearanceSettingCode( + contract.tradeDirection, + contract.freightType, + contract.customsClearingEnabled ?? false, + ); + + // GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract + // level: there is no contract clearance cycle. The contract just becomes + // active; the customer then files shipment requests and GL books + clears + // each one. ONE_TIME customs and Path A self-clearance keep the contract + // cycle below. + const isGeneralCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + + if (clearanceCode && !isGeneralCustoms) { + // Open a clearance cycle, seed the pre-booking milestones, and route the + // customer to upload. Path A is ops-reviewed; Path B is GL-reviewed β€” the + // distinction is enforced at the review/finalize endpoints, not here. + const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; + const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); + await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); + updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; + updates.clearanceStatus = 'AWAITING_DOCUMENTS'; + updates.clearanceCycleNumber = cycleNumber; + } else { + // No contract-level clearance gate β€” DOMESTIC, or GENERAL+customs (which + // clears per booking). Ready for shipment requests / direct booking. + updates.status = + contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; + updates.clearanceStatus = 'NOT_APPLICABLE'; + } + + await this.contractsRepository.update(contractId, updates as never); + await this.regenerateContractPdf(contractId, contract.reference); + return this.contractsService.findById(contractId); + } + + /** Customer requests renewal β†’ RENEWAL_DRAFT linked via renewalOfId. */ + async renew(contractId: string, userId?: string): Promise { + const source = await this.contractsService.findById(contractId); + + const reference = await this.generateRenewalReference(); + const renewal = await this.contractsRepository.create({ + reference, + companyId: source.companyId, + companyProfileId: source.companyProfileId, + isGovernment: source.isGovernment, + governmentInstitution: source.governmentInstitution, + contractKind: source.contractKind, + renewalOfId: source.id, + tradeDirection: source.tradeDirection, + freightType: source.freightType, + serviceTypeId: source.serviceTypeId, + paymentCurrency: source.paymentCurrency, + customsClearingEnabled: source.customsClearingEnabled, + customsClearingAgent: source.customsClearingAgent, + equipmentReturn: source.equipmentReturn, + firstMilePickupAddress: source.firstMilePickupAddress, + firstMilePickupLat: source.firstMilePickupLat, + firstMilePickupLng: source.firstMilePickupLng, + lastMileDeliveryAddress: source.lastMileDeliveryAddress, + lastMileDeliveryLat: source.lastMileDeliveryLat, + lastMileDeliveryLng: source.lastMileDeliveryLng, + isHazardous: source.isHazardous, + isReefer: source.isReefer, + contractType: source.contractType, + versionNumber: (source.versionNumber ?? 1) + 1, + status: 'RENEWAL_DRAFT', + clearanceStatus: 'NOT_APPLICABLE', + clearanceCycleNumber: 0, + } as never); + + void userId; + return this.contractsService.findById(renewal.id); + } + + private async generateRenewalReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.contractsRepository.countByYear(year); + return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts new file mode 100644 index 000000000..6ded65ae9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -0,0 +1,1105 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Res, + UnauthorizedException, + UploadedFiles, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { AnyFilesInterceptor, FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +import { + ApiBearerAuth, + ApiBody, + ApiConsumes, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + assertFreightPermission, + hasFreightPermission, +} from '../../common/freight-permission.util'; +import { + type AuthUserPayload, + resolveAuthUserId, +} from '../../common/resolve-auth-user-id'; + +import { ContractsService } from './contracts.service'; +import { ContractPricingService } from './contract-pricing.service'; +import { ContractTransitionService } from './contract-transition.service'; +import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; +import { ContractBookingService } from './contract-booking.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; +import { BookingRequestService } from './booking-request.service'; +import { SignaturesService } from '../signatures/signatures.service'; +import { BookingsService } from '../bookings/bookings.service'; +import { CreateContractDto } from './dto/create-contract.dto'; +import { UpdateContractDto } from './dto/update-contract.dto'; +import { FilterContractDto } from './dto/filter-contract.dto'; +import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; +import { AcceptContractDto } from './dto/accept-contract.dto'; +import { + ApproveStepDto, + RejectContractDto, + RequestChangesDto, +} from './dto/approve-step.dto'; +import { SignContractDto } from './dto/sign-contract.dto'; +import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; +import { RenewContractDto } from './dto/renew-contract.dto'; +import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; +import { + CreateBookingRequestDto, + ReviewBookingRequestDto, +} from './dto/create-booking-request.dto'; +import { + AdviseDutyDto, + AssignRiskDto, + AssignStationDto, + CompleteMilestoneDto, + ReportIncidentDto, +} from './dto/gl-operations.dto'; +import { + AdviseContractDutyDto, + GatepassDto, + RoAmendmentDto, +} from './dto/phased-clearance.dto'; + +@ApiTags('contracts') +@Controller('contracts') +@ApiBearerAuth() +export class ContractsController { + constructor( + private readonly contractsService: ContractsService, + private readonly pricingService: ContractPricingService, + private readonly transitionService: ContractTransitionService, + private readonly clearanceService: ContractClearanceService, + private readonly contractBookingService: ContractBookingService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly glOperationsService: GlOperationsService, + private readonly bookingRequestService: BookingRequestService, + private readonly signaturesService: SignaturesService, + private readonly bookingClearanceService: BookingClearanceService, + private readonly bookingsService: BookingsService, + ) {} + + // ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────── + // STATIC routes declared before any `:id`-param route so Nest matches them + // (mirrors the clearance/queue ordering below). + + @Get('booking-requests/queue') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' }) + bookingRequestQueue() { + return this.bookingRequestService.queue(); + } + + @Get('booking-requests/:reqId') + @ApiOperation({ summary: 'A single shipment request' }) + getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) { + return this.bookingRequestService.findOne(reqId); + } + + @Post('booking-requests/:reqId/accept') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' }) + acceptBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @Body() body: { bookingId: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.accept( + reqId, + body.bookingId, + resolveAuthUserId(user), + ); + } + + @Post('booking-requests/:reqId/reject') + @BookingStaff(FREIGHT_PERMS.contracts.createBooking) + @ApiOperation({ summary: 'GL rejects a shipment request' }) + rejectBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @Body() dto: ReviewBookingRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user)); + } + + @Post('booking-requests/:reqId/cancel') + @ApiOperation({ summary: 'Customer cancels their own pending shipment request' }) + cancelBookingRequest( + @Param('reqId', ParseUUIDPipe) reqId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user)); + } + + @Post(':id/booking-requests') + @ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' }) + submitBookingRequest( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingRequestDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user)); + } + + @Get(':id/booking-requests') + @ApiOperation({ summary: 'List the shipment requests on a contract' }) + listBookingRequests(@Param('id', ParseUUIDPipe) id: string) { + return this.bookingRequestService.listForContract(id); + } + + @Post() + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Create a new contract (DRAFT) with routes + cargo scope' }) + @ApiBody({ type: CreateContractDto }) + async create( + @Body() dto: CreateContractDto, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: TCurrentUser, + ) { + if (dto.isGovernment) { + assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + } + return this.contractsService.create(dto, files ?? [], user?.id); + } + + @Get() + @ApiOperation({ summary: 'List contracts (paginated)' }) + async findAll( + @Query() filter: FilterContractDto, + @CurrentUser() user: TCurrentUser, + ) { + // Staff see every contract; customers are force-scoped to their own company. + if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + return this.contractsService.findAll(filter); + } + const userId = user?.id; + if (!userId) throw new UnauthorizedException('Authentication required'); + const companyId = await this.contractsService.resolveCustomerCompanyId(userId); + if (!companyId) { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + return { + items: [], + total: 0, + meta: { + page, + pageSize, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }; + } + return this.contractsService.findAll(filter, companyId); + } + + @Get('my') + @ApiOperation({ summary: "List the current customer's contracts" }) + async findMy( + @CurrentUser() user: AuthUserPayload, + @Query() filter: FilterContractDto, + ) { + const userId = resolveAuthUserId(user); + const companyId = await this.contractsService.resolveCustomerCompanyId(userId); + if (!companyId) { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + return { + items: [], + total: 0, + meta: { + page, + pageSize, + total: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false, + }, + }; + } + return this.contractsService.findAll(filter, companyId); + } + + @Get('list-summary') + @ApiOperation({ summary: 'Contract list metrics and status counts (backoffice)' }) + @ApiOkResponse({ type: ContractListSummaryDto }) + findListSummary(@Query() filter: FilterContractDto) { + return this.contractsService.getListSummary(filter); + } + + @Get('clearance/queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' }) + clearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.queue(filter); + } + + @Get(':id') + @ApiOperation({ summary: 'Get contract by ID (routes, cargo scope, unit rates)' }) + async findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const contract = await this.contractsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + ) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + return contract; + } + + @Patch(':id') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'Update contract', + description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.', + }) + @ApiBody({ type: UpdateContractDto }) + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateContractDto, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.contractsService.update(id, dto, files ?? []); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Soft-delete DRAFT contract' }) + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.contractsService.remove(id); + } + + @Post(':id/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload intake documents for a contract (DRAFT only)' }) + uploadDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.contractsService.uploadDocuments(id, files ?? []); + } + + @Post(':id/generate-price') + @ApiOperation({ summary: 'Generate unit-rate breakdown (no totals at contract phase)' }) + generatePrice(@Param('id', ParseUUIDPipe) id: string) { + return this.pricingService.generatePrice(id); + } + + @Post(':id/submit') + @ApiOperation({ summary: 'Customer submit contract (freezes contract_rate_snapshots)' }) + submit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.submit(id); + } + + @Post(':id/confirm-submit') + @ApiOperation({ summary: 'Confirm submit after a price change' }) + confirmSubmit(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.confirmSubmit(id); + } + + @Post(':id/staff/accept') + @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @ApiOperation({ summary: 'Staff accept β†’ set validity window + start approval chain' }) + staffAccept( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: AcceptContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.staffAccept( + id, + resolveAuthUserId(user), + dto.validityDays, + ); + } + + @Post(':id/staff/request-changes') + @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @ApiOperation({ summary: 'Staff return contract for customer updates' }) + requestChanges( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RequestChangesDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.requestChanges( + id, + dto.note, + resolveAuthUserId(user), + ); + } + + @Post(':id/staff/reject') + @BookingStaff(FREIGHT_PERMS.contracts.reject) + @ApiOperation({ summary: 'Staff reject contract' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + } + + @Post(':id/approval-steps/:stepId/approve') + @BookingStaff([ + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.approveCeo, + ]) + @ApiOperation({ summary: 'Approve one approval step in sequence' }) + approveStep( + @Param('id', ParseUUIDPipe) id: string, + @Param('stepId', ParseUUIDPipe) stepId: string, + @Body() dto: ApproveStepDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.approveStep( + id, + stepId, + resolveAuthUserId(user), + dto.requiredRole, + user, + ); + } + + @Post(':id/contract/generate') + @BookingStaff(FREIGHT_PERMS.contracts.generateContract) + @ApiOperation({ summary: 'Generate contract document β†’ CONTRACT_READY' }) + generateContract(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.generateContract(id); + } + + @Get(':id/contract/view') + @ApiOperation({ summary: 'Contract PDF view-model + rendered HTML for signing' }) + async getContractView( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { view, html, signatures } = + await this.transitionService.getContractDocumentView(id); + // The signer's reusable saved signature (if any) so the sign UI can offer + // "Approve & sign" with the stored image instead of forcing a fresh draw. + const signerId = resolveAuthUserId(user); + const savedSignature = signerId + ? await this.signaturesService.getForUser(signerId) + : null; + return { + contractId: view.bookingId, + reference: view.reference, + status: view.status, + templateKey: view.templateKey, + title: view.template.title, + html, + view, + canSignCustomer: view.canSignCustomer, + canSignStaff: view.canSignStaff, + hasContractDocument: view.hasContractDocument, + signatures, + savedSignature, + }; + } + + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + + @Post(':id/contract/sign') + @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) + signContract( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SignContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.sign(id, dto, { + signerUserId: user?.id ?? user?.sub, + }); + } + + @Post(':id/renew') + @ApiOperation({ summary: 'Create a renewal draft linked via renewalOfId' }) + renew( + @Param('id', ParseUUIDPipe) id: string, + @Body() _dto: RenewContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.transitionService.renew(id, user?.id ?? user?.sub); + } + + // ── Pre-booking clearance (Path B, doc Β§15.2.1) ──────────────────────────── + + @Get(':id/clearance') + @ApiOperation({ summary: 'Pre-booking clearance document grid on the contract' }) + getClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.getClearanceView(id); + } + + @Post(':id/clearance/documents') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads clearance documents (fieldname = document key)' }) + uploadClearanceDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.clearanceService.uploadDocuments(id, files ?? []); + } + + @Post(':id/clearance/review') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL ET reviews a clearance document (Approve | Query)' }) + reviewClearanceDocument( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReviewClearanceDocumentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.reviewDocument( + id, + dto.fileKey, + dto.status, + resolveAuthUserId(user), + dto.note, + ); + } + + @Post(':id/clearance/output-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL uploads customs output documents (IM4/EX3/…) pre-booking' }) + uploadOutputDocuments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.clearanceService.uploadOutputDocuments(id, files ?? []); + } + + @Post(':id/clearance/finalize') + @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) + @ApiOperation({ summary: 'GL ET finalizes clearance β†’ CLEARANCE_READY_FOR_BOOKING (legacy)' }) + finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalize(id); + } + + @Post(':id/clearance/declaration') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads customs declaration documents (multi-file)' }) + uploadDeclaration( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeclaration(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDutyAdvise) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET sets duty/tax requirement and advises amount with notice attachment' }) + adviseContractDuty( + @Param('id', ParseUUIDPipe) id: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + const dutyRequired = dutyRequiredRaw === 'true' || dutyRequiredRaw === '1'; + const dto: AdviseContractDutyDto = { + dutyRequired, + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }; + return this.clearanceService.adviseDuty( + id, + dto, + resolveAuthUserId(user), + attachment, + ); + } + + @Post(':id/clearance/finalize-pre-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET finalizes import pre-clearance β€” unlocks Djibouti DO upload' }) + finalizePreClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalizePreClearance(id); + } + + @Post(':id/clearance/duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads duty/tax payment slip on contract' }) + uploadContractDutySlip( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.clearanceService.uploadDutySlip(id, file); + } + + @Post(':id/clearance/transit-permit') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads import transit permit documents (multi-file)' }) + uploadTransitPermit( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadTransitPermit(id, files ?? [], resolveAuthUserId(user)); + } + + @Post(':id/clearance/delivery-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' }) + uploadDeliveryOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); + } + + @Post(':id/clearance/release-order') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + uploadReleaseOrder( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.uploadReleaseOrder( + id, + file, + vesselDepartureDate, + resolveAuthUserId(user), + ); + } + + @Post(':id/clearance/ro-amendment') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ requests port amendment when RO vessel window is too short' }) + requestRoAmendment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RoAmendmentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.requestRoAmendment(id, dto.note, resolveAuthUserId(user)); + } + + @Post(':id/clearance/export-release') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL ET confirms export release after declaration' }) + confirmExportRelease( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.confirmExportRelease(id, resolveAuthUserId(user)); + } + + @Post(':id/clearance/finalize-export-clearance') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: 'GL ET finalizes export clearance after post-booking transit permit upload', + }) + finalizeExportClearance( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.finalizeExportClearance(id, resolveAuthUserId(user)); + } + + @Get('clearance/et-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ summary: 'GL Ethiopia phased clearance list (persistent after booking)' }) + etClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.etQueue(filter); + } + + @Get('clearance/dj-queue') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL Djibouti phased clearance list (persistent after booking)' }) + djClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.djQueue(filter); + } + + @Get('clearance/dj-schedules') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'Train schedules carrying customs bookings β€” GL DJ gate-pass table' }) + djClearanceSchedules() { + return this.glOperationsService.djSchedules(); + } + + @Post('clearance/schedules/:scheduleId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', + }) + grantScheduleGatepass( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantScheduleGatepass( + scheduleId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + + // ── Path A self-clearance β€” Operations reviews the customer's own docs ─────── + + @Get('clearance/ops-queue') + @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) + @ApiOperation({ + summary: 'Operations queue: self-clearance (non-customs) contracts awaiting review', + }) + opsClearanceQueue(@Query() filter: FilterContractDto) { + return this.clearanceService.opsQueue(filter); + } + + @Post(':id/clearance/ops-review') + @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) + @ApiOperation({ + summary: 'Operations reviews a customer self-clearance document (Approve | Query)', + }) + opsReviewClearanceDocument( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReviewClearanceDocumentDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.clearanceService.opsReviewDocument( + id, + dto.fileKey, + dto.status, + resolveAuthUserId(user), + dto.note, + ); + } + + @Post(':id/clearance/ops-finalize') + @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) + @ApiOperation({ + summary: 'Operations finalizes self-clearance β†’ customer may create the booking', + }) + opsFinalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.opsFinalize(id); + } + + @Get('clearance/history') + @BookingStaff(FREIGHT_PERMS.contracts.finalizeClearance) + @ApiOperation({ summary: 'GL ET clearance history: contracts that completed Path B clearance' }) + clearanceHistory(@Query() filter: FilterContractDto) { + return this.clearanceService.history(filter); + } + + @Get('clearance/ops-history') + @BookingStaff(FREIGHT_PERMS.contracts.opsClearanceReview) + @ApiOperation({ summary: 'Operations clearance history: contracts that completed Path A self-clearance' }) + opsClearanceHistory(@Query() filter: FilterContractDto) { + return this.clearanceService.opsHistory(filter); + } + + // ── Booking under contract (Path A customer / Path B GL ET) ──────────────── + + @Post(':id/bookings') + @ApiOperation({ + summary: + 'Create a shipment booking under a contract β€” Path A (customer) or Path B (GL Ethiopia).', + }) + createBooking( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, + ) { + // The service decides the execution path from the contract: + // Path A (customs disabled) β†’ customer/staff create; status checks apply. + // Path B (customs enabled) β†’ GL Ethiopia only, once clearance is ready. + return this.contractBookingService.createUnderContract( + id, + dto, + { id: user?.id ?? user?.sub }, + user, + ); + } + + @Get(':id/capacity') + @ApiOperation({ + summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', + }) + async capacity(@Param('id', ParseUUIDPipe) id: string) { + const contract = await this.contractsService.findById(id); + return this.contractBookingService.computeCapacity(contract); + } + + // ── Clearance milestones (doc Β§11.3, Β§12.2) ──────────────────────────────── + + @Get(':id/milestones') + @ApiOperation({ summary: 'Pre-booking clearance milestones for a contract cycle' }) + listContractMilestones(@Param('id', ParseUUIDPipe) id: string) { + return this.milestoneService.listForContract(id); + } + + @Get('bookings/:bookingId/milestones') + @ApiOperation({ summary: 'Post-booking clearance milestones for a shipment booking' }) + listBookingMilestones(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.milestoneService.listForBooking(bookingId); + } + + @Post('bookings/:bookingId/milestones/:code/complete') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'GL / Ops / Terminal marks a post-booking milestone complete' }) + completeBookingMilestone( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Param('code') code: string, + @Body() body: { note?: string }, + @CurrentUser() user: AuthUserPayload, + ) { + return this.milestoneService.completeForBooking( + bookingId, + code, + user?.id ?? user?.sub, + body?.note, + ); + } + + @Post(':id/milestones/:code/complete') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceReview) + @ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' }) + completeContractMilestone( + @Param('id', ParseUUIDPipe) id: string, + @Param('code') code: string, + @Body() body: CompleteMilestoneDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.milestoneService.completeForContract( + id, + code, + resolveAuthUserId(user), + body?.note, + ); + } + + // ── GL operational actions on a booking (doc Β§11–§13) ────────────────────── + + @Post('bookings/:bookingId/risk') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' }) + assignRisk( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AssignRiskDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.milestoneService.assignRisk( + bookingId, + dto.riskLevel, + resolveAuthUserId(user), + dto.note, + ); + } + + @Post('bookings/:bookingId/duty') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' }) + adviseDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AdviseDutyDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.milestoneService.adviseDuty( + bookingId, + { amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial }, + resolveAuthUserId(user), + dto.note, + ); + } + + @Post('bookings/:bookingId/station-assign') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' }) + assignStation( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: AssignStationDto, + ) { + return this.glOperationsService.assignStation(bookingId, { + stationYardId: dto.stationYardId, + staffId: dto.staffId, + }); + } + + @Post('bookings/:bookingId/transport-document') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) + uploadTransportDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-documents') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: + 'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs', + }) + uploadT1Documents( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadT1Documents(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/t1-close') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) + @ApiOperation({ + summary: + 'Close (accept) the T1 set β€” GL ET after arrival (import) / GL DJ after gate pass (export)', + }) + closeT1( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); + } + + @Post('bookings/:bookingId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) + grantGatepass( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantGatepass( + bookingId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip β€” settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + + @Post('bookings/:bookingId/documents') + @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)', + }) + uploadGlDocuments( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadDocuments(bookingId, files ?? []); + } + + @Post('bookings/:bookingId/duty-slip') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' }) + async uploadDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + const file = (files ?? [])[0]; + const booking = await this.bookingsService.findById(bookingId); + if (this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking)) { + return this.bookingClearanceService.uploadDutySlip(bookingId, file); + } + return this.glOperationsService.uploadDutySlip(bookingId, file); + } + + @Get('bookings/:bookingId/incidents') + @ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' }) + listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.glOperationsService.listIncidents(bookingId); + } + + @Post('bookings/:bookingId/incidents') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' }) + reportIncident( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ReportIncidentDto, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.reportIncident( + bookingId, + { + incidentType: dto.incidentType, + description: dto.description, + files: files ?? [], + }, + resolveAuthUserId(user), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts new file mode 100644 index 000000000..e0eece986 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -0,0 +1,118 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; + +import { BillingModule } from '../billing/billing.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 { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { SignaturesModule } from '../signatures/signatures.module'; +import { BookingsModule } from '../bookings/bookings.module'; + +import { ContractsController } from './contracts.controller'; +import { ContractsService } from './contracts.service'; +import { ContractsRepository } from './contracts.repository'; +import { ContractPricingService } from './contract-pricing.service'; +import { ContractTransitionService } from './contract-transition.service'; +import { ContractClearanceService } from './contract-clearance.service'; +import { BookingClearanceService } from './booking-clearance.service'; +import { ClearanceWorkflowService } from './clearance-workflow.service'; +import { ContractBookingService } from './contract-booking.service'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { GlOperationsService } from './gl-operations.service'; +import { BookingRequestService } from './booking-request.service'; +import { BookingRequestRepository } from './booking-request.repository'; + +import { Contract } from './entities/contract.entity'; +import { ContractRoute } from './entities/contract-route.entity'; +import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; +import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; +import { ContractSignature } from './entities/contract-signature.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; +import { ContractReviewNote } from './entities/contract-review-note.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ContractDocumentReview } from './entities/contract-document-review.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { ClearanceIncident } from './entities/clearance-incident.entity'; +import { BookingRequest } from './entities/booking-request.entity'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity'; + +import { ContractPdfService } from '../../contracts/contract-pdf.service'; +import { ContractRendererService } from '../../contracts/contract-renderer.service'; +import { ContractTemplateResolver } from '../../contracts/contract-template.resolver'; +import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Contract, + ContractRoute, + ContractCargoScope, + ContractRateSnapshot, + ContractSignature, + ContractApprovalStep, + ContractReviewNote, + ContractClearanceCycle, + ContractDocumentReview, + ClearanceMilestone, + ClearanceIncident, + BookingRequest, + Booking, + BookingContainerUnit, + ]), + BillingModule, + RuleEngineModule, + FileUploadSettingsModule, + DropdownSettingsModule, + FilesModule, + MinioModule, + SignaturesModule, + CompaniesModule, + // BookingsModule provides BookingsRepository/BookingPricingService used by the + // contract PDF builders (they read a Booking today β€” see docs/new-doc.md Β§3.3). + forwardRef(() => BookingsModule), + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), + ], + controllers: [ContractsController], + providers: [ + ContractsService, + ContractsRepository, + ContractPricingService, + ContractTransitionService, + ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, + ContractBookingService, + ClearanceMilestoneService, + GlOperationsService, + BookingRequestService, + BookingRequestRepository, + // Contract PDF providers (template resolution + render + PDF) β€” stateless + // helpers reused from src/contracts/. + ContractTemplateResolver, + ContractRendererService, + ContractPdfService, + ContractDocumentViewModelBuilder, + ], + exports: [ + ContractsService, + ContractsRepository, + ContractPricingService, + ContractTransitionService, + ContractClearanceService, + ClearanceWorkflowService, + BookingClearanceService, + ContractBookingService, + ClearanceMilestoneService, + ], +}) +export class ContractsModule {} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts new file mode 100644 index 000000000..9e464db51 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -0,0 +1,584 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm'; + +import { FileRecord } from '../files/entities/file.entity'; +import { Contract } from './entities/contract.entity'; +import { ContractApprovalStep } from './entities/contract-approval-step.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { + ContractDocReviewStatus, + ContractDocumentReview, +} from './entities/contract-document-review.entity'; +import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity'; +import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity'; +import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity'; + +export interface ContractListFilterOptions { + statuses?: string[]; + status?: string; + companyId?: string; + companyProfileId?: string; + contractKind?: string; + serviceTypeId?: string; + freightType?: string; + tradeDirection?: string; + paymentCurrency?: string; + customsClearingEnabled?: boolean; + createdFrom?: string; + createdTo?: string; +} + +@Injectable() +export class ContractsRepository extends BaseRepository { + constructor( + @InjectRepository(Contract) + repository: Repository, + private readonly dataSource: DataSource, + ) { + super(repository); + } + + /** Find a contract by its human-readable reference number. */ + findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } + + /** Count contracts created in a specific year. */ + async countByYear(year: number): Promise { + const startDate = new Date(year, 0, 1); + const endDate = new Date(year + 1, 0, 1); + + return this.repository + .createQueryBuilder('contract') + .where('contract.created_at >= :startDate', { startDate }) + .andWhere('contract.created_at < :endDate', { endDate }) + .getCount(); + } + + /** Find a contract by ID with all child collections, service type, company and files. */ + async findByIdWithRelations(id: string): Promise { + if (!id) return null; + + const contract = await this.repository + .createQueryBuilder('contract') + .leftJoinAndSelect('contract.routes', 'routes') + .leftJoinAndSelect('routes.originYard', 'routeOrigin') + .leftJoinAndSelect('routes.destinationYard', 'routeDestination') + .leftJoinAndSelect('contract.cargoScope', 'cargoScope') + .leftJoinAndSelect('cargoScope.cargoType', 'cargoType') + .leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots') + .leftJoinAndSelect('contract.signatures', 'signatures') + .leftJoinAndSelect('signatures.signatureFile', 'signatureFile') + .leftJoinAndSelect('contract.approvalSteps', 'approvalSteps') + .leftJoinAndSelect('contract.serviceType', 'serviceType') + .leftJoinAndSelect('contract.company', 'company') + .where('contract.id = :id', { id }) + .leftJoinAndMapMany( + 'contract.files', + FileRecord, + 'file', + "file.resource_id = contract.id AND file.resource = 'contracts'", + ) + .getOne(); + + return contract ?? null; + } + + /** Paginated list with optional multi-status filter (API tab queues). */ + async findAllPaginated( + options: ContractListFilterOptions & { + page: number; + pageSize: number; + sortBy?: string; + sortOrder?: 'ASC' | 'DESC'; + }, + ): Promise<{ + items: Contract[]; + total: number; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + }> { + const page = options.page; + const pageSize = options.pageSize; + + const qb = this.repository + .createQueryBuilder('contract') + .leftJoinAndSelect('contract.company', 'company') + .leftJoinAndSelect('contract.serviceType', 'serviceType') + .leftJoinAndSelect('contract.routes', 'routes') + .leftJoinAndSelect('routes.originYard', 'routeOrigin') + .leftJoinAndSelect('routes.destinationYard', 'routeDestination') + .leftJoinAndSelect('contract.cargoScope', 'cargoScope') + .where('contract.deleted_at IS NULL'); + + this.applyListFilters(qb, options); + + const sortField = + options.sortBy === 'contractValidUntil' + ? 'contract.contractValidUntil' + : 'contract.createdAt'; + qb.orderBy(sortField, options.sortOrder ?? 'DESC'); + + const [items, total] = await qb + .skip((page - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + + // Attach the generated contract PDF to each row so list/home can offer a + // direct download. Loaded separately to keep pagination counts correct. + await this.attachContractFiles(items); + + const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; + return { + items, + total, + meta: { + page, + pageSize, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; + } + + /** + * Load contract-resource files for the given contracts and attach them to + * `contract.files`. Kept separate from the paginated query so the one-to-many + * join doesn't inflate the page count. + */ + private async attachContractFiles(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + const ids = contracts.map((c) => c.id); + const files = await this.dataSource.getRepository(FileRecord).find({ + where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() }, + }); + const byContract = new Map(); + for (const file of files) { + const list = byContract.get(file.resourceId) ?? []; + list.push(file); + byContract.set(file.resourceId, list); + } + for (const contract of contracts) { + contract.files = byContract.get(contract.id) ?? []; + } + } + + async getStatusCounts(): Promise> { + const rows = await this.repository + .createQueryBuilder('contract') + .select('contract.status', 'status') + .addSelect('COUNT(*)::int', 'count') + .where('contract.deleted_at IS NULL') + .groupBy('contract.status') + .getRawMany<{ status: string; count: string }>(); + + return Object.fromEntries(rows.map((row) => [row.status, Number(row.count)])); + } + + async getListSummaryMetrics( + options: ContractListFilterOptions & { + page: number; + pageSize: number; + needsActionStatuses: readonly string[]; + }, + ): Promise<{ inQueue: number; onThisPage: number; needsAction: number }> { + const baseQb = () => { + const qb = this.repository + .createQueryBuilder('contract') + .where('contract.deleted_at IS NULL'); + this.applyListFilters(qb, options); + return qb; + }; + + const inQueue = await baseQb().getCount(); + + const needsAction = await baseQb() + .andWhere('contract.status IN (:...needsActionStatuses)', { + needsActionStatuses: [...options.needsActionStatuses], + }) + .getCount(); + + const offset = (options.page - 1) * options.pageSize; + const onThisPage = Math.min(options.pageSize, Math.max(0, inQueue - offset)); + + return { inQueue, onThisPage, needsAction }; + } + + private applyListFilters( + qb: SelectQueryBuilder, + options: ContractListFilterOptions, + ): void { + if (options.statuses?.length) { + qb.andWhere('contract.status IN (:...statuses)', { statuses: options.statuses }); + } else if (options.status) { + qb.andWhere('contract.status = :status', { status: options.status }); + } + if (options.companyId) { + qb.andWhere('contract.company_id = :companyId', { companyId: options.companyId }); + } + if (options.companyProfileId) { + qb.andWhere('contract.company_profile_id = :companyProfileId', { + companyProfileId: options.companyProfileId, + }); + } + if (options.contractKind) { + qb.andWhere('contract.contract_kind = :contractKind', { + contractKind: options.contractKind, + }); + } + if (options.customsClearingEnabled !== undefined) { + qb.andWhere('contract.customs_clearing_enabled = :customsClearingEnabled', { + customsClearingEnabled: options.customsClearingEnabled, + }); + } + if (options.serviceTypeId) { + qb.andWhere('contract.service_type_id = :serviceTypeId', { + serviceTypeId: options.serviceTypeId, + }); + } + if (options.freightType) { + qb.andWhere('contract.freight_type = :freightType', { + freightType: options.freightType, + }); + } + if (options.tradeDirection) { + qb.andWhere('contract.trade_direction = :tradeDirection', { + tradeDirection: options.tradeDirection, + }); + } + if (options.paymentCurrency) { + qb.andWhere('contract.payment_currency = :paymentCurrency', { + paymentCurrency: options.paymentCurrency, + }); + } + if (options.createdFrom) { + qb.andWhere('contract.created_at >= :createdFrom', { + createdFrom: options.createdFrom, + }); + } + if (options.createdTo) { + qb.andWhere('contract.created_at <= :createdTo', { createdTo: options.createdTo }); + } + } + + // ── Approval steps ───────────────────────────────────────────────────────── + + /** Lowest-order pending approval step (sequential enforcement). */ + async findNextPendingApprovalStep( + contractId: string, + ): Promise { + return this.dataSource.getRepository(ContractApprovalStep).findOne({ + where: { contractId, status: 'PENDING' }, + order: { stepOrder: 'ASC' }, + }); + } + + async findApprovalStepById( + contractId: string, + stepId: string, + ): Promise { + return this.dataSource.getRepository(ContractApprovalStep).findOne({ + where: { contractId, id: stepId }, + }); + } + + /** Mark an approval step complete. */ + async completeApprovalStep( + stepId: string, + actorId: string, + status: 'APPROVED' | 'REJECTED', + note?: string, + ): Promise { + await this.dataSource.getRepository(ContractApprovalStep).update(stepId, { + status, + actedByStaffId: actorId, + actedAt: new Date(), + note, + }); + } + + /** Check if all approval steps are approved. */ + async allApprovalStepsComplete(contractId: string): Promise { + const pending = await this.dataSource.getRepository(ContractApprovalStep).count({ + where: { contractId, status: 'PENDING' }, + }); + return pending === 0; + } + + /** Persist a contract approval step (instantiated at staff accept). */ + async createApprovalStep( + data: Partial, + ): Promise { + const repo = this.dataSource.getRepository(ContractApprovalStep); + return repo.save(repo.create(data)); + } + + // ── Signatures ────────────────────────────────────────────────────────────── + + findSignatures(contractId: string): Promise { + return this.dataSource.getRepository(ContractSignature).find({ + where: { contractId }, + relations: ['signatureFile'], + order: { signedAt: 'ASC' }, + }); + } + + findSignature( + contractId: string, + role: ContractSignerRole, + ): Promise { + return this.dataSource.getRepository(ContractSignature).findOne({ + where: { contractId, role }, + relations: ['signatureFile'], + }); + } + + async saveSignature(data: Partial): Promise { + const repo = this.dataSource.getRepository(ContractSignature); + const existing = await repo.findOne({ + where: { contractId: data.contractId!, role: data.role! }, + }); + if (existing) { + Object.assign(existing, data); + return repo.save(existing); + } + return repo.save(repo.create(data)); + } + + // ── Review notes ────────────────────────────────────────────────────────────── + + async createReviewNote( + contractId: string, + body: string, + noteType: ContractReviewNoteType, + authorUserId?: string, + authorRole?: string, + ): Promise { + const repo = this.dataSource.getRepository(ContractReviewNote); + return repo.save( + repo.create({ + contractId, + body, + noteType, + authorUserId: authorUserId ?? null, + authorRole: authorRole ?? null, + }), + ); + } + + async findLatestReviewNote( + contractId: string, + noteType?: ContractReviewNoteType, + ): Promise { + const repo = this.dataSource.getRepository(ContractReviewNote); + return repo.findOne({ + where: noteType ? { contractId, noteType } : { contractId }, + order: { createdAt: 'DESC' }, + }); + } + + // ── Pre-booking clearance document reviews ──────────────────────────────────── + + findDocumentReviews( + contractId: string, + cycleId?: string | null, + ): Promise { + return this.dataSource.getRepository(ContractDocumentReview).find({ + where: + cycleId !== undefined + ? { contractId, clearanceCycleId: cycleId === null ? IsNull() : cycleId } + : { contractId }, + order: { createdAt: 'ASC' }, + }); + } + + /** + * Upsert a document-review row to PENDING for a freshly uploaded file. Resets + * any prior QUERIED/APPROVED state so the GL re-reviews the new upload. Keyed + * on (contractId, clearanceCycleId, settingCode, fileKey). + */ + async upsertDocumentReviewPending(input: { + contractId: string; + clearanceCycleId?: string | null; + settingCode: string; + fileKey: string; + fileRecordId: string; + uploadedByRole?: 'CUSTOMER' | 'GL_ET' | 'GL_DJ'; + }): Promise { + const repo = this.dataSource.getRepository(ContractDocumentReview); + const cycleId = input.clearanceCycleId ?? null; + const existing = await repo.findOne({ + where: { + contractId: input.contractId, + clearanceCycleId: cycleId === null ? IsNull() : cycleId, + settingCode: input.settingCode, + fileKey: input.fileKey, + }, + }); + if (existing) { + await repo.update(existing.id, { + fileRecordId: input.fileRecordId, + status: 'PENDING', + note: null, + reviewedByStaffId: null, + reviewedAt: null, + }); + return; + } + await repo.save( + repo.create({ + contractId: input.contractId, + clearanceCycleId: cycleId, + settingCode: input.settingCode, + fileKey: input.fileKey, + fileRecordId: input.fileRecordId, + status: 'PENDING', + uploadedByRole: input.uploadedByRole ?? 'CUSTOMER', + }), + ); + } + + /** GL marks a document APPROVED or QUERIED (with an optional note). */ + async setDocumentReviewStatus(input: { + contractId: string; + clearanceCycleId?: string | null; + settingCode: string; + fileKey: string; + status: ContractDocReviewStatus; + staffId: string; + note?: string; + }): Promise { + const repo = this.dataSource.getRepository(ContractDocumentReview); + const cycleId = input.clearanceCycleId ?? null; + const existing = await repo.findOne({ + where: { + contractId: input.contractId, + clearanceCycleId: cycleId === null ? IsNull() : cycleId, + settingCode: input.settingCode, + fileKey: input.fileKey, + }, + }); + const patch = { + status: input.status, + note: input.note ?? null, + reviewedByStaffId: input.staffId, + reviewedAt: new Date(), + }; + if (existing) { + await repo.update(existing.id, patch); + return; + } + await repo.save( + repo.create({ + contractId: input.contractId, + clearanceCycleId: cycleId, + settingCode: input.settingCode, + fileKey: input.fileKey, + ...patch, + }), + ); + } + + // ── Clearance cycles ────────────────────────────────────────────────────────── + + /** The current (latest, non-completed) clearance cycle for a contract. */ + async currentCycle(contractId: string): Promise { + return this.dataSource.getRepository(ContractClearanceCycle).findOne({ + where: { contractId }, + order: { cycleNumber: 'DESC' }, + }); + } + + /** Open a new clearance cycle (incrementing cycle_number). */ + async openCycle( + contractId: string, + cycleNumber: number, + ): Promise { + const repo = this.dataSource.getRepository(ContractClearanceCycle); + return repo.save( + repo.create({ + contractId, + cycleNumber, + status: 'AWAITING_DOCUMENTS', + }), + ); + } + + async setCycleStatus( + cycleId: string, + status: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'bookingId' + | 'clearanceReadyAt' + | 'completedAt' + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + > + > = {}, + ): Promise { + await this.dataSource + .getRepository(ContractClearanceCycle) + .update(cycleId, { status, ...fields } as never); + } + + async updateCycle( + cycleId: string, + fields: Partial< + Pick< + ContractClearanceCycle, + | 'dutyRequired' + | 'vesselDepartureDate' + | 'roAmendmentRequestedAt' + | 'roHoldReason' + | 'currentPhase' + | 'status' + | 'preClearanceFinalizedAt' + | 'completedAt' + > + >, + ): Promise { + await this.dataSource.getRepository(ContractClearanceCycle).update(cycleId, fields as never); + } + + /** Link the GL-created booking to a clearance cycle. */ + async linkBooking(cycleId: string, bookingId: string): Promise { + await this.dataSource + .getRepository(ContractClearanceCycle) + .update(cycleId, { bookingId }); + } + + // ── Rate snapshots ────────────────────────────────────────────────────────── + + async createRateSnapshot( + data: Partial, + ): Promise { + const repo = this.dataSource.getRepository(ContractRateSnapshot); + return repo.save(repo.create(data)); + } + + async clearRateSnapshots(contractId: string): Promise { + await this.dataSource.getRepository(ContractRateSnapshot).delete({ contractId }); + } + + findRateSnapshots(contractId: string): Promise { + return this.dataSource.getRepository(ContractRateSnapshot).find({ + where: { contractId }, + order: { createdAt: 'ASC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts new file mode 100644 index 000000000..8d864aac7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -0,0 +1,526 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { CompaniesService } from '../companies/companies.service'; +import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; +import { CompanyStatus } from '../companies/entities/company.entity'; +import { ServiceType } from '../rule-engine/entities/service-type.entity'; +import { FilesService } from '../files/files.service'; +import { MinioService } from '../minio/minio.service'; +import { ContractsRepository } from './contracts.repository'; +import { CreateContractDto } from './dto/create-contract.dto'; +import { UpdateContractDto } from './dto/update-contract.dto'; +import { FilterContractDto } from './dto/filter-contract.dto'; +import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; +import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity'; +import { ContractRoute } from './entities/contract-route.entity'; +import { ContractCargoScope } from './entities/contract-cargo-scope.entity'; +import { FileRecord } from '../files/entities/file.entity'; + +/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */ +export interface PaginatedContracts { + items: Contract[]; + total: number; + meta: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; +} + +const NEEDS_ACTION_STATUSES = [ + 'SUBMITTED', + 'PENDING_APPROVAL', + 'APPROVED_PENDING_SIGNATURE', + 'SIGNED_CUSTOMER', +] as const; + +@Injectable() +export class ContractsService { + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly contractsRepository: ContractsRepository, + private readonly companiesService: CompaniesService, + private readonly filesService: FilesService, + private readonly minioService: MinioService, + ) {} + + /** Generate a unique contract reference number (CTR-YYYY-NNNNN). */ + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.contractsRepository.countByYear(year); + return `CTR-${year}-${String(count + 1).padStart(5, '0')}`; + } + + /** Whether a service type bundles customs clearance. */ + private async resolveIncludesCustoms(serviceTypeId: string): Promise { + const serviceType = await this.dataSource + .getRepository(ServiceType) + .findOne({ where: { id: serviceTypeId } }); + return serviceType?.includesCustoms ?? false; + } + + /** Validate cargo-scope rows against freight type (doc Β§5.4). */ + private assertCargoScopeShape( + freightType: string, + cargoScope: CreateContractDto['cargoScope'], + ): void { + if (freightType === 'CONTAINER') { + const sizes = cargoScope.filter((c) => + ['20ft', '40ft'].includes(c.containerSize ?? ''), + ); + if (sizes.length === 0) { + throw new BadRequestException( + 'CONTAINER contracts require at least one container size (20ft/40ft) in scope', + ); + } + } else { + const bulk = cargoScope.filter((c) => c.cargoTypeId); + if (bulk.length !== 1) { + throw new BadRequestException( + 'BULK contracts require exactly one cargo-type scope row', + ); + } + if (cargoScope.some((c) => c.containerSize)) { + throw new BadRequestException('BULK contracts must not set a container size'); + } + } + } + + /** Validate route count against contract kind (doc Β§5.3). */ + private assertRouteShape( + contractKind: string, + routes: CreateContractDto['routes'], + ): void { + if (contractKind === 'ONE_TIME' && routes.length !== 1) { + throw new BadRequestException('ONE_TIME contracts require exactly one route'); + } + if (routes.length < 1) { + throw new BadRequestException('A contract requires at least one route'); + } + } + + /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ + async create( + dto: CreateContractDto, + files: Express.Multer.File[], + userId?: string, + ): Promise<{ contract: Contract; warnings: string[] }> { + const warnings: string[] = []; + const isGovernment = dto.isGovernment === true; + + let companyId: string | null | undefined = dto.companyId; + if (isGovernment) { + if (!dto.governmentInstitution?.trim()) { + throw new BadRequestException( + 'governmentInstitution is required for government contracts', + ); + } + companyId = dto.companyId ?? null; + } else if (!companyId) { + if (!userId) { + throw new BadRequestException( + 'companyId is required or must be resolvable from auth token', + ); + } + const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + if (company.status !== CompanyStatus.Active) { + throw new ForbiddenException( + "Your company is awaiting approval β€” you can't create contracts yet.", + ); + } + companyId = company.id; + } + + this.assertCargoScopeShape(dto.freightType, dto.cargoScope); + this.assertRouteShape(dto.contractKind, dto.routes); + + const reference = dto.reference || (await this.generateReference()); + + // Stamp the operational profile (importer/exporter) for portal scoping. + let companyProfileId: string | null = null; + if (!isGovernment && companyId) { + let fallbackType: ProfileType | null = null; + if (userId) { + try { + const { profile } = + await this.companiesService.getCompanyInfoByUserId(userId); + fallbackType = profile.activeProfileType ?? null; + } catch { + // No profile (e.g. staff creating on behalf) β€” fall back to mapping. + } + } + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + fallbackType, + ); + + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } + } + + // Customs clearing is owned by the service type, not the customer. + const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); + + const contract = await this.contractsRepository.create({ + reference, + companyId: companyId ?? null, + companyProfileId, + isGovernment, + governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, + contractKind: dto.contractKind, + renewalOfId: dto.renewalOfId ?? null, + tradeDirection: dto.tradeDirection, + freightType: dto.freightType, + serviceTypeId: dto.serviceTypeId, + paymentCurrency: dto.paymentCurrency, + customsClearingEnabled: includesCustoms, + customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null), + equipmentReturn: dto.equipmentReturn ?? null, + firstMilePickupAddress: dto.firstMilePickupAddress ?? null, + firstMilePickupLat: dto.firstMilePickupLat ?? null, + firstMilePickupLng: dto.firstMilePickupLng ?? null, + lastMileDeliveryAddress: dto.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, + isHazardous: dto.isHazardous ?? false, + isReefer: dto.isReefer ?? false, + contractType: dto.contractType ?? null, + status: 'DRAFT', + clearanceStatus: 'NOT_APPLICABLE', + clearanceCycleNumber: 0, + } as never); + + await this.persistRoutes(contract.id, dto.routes); + await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind); + + if (files.length > 0) { + try { + await this.filesService.uploadMany(contract.id, 'contracts', files); + } catch { + warnings.push('File upload failed β€” contract was created without attached files.'); + } + } + + // Attach the company profile's onboarding / business-license documents to the + // contract by reference. The separate "Documents" intake step was removed β€” + // the profile documents are simply carried onto every contract automatically. + await this.attachProfileDocuments(contract.id, companyProfileId); + + return { contract: await this.findById(contract.id), warnings }; + } + + /** + * Copy a company profile's stored business-license / onboarding documents onto + * a contract by reference (no byte re-upload). Codes are slugged from each + * document name so they group under "Profile documents" on the contract detail + * page. No-op when the contract has no profile or the profile has no documents. + */ + private async attachProfileDocuments( + contractId: string, + companyProfileId: string | null, + ): Promise { + if (!companyProfileId) return; + const profile = await this.dataSource + .getRepository(CompanyProfile) + .findOne({ where: { id: companyProfileId } }); + const docs = profile?.businessLicenseFiles ?? []; + if (docs.length === 0) return; + + const slug = (name: string) => + name + .toLowerCase() + .replace(/\.[a-z0-9]+$/, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') || 'profile_document'; + + try { + await this.filesService.attachExistingFiles( + contractId, + 'contracts', + docs.map((d, i) => ({ + code: `${slug(d.name)}_${i + 1}`, + name: d.name, + url: d.url, + size: d.size, + mimeType: d.mimeType, + })), + ); + } catch { + // Non-fatal β€” the contract is still valid without the carried documents. + } + } + + private async persistRoutes( + contractId: string, + routes: CreateContractDto['routes'], + ): Promise { + const repo = this.dataSource.getRepository(ContractRoute); + await repo.save( + routes.map((r, i) => + repo.create({ + contractId, + originYardId: r.originYardId, + destinationYardId: r.destinationYardId, + km: r.km ?? null, + sortOrder: r.sortOrder ?? i, + }), + ), + ); + } + + private async persistCargoScope( + contractId: string, + cargoScope: CreateContractDto['cargoScope'], + contractKind: string, + ): Promise { + const repo = this.dataSource.getRepository(ContractCargoScope); + // A quantity cap only governs GENERAL contracts (multi-shipment draw-down). + // ONE_TIME allows a single booking, so any cap on it is meaningless β†’ null. + const isGeneral = contractKind === 'GENERAL'; + await repo.save( + cargoScope.map((c) => + repo.create({ + contractId, + containerSize: c.containerSize ?? null, + cargoTypeId: c.cargoTypeId ?? null, + cargoFreeText: c.cargoFreeText ?? null, + quantityCap: isGeneral ? (c.quantityCap ?? null) : null, + }), + ), + ); + } + + /** Update a DRAFT / CHANGES_REQUESTED contract. */ + async update( + id: string, + dto: UpdateContractDto, + files: Express.Multer.File[], + ): Promise<{ contract: Contract; warnings: string[] }> { + const existing = await this.findById(id); + if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) { + throw new BadRequestException( + 'Only DRAFT or CHANGES_REQUESTED contracts can be updated', + ); + } + + const warnings: string[] = []; + const freightType = dto.freightType ?? existing.freightType; + const contractKind = dto.contractKind ?? existing.contractKind; + + if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); + if (dto.routes) this.assertRouteShape(contractKind, dto.routes); + + const updates: Record = { + contractKind, + tradeDirection: dto.tradeDirection ?? existing.tradeDirection, + freightType, + serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId, + paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, + isHazardous: dto.isHazardous ?? existing.isHazardous, + isReefer: dto.isReefer ?? existing.isReefer, + equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, + firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, + firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, + firstMilePickupLng: dto.firstMilePickupLng ?? existing.firstMilePickupLng, + lastMileDeliveryAddress: + dto.lastMileDeliveryAddress ?? existing.lastMileDeliveryAddress, + lastMileDeliveryLat: dto.lastMileDeliveryLat ?? existing.lastMileDeliveryLat, + lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, + contractType: dto.contractType ?? existing.contractType, + }; + if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; + + // Customs clearing always mirrors the (possibly changed) service type. + const includesCustoms = await this.resolveIncludesCustoms( + dto.serviceTypeId ?? existing.serviceTypeId, + ); + updates.customsClearingEnabled = includesCustoms; + updates.customsClearingAgent = includesCustoms + ? null + : (dto.customsClearingAgent ?? existing.customsClearingAgent ?? null); + + await this.contractsRepository.update(id, updates); + + if (dto.routes) { + await this.dataSource.getRepository(ContractRoute).delete({ contractId: id }); + await this.persistRoutes(id, dto.routes); + } + if (dto.cargoScope) { + await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id }); + await this.persistCargoScope(id, dto.cargoScope, existing.contractKind); + } + + if (files.length > 0) { + await this.filesService.uploadMany(id, 'contracts', files); + } + + return { contract: await this.findById(id), warnings }; + } + + /** Parse comma-separated or repeated status query values. */ + private parseStatusFilter(filter: FilterContractDto): { + statuses?: string[]; + status?: string; + } { + const allowed = new Set(CONTRACT_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 {}; + } + + async findAll( + filter: FilterContractDto, + forceCompanyId?: string, + forceCompanyProfileId?: string, + ): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + + return this.contractsRepository.findAllPaginated({ + page, + pageSize, + ...statusFilter, + companyId: forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId ?? filter.companyProfileId, + contractKind: filter.contractKind, + serviceTypeId: filter.serviceTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + createdFrom: filter.createdFrom, + createdTo: filter.createdTo, + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } + + /** Aggregate metrics and status counts for the backoffice contract list. */ + async getListSummary(filter: FilterContractDto): Promise { + const page = filter.page ?? 1; + const pageSize = filter.pageSize ?? 20; + const statusFilter = this.parseStatusFilter(filter); + const listFilter = { + ...statusFilter, + companyId: filter.companyId, + contractKind: filter.contractKind, + serviceTypeId: filter.serviceTypeId, + freightType: filter.freightType, + tradeDirection: filter.tradeDirection, + paymentCurrency: filter.paymentCurrency, + createdFrom: filter.createdFrom, + createdTo: filter.createdTo, + }; + + const [statusCounts, metrics] = await Promise.all([ + this.contractsRepository.getStatusCounts(), + this.contractsRepository.getListSummaryMetrics({ + ...listFilter, + page, + pageSize, + needsActionStatuses: NEEDS_ACTION_STATUSES, + }), + ]); + + return { metrics, statusCounts }; + } + + /** Get a single contract by ID with relations and signed file URLs. */ + async findById(id: string): Promise { + const contract = await this.contractsRepository.findByIdWithRelations(id); + if (!contract) { + throw new NotFoundException(`Contract ${id} not found`); + } + + if (contract.files && contract.files.length > 0) { + contract.files = await Promise.all( + contract.files.map(async (file: FileRecord) => { + const objectName = this.minioService.getObjectNameFromUrl(file.url); + const signedUrl = await this.minioService.getSignedUrl(objectName, 300); + return { ...file, signedUrl } as FileRecord; + }), + ); + } + + return contract; + } + + async findByReference(reference: string): Promise { + const found = await this.contractsRepository.findByReference(reference); + if (!found) { + throw new NotFoundException(`Contract with reference "${reference}" not found`); + } + return this.findById(found.id); + } + + /** Upload intake documents for a DRAFT contract. */ + async uploadDocuments( + id: string, + files: Express.Multer.File[], + ): Promise { + const contract = await this.findById(id); + if (contract.status !== 'DRAFT') { + throw new BadRequestException( + 'Documents can only be uploaded for DRAFT contracts', + ); + } + await this.filesService.uploadMany(id, 'contracts', files); + return this.findById(id); + } + + async remove(id: string): Promise { + const contract = await this.findById(id); + if (contract.status !== 'DRAFT') { + throw new BadRequestException('Only DRAFT contracts can be deleted'); + } + await this.contractsRepository.softDelete(id); + } + + /** Resolve the company a customer user belongs to, for scoping their contracts. */ + async resolveCustomerCompanyId(userId: string): Promise { + try { + const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + return company?.id ?? null; + } catch { + return null; + } + } + + /** Authorize a customer's access to a single contract (hides as NotFound otherwise). */ + async assertCustomerCanAccessContract( + userId: string | undefined, + contract: Contract, + ): Promise { + if (!userId) { + throw new ForbiddenException('Authentication required'); + } + const companyId = await this.resolveCustomerCompanyId(userId); + if (!companyId || contract.companyId !== companyId) { + throw new NotFoundException(`Contract ${contract.id} not found`); + } + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts new file mode 100644 index 000000000..86e1260c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, Max, Min } from 'class-validator'; + +export class AcceptContractDto { + @ApiProperty({ + description: + 'How many days the contract stays valid, counted from the accept date. ' + + 'The contract is valid from now through now + validityDays.', + minimum: 1, + maximum: 3650, + example: 365, + }) + @IsInt() + @Min(1) + @Max(3650) + validityDays!: number; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts new file mode 100644 index 000000000..173857159 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MinLength } from 'class-validator'; + +export class ApproveStepDto { + @ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' }) + @IsString() + requiredRole!: string; +} + +export class RequestChangesDto { + @ApiProperty({ description: 'Note explaining what the customer must fix' }) + @IsString() + @MinLength(1) + note!: string; +} + +export class RejectContractDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class RejectStepDto { + @ApiProperty() + @IsString() + @MinLength(1) + reason!: string; +} + +export class CancelContractDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts new file mode 100644 index 000000000..78b7d97c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ContractListSummaryMetricsDto { + @ApiProperty({ example: 42 }) + inQueue!: number; + + @ApiProperty({ example: 10 }) + onThisPage!: number; + + @ApiProperty({ example: 8 }) + needsAction!: number; +} + +export class ContractListSummaryDto { + @ApiProperty({ type: ContractListSummaryMetricsDto }) + metrics!: ContractListSummaryMetricsDto; + + @ApiProperty({ description: 'Count per contract status', type: 'object', additionalProperties: { type: 'number' } }) + statusCounts!: Record; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts new file mode 100644 index 000000000..11d50494a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-request.dto.ts @@ -0,0 +1,105 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +/** A requested container line (no per-unit data β€” GL enters that at booking). */ +export class RequestContainerLineDto { + @ApiProperty({ description: '"20ft" | "40ft" β€” must be in the contract scope' }) + @IsString() + containerSize!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; +} + +export class RequestBulkLineDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string | null; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoWeightTons?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + itemCount?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; +} + +/** Customer's shipment request on a GENERAL customs contract. */ +export class CreateBookingRequestDto { + @ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' }) + @IsOptional() + @IsUUID() + contractRouteId?: string; + + @ApiPropertyOptional({ description: 'Preferred shipment day (informational).' }) + @IsOptional() + @IsString() + scheduledDate?: string; + + @ApiPropertyOptional({ type: [RequestContainerLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RequestContainerLineDto) + containers?: RequestContainerLineDto[]; + + @ApiPropertyOptional({ type: RequestBulkLineDto }) + @IsOptional() + @ValidateNested() + @Type(() => RequestBulkLineDto) + bulk?: RequestBulkLineDto; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} + +export class ReviewBookingRequestDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts new file mode 100644 index 000000000..ac404c9df --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -0,0 +1,145 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsString, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +/** One physical container under a booking line β€” entered at booking time. */ +export class CreateContainerUnitDto { + @ApiProperty() + @IsString() + containerNumber!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sealNumber?: string; + + @ApiProperty({ description: 'VGM in tons', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + vgmTons!: number; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReefer?: boolean; +} + +export class CreateBookingContainerLineDto { + @ApiProperty({ description: '"20ft" | "40ft" β€” must be in the contract scope' }) + @IsString() + containerSize!: string; + + @ApiProperty({ minimum: 1 }) + @IsInt() + @Min(1) + @Transform(({ value }) => Number(value)) + quantity!: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; + + @ApiProperty({ type: [CreateContainerUnitDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateContainerUnitDto) + units!: CreateContainerUnitDto[]; +} + +export class CreateBulkLineDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string | null; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + cargoWeightTons?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + itemCount?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + hazardousQuantity?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsInt() + @Min(0) + @Transform(({ value }) => Number(value)) + reeferQuantity?: number; +} + +/** Shipment booking created under a contract (Path A customer, Path B GL ET). */ +export class CreateBookingUnderContractDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Required for GENERAL multi-route contracts; ONE_TIME auto-selected.', + }) + @IsOptional() + @IsUUID() + contractRouteId?: string; + + @ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) + @IsDateString() + scheduledDate!: string; + + @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateBookingContainerLineDto) + containers?: CreateBookingContainerLineDto[]; + + @ApiPropertyOptional({ type: [CreateBulkLineDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CreateBulkLineDto) + bulkLines?: CreateBulkLineDto[]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts new file mode 100644 index 000000000..b20b99575 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -0,0 +1,247 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsIn, + IsNumber, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; + +import { CONTRACT_KINDS } from '../entities/contract.entity'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; +const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; +const EQUIPMENT_RETURNS = ['with_return', 'without_return'] as const; + +export { + CONTRACT_KINDS, + TRADE_DIRECTIONS, + FREIGHT_TYPES, + PAYMENT_CURRENCIES, + EQUIPMENT_RETURNS, +}; + +/** One cargo-scope row β€” a container size OR a bulk commodity. NO quantities. */ +export class CreateContractCargoScopeDto { + @ApiPropertyOptional({ description: '"20ft" | "40ft"; omit for bulk' }) + @IsOptional() + @IsString() + @MaxLength(10) + containerSize?: string | null; + + @ApiPropertyOptional({ format: 'uuid', description: 'FK to cargo_types.id' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string | null; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + cargoFreeText?: string | null; + + @ApiPropertyOptional({ + description: + 'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.', + minimum: 1, + }) + @IsOptional() + @IsNumber() + @Min(1) + @Transform(({ value }) => (value == null || value === '' ? null : Number(value))) + quantityCap?: number | null; +} + +/** A contracted lane (origin β†’ destination). Routes carry NO quantity. */ +export class CreateContractRouteInputDto { + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' }) + @IsUUID() + originYardId!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to yards.id (destination)' }) + @IsUUID() + destinationYardId!: string; + + @ApiPropertyOptional({ + description: 'Road billing distance (km); null for rail-only.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => + value === undefined || value === null || value === '' ? undefined : Number(value), + ) + km?: number; + + @ApiPropertyOptional({ minimum: 0 }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => + value === undefined || value === null || value === '' ? undefined : Number(value), + ) + sortOrder?: number; +} + +export class CreateContractDto { + @ApiPropertyOptional({ description: 'Unique contract reference (auto-generated if omitted)' }) + @IsOptional() + @IsString() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + reference?: string; + + @ApiPropertyOptional({ description: 'Staff only: government contract flag' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isGovernment?: boolean; + + @ApiPropertyOptional({ description: 'Required when isGovernment is true' }) + @ValidateIf((o) => o.isGovernment === true) + @IsString() + @MinLength(2) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + governmentInstitution?: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' }) + @ValidateIf((o) => o.isGovernment !== true) + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) + @IsIn([...CONTRACT_KINDS]) + contractKind!: string; + + @ApiPropertyOptional({ format: 'uuid', description: 'FK to contracts.id when renewing' }) + @IsOptional() + @IsUUID() + @Transform(({ value }) => (value === '' || value == null ? undefined : value)) + renewalOfId?: string; + + @ApiProperty({ enum: TRADE_DIRECTIONS }) + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection!: string; + + @ApiProperty({ enum: FREIGHT_TYPES }) + @IsIn([...FREIGHT_TYPES]) + freightType!: string; + + @ApiProperty({ format: 'uuid', description: 'FK to service_types.id' }) + @IsUUID() + serviceTypeId!: string; + + @ApiProperty({ enum: PAYMENT_CURRENCIES }) + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency!: string; + + @ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + customsClearingEnabled?: boolean; + + @ApiPropertyOptional({ maxLength: 200 }) + @IsOptional() + @IsString() + @MaxLength(200) + customsClearingAgent?: string; + + @ApiPropertyOptional({ enum: EQUIPMENT_RETURNS }) + @IsOptional() + @IsIn([...EQUIPMENT_RETURNS]) + equipmentReturn?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + firstMilePickupAddress?: string; + + @ApiPropertyOptional({ description: 'First-mile pickup latitude (-90..90)' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + firstMilePickupLat?: number; + + @ApiPropertyOptional({ description: 'First-mile pickup longitude (-180..180)' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + firstMilePickupLng?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + lastMileDeliveryAddress?: string; + + @ApiPropertyOptional({ description: 'Last-mile delivery latitude (-90..90)' }) + @IsOptional() + @IsNumber() + @Min(-90) + @Max(90) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + lastMileDeliveryLat?: number; + + @ApiPropertyOptional({ description: 'Last-mile delivery longitude (-180..180)' }) + @IsOptional() + @IsNumber() + @Min(-180) + @Max(180) + @Transform(({ value }) => (value == null || value === '' ? undefined : Number(value))) + lastMileDeliveryLng?: number; + + @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_hazardous' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isHazardous?: boolean; + + @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' }) + @IsOptional() + @IsBoolean() + @Transform(({ value }) => value === 'true' || value === true) + isReefer?: boolean; + + @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) + @IsOptional() + @IsString() + @MaxLength(20) + contractType?: string; + + @ApiProperty({ + type: [CreateContractCargoScopeDto], + description: + 'Cargo scope rows. CONTAINER: β‰₯1 size row. BULK: exactly one cargo-type row.', + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContractCargoScopeDto) + cargoScope!: CreateContractCargoScopeDto[]; + + @ApiProperty({ + type: [CreateContractRouteInputDto], + description: 'Contracted lanes. ONE_TIME: exactly 1. GENERAL: 1..N.', + }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateContractRouteInputDto) + routes!: CreateContractRouteInputDto[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts new file mode 100644 index 000000000..9c1c60921 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts @@ -0,0 +1,93 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator'; + +import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity'; + +const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; +const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const; +const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; + +export class FilterContractDto { + @ApiPropertyOptional({ enum: CONTRACT_STATUSES }) + @IsOptional() + @IsIn([...CONTRACT_STATUSES]) + status?: string; + + @ApiPropertyOptional({ + description: + 'Filter by statuses: comma-separated 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() + companyId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + + @ApiPropertyOptional({ enum: CONTRACT_KINDS }) + @IsOptional() + @IsIn([...CONTRACT_KINDS]) + contractKind?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + serviceTypeId?: string; + + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) + @IsOptional() + @IsIn([...FREIGHT_TYPES]) + freightType?: string; + + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) + @IsOptional() + @IsIn([...TRADE_DIRECTIONS]) + tradeDirection?: string; + + @ApiPropertyOptional({ enum: PAYMENT_CURRENCIES }) + @IsOptional() + @IsIn([...PAYMENT_CURRENCIES]) + paymentCurrency?: string; + + @ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' }) + @IsOptional() + @IsDateString() + createdFrom?: string; + + @ApiPropertyOptional({ description: 'Filter contracts created on/before this date (ISO)' }) + @IsOptional() + @IsDateString() + createdTo?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 1)) + page?: number; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }) => (value ? parseInt(value, 10) : 20)) + pageSize?: number; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsIn(['createdAt', 'contractValidUntil']) + sortBy?: string; + + @ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' }) + @IsOptional() + @IsIn(['ASC', 'DESC']) + sortOrder?: 'ASC' | 'DESC'; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts new file mode 100644 index 000000000..d99478554 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsIn, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MinLength, +} from 'class-validator'; + +import { CUSTOMS_RISK_LEVELS } from '../entities/clearance-milestone.entity'; +import { INCIDENT_TYPES } from '../entities/clearance-incident.entity'; + +export class AssignRiskDto { + @ApiProperty({ enum: CUSTOMS_RISK_LEVELS }) + @IsIn(CUSTOMS_RISK_LEVELS as unknown as string[]) + riskLevel!: (typeof CUSTOMS_RISK_LEVELS)[number]; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + note?: string; +} + +export class AdviseDutyDto { + @ApiProperty({ description: 'Duty & tax amount advised to the customer' }) + @Type(() => Number) + @IsNumber() + @IsPositive() + amount!: number; + + @ApiProperty({ example: 'ETB' }) + @IsString() + @MinLength(1) + currency!: string; + + @ApiPropertyOptional({ description: 'Customs declaration serial number' }) + @IsOptional() + @IsString() + declarationSerial?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + note?: string; +} + +export class AssignStationDto { + @ApiProperty({ description: 'Origin-station yard the shipment is routed to' }) + @IsUUID() + stationYardId!: string; + + @ApiPropertyOptional({ description: 'GL staff user bound to this shipment' }) + @IsOptional() + @IsUUID() + staffId?: string; +} + +export class ReportIncidentDto { + @ApiProperty({ enum: INCIDENT_TYPES }) + @IsIn(INCIDENT_TYPES as unknown as string[]) + incidentType!: (typeof INCIDENT_TYPES)[number]; + + @ApiProperty({ description: 'Mandatory free-text narrative of the anomaly' }) + @IsString() + @MinLength(1) + description!: string; +} + +export class CompleteMilestoneDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts new file mode 100644 index 000000000..6b784073b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsNumber, IsOptional, IsString, Min } from 'class-validator'; + +export class AdviseContractDutyDto { + @ApiProperty({ description: 'Whether the customer must pay duty/tax' }) + @IsBoolean() + dutyRequired!: boolean; + + @ApiPropertyOptional({ description: 'Duty amount (required when dutyRequired is true)' }) + @IsOptional() + @IsNumber() + @Min(0) + amount?: number; + + @ApiPropertyOptional({ default: 'ETB' }) + @IsOptional() + @IsString() + currency?: string; + + @ApiPropertyOptional({ description: 'Declaration / payment reference code' }) + @IsOptional() + @IsString() + declarationSerial?: string; +} + +export class ReleaseOrderDto { + @ApiProperty({ description: 'Vessel departure date (ISO date YYYY-MM-DD)' }) + @IsString() + vesselDepartureDate!: string; +} + +export class RoAmendmentDto { + @ApiPropertyOptional({ description: 'Note to customer / ET GL about the amendment request' }) + @IsOptional() + @IsString() + note?: string; +} + +export class GatepassDto { + @ApiPropertyOptional({ + description: 'When the gate pass was granted (ISO datetime; defaults to now)', + }) + @IsOptional() + @IsString() + gatepassAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/renew-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/renew-contract.dto.ts new file mode 100644 index 000000000..d3eea9b5a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/renew-contract.dto.ts @@ -0,0 +1,9 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class RenewContractDto { + @ApiPropertyOptional({ description: 'Reference of the prior contract being renewed.' }) + @IsOptional() + @IsString() + previousContractReference?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/review-clearance-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/review-clearance-document.dto.ts new file mode 100644 index 000000000..092ee2c3d --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/review-clearance-document.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class ReviewClearanceDocumentDto { + @ApiProperty({ description: 'The document fileKey being reviewed' }) + @IsString() + @MinLength(1) + fileKey!: string; + + @ApiProperty({ enum: ['APPROVED', 'QUERIED'] }) + @IsIn(['APPROVED', 'QUERIED']) + status!: 'APPROVED' | 'QUERIED'; + + @ApiPropertyOptional({ description: 'Required when querying a document' }) + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts new file mode 100644 index 000000000..febe7a83b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignContractDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) + @IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO']) + role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO'; + + @ApiPropertyOptional({ + description: + 'PNG signature image as base64 (with or without data URL prefix). ' + + 'Optional: when omitted, the signer\'s reusable saved signature from their ' + + 'profile is used instead.', + }) + @IsOptional() + @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/contracts/dto/update-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/update-contract.dto.ts new file mode 100644 index 000000000..0e8c3158b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/update-contract.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateContractDto } from './create-contract.dto'; + +/** Partial contract update β€” allowed only in DRAFT / CHANGES_REQUESTED. */ +export class UpdateContractDto extends PartialType(CreateContractDto) {} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts new file mode 100644 index 000000000..6582376d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/booking-request.entity.ts @@ -0,0 +1,61 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import type { Freight } from '@edr/types'; + +import { Contract } from './contract.entity'; + +/** + * A customer's request to ship under a GENERAL customs (Path B) contract. The + * customer cannot book directly; they submit the date + quantities here, Global + * Logistics reviews the queue, then creates the booking on their behalf β€” after + * which per-booking customs clearance begins. ONE_TIME contracts do not use this + * (they keep contract-level clearance). See plan: per-booking clearance. + */ +@Entity({ schema: 'freight', name: 'booking_requests' }) +@Index(['contractId']) +@Index(['status']) +@Index(['contractId', 'status']) +export class BookingRequest extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 40, default: '' }) + reference!: string; + + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) + contractRouteId?: string | null; + + /** Customer's preferred shipment day β€” informational; GL sets the binding date. */ + @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) + scheduledDate?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 16, default: 'PENDING' }) + status!: Freight.BookingRequestStatus; + + /** Requested quantities (container lines or one bulk line) β€” no per-unit data. */ + @Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" }) + requestedLines!: Freight.RequestedShipmentLines; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string | null; + + /** Set when GL accepts the request and creates the booking. */ + @Column({ name: 'created_booking_id', type: 'uuid', nullable: true }) + createdBookingId?: string | null; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'review_note', type: 'text', nullable: true }) + reviewNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts new file mode 100644 index 000000000..6d2afce3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -0,0 +1,49 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; + +/** + * Standard anomaly taxonomy for cargo exception reporting at a border/port + * station (GL Import US-07 AC1.2). GL Djibouti logs one of these against a + * shipment with a free-text narrative and photo evidence; a high-priority alert + * is then surfaced to GL Ethiopia. + */ +export const INCIDENT_TYPES = [ + 'SEAL_BROKEN', + 'CONTAINER_OPENED', + 'CONTAINER_DAMAGED', + 'FLUID_LEAKING', +] as const; +export type IncidentType = (typeof INCIDENT_TYPES)[number]; + +/** + * A cargo exception/damage report raised during loading or handover. Attaches to + * a booking (post-booking phase) and carries one or more photo file references + * for verification. See docs/new-doc.md Β§14 gap #18, GL Import US-07. + */ +@Entity({ schema: 'freight', name: 'clearance_incidents' }) +@Index(['bookingId']) +export class ClearanceIncident extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'incident_type', type: 'varchar', length: 32 }) + incidentType!: IncidentType; + + @Column({ name: 'description', type: 'text' }) + description!: string; + + /** MinIO file ids of the uploaded photo evidence (.jpg). */ + @Column({ name: 'photo_file_ids', type: 'jsonb', default: () => "'[]'" }) + photoFileIds!: string[]; + + @Column({ name: 'reported_by_user_id', type: 'uuid', nullable: true }) + reportedByUserId?: string | null; + + @Column({ name: 'reported_at', type: 'timestamptz', default: () => 'NOW()' }) + reportedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts new file mode 100644 index 000000000..d4676b8cd --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -0,0 +1,86 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Contract } from './contract.entity'; + +export const MILESTONE_STATUSES = ['PENDING', 'COMPLETED', 'SKIPPED'] as const; +export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number]; + +export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const; +export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number]; + +export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const; +export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; + +/** + * Structured payload some milestones carry beyond a plain note (doc Β§11.3): + * - RISK_ASSIGNED β†’ `riskLevel` + * - DUTY_TAXES_ADVISED β†’ `dutyAmount`, `dutyCurrency`, `declarationSerial` + * Stored on the milestone so the timeline can render the value inline. + */ +export interface MilestoneMetadata { + riskLevel?: CustomsRiskLevel; + dutyAmount?: number; + dutyCurrency?: string; + declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; +} + +/** + * A GL clearance milestone (18–23 per direction). Pre-booking milestones attach + * to contract_id + clearance_cycle_id; post-booking milestones to booking_id. + * See Β§5.12, Β§5.16, Β§11.3. + */ +@Entity({ schema: 'freight', name: 'clearance_milestones' }) +@Index(['bookingId']) +@Index(['contractId']) +@Index(['ownerRegion', 'status']) +export class ClearanceMilestone extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + @Column({ name: 'contract_id', type: 'uuid', nullable: true }) + contractId?: string | null; + + @ManyToOne(() => Contract, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract | null; + + @Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true }) + clearanceCycleId?: string | null; + + @Column({ name: 'milestone_code', type: 'varchar', length: 64 }) + milestoneCode!: string; + + @Column({ name: 'milestone_label', type: 'varchar', length: 255 }) + milestoneLabel!: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: MilestoneStatus; + + @Column({ name: 'owner_region', type: 'varchar', length: 5, nullable: true }) + ownerRegion?: MilestoneOwnerRegion | null; + + @Column({ name: 'triggered_by_doc', type: 'boolean', default: false }) + triggeredByDoc!: boolean; + + @Column({ name: 'triggered_at', type: 'timestamptz', nullable: true }) + triggeredAt?: Date | null; + + @Column({ name: 'triggered_by_user_id', type: 'uuid', nullable: true }) + triggeredByUserId?: string | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'metadata', type: 'jsonb', nullable: true }) + metadata?: MilestoneMetadata | null; + + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) + sortOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts new file mode 100644 index 000000000..3c8c7fd5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Contract } from './contract.entity'; + +export const CONTRACT_APPROVAL_STEP_STATUSES = [ + 'PENDING', + 'APPROVED', + 'REJECTED', + 'SKIPPED', +] as const; +export type ContractApprovalStepStatus = + (typeof CONTRACT_APPROVAL_STEP_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'contract_approval_steps' }) +@Index(['contractId']) +@Index(['status']) +@Index(['contractId', 'stepOrder']) +export class ContractApprovalStep extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.approvalSteps, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'step_order', type: 'smallint', default: 0 }) + stepOrder!: number; + + @Column({ name: 'required_role', type: 'varchar', length: 40 }) + requiredRole!: string; + + @Column({ name: 'blocks_role', type: 'varchar', length: 40, nullable: true }) + blocksRole?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: ContractApprovalStepStatus; + + @Column({ name: 'acted_by_staff_id', type: 'uuid', nullable: true }) + actedByStaffId?: string | null; + + @Column({ name: 'acted_at', type: 'timestamptz', nullable: true }) + actedAt?: Date | null; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-cargo-scope.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-cargo-scope.entity.ts new file mode 100644 index 000000000..9d5778dde --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-cargo-scope.entity.ts @@ -0,0 +1,44 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; +import { Contract } from './contract.entity'; + +/** + * What cargo sizes/commodities are in scope for a contract β€” NO quantities. + * Container: one row per enabled size (20ft/40ft). Bulk: one row with a cargo + * type. See Β§5.4. + */ +@Entity({ schema: 'freight', name: 'contract_cargo_scope' }) +@Index(['contractId']) +export class ContractCargoScope extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.cargoScope, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + /** '20ft' | '40ft'; null for bulk. */ + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + + @Column({ name: 'cargo_type_id', type: 'uuid', nullable: true }) + cargoTypeId?: string | null; + + @ManyToOne(() => CargoType, { nullable: true }) + @JoinColumn({ name: 'cargo_type_id' }) + cargoType?: CargoType | null; + + @Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true }) + cargoFreeText?: string | null; + + /** + * GENERAL contracts: total cargo quantity allowed across ALL shipments on this + * scope line over the validity window. Container β†’ number of containers of + * this size; bulk β†’ tons (or items for per-item commodities). Bookings draw + * down against it until the cap is reached. NULL = uncapped (always NULL for + * ONE_TIME, which allows a single booking). + */ + @Column({ name: 'quantity_cap', type: 'numeric', precision: 12, scale: 2, nullable: true }) + quantityCap?: number | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts new file mode 100644 index 000000000..3c101f58e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -0,0 +1,58 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Contract } from './contract.entity'; + +/** + * One pre-booking clearance round on a contract (Path B). GENERAL contracts run + * many cycles; ONE_TIME uses cycle_number = 1. The booking GL creates is linked + * back via bookingId. See Β§5.16. + */ +@Entity({ schema: 'freight', name: 'contract_clearance_cycles' }) +@Index(['contractId']) +export class ContractClearanceCycle extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.clearanceCycles, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'cycle_number', type: 'int' }) + cycleNumber!: number; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'AWAITING_DOCUMENTS' }) + status!: string; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'now()' }) + startedAt!: Date; + + @Column({ name: 'clearance_ready_at', type: 'timestamptz', nullable: true }) + clearanceReadyAt?: Date | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; + + /** ET GL toggle: whether customer must pay duty/tax before DO collection (import). */ + @Column({ name: 'duty_required', type: 'boolean', nullable: true }) + dutyRequired?: boolean | null; + + /** Export RO vessel departure date (Path B export). */ + @Column({ name: 'vessel_departure_date', type: 'date', nullable: true }) + vesselDepartureDate?: string | null; + + @Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true }) + roAmendmentRequestedAt?: Date | null; + + @Column({ name: 'ro_hold_reason', type: 'text', nullable: true }) + roHoldReason?: string | null; + + @Column({ name: 'current_phase', type: 'varchar', length: 40, nullable: true }) + currentPhase?: string | null; + + /** ET GL confirms import pre-clearance complete β€” unlocks Djibouti DO upload. */ + @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) + preClearanceFinalizedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-document-review.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-review.entity.ts new file mode 100644 index 000000000..141e39302 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-document-review.entity.ts @@ -0,0 +1,55 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Contract } from './contract.entity'; + +export const CONTRACT_DOC_REVIEW_STATUSES = ['PENDING', 'APPROVED', 'QUERIED'] as const; +export type ContractDocReviewStatus = + (typeof CONTRACT_DOC_REVIEW_STATUSES)[number]; + +export const CONTRACT_DOC_UPLOADER_ROLES = ['CUSTOMER', 'GL_ET', 'GL_DJ'] as const; +export type ContractDocUploaderRole = + (typeof CONTRACT_DOC_UPLOADER_ROLES)[number]; + +/** + * Per-document pre-booking clearance review on a contract (Path B). Mirrors + * BookingDocumentReview but keyed on contract_id (+ optional clearance cycle). + * See Β§5.16. + */ +@Entity({ schema: 'freight', name: 'contract_document_review' }) +@Index(['contractId']) +@Index(['status']) +export class ContractDocumentReview extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'clearance_cycle_id', type: 'uuid', nullable: true }) + clearanceCycleId?: string | null; + + @Column({ name: 'setting_code', type: 'varchar', length: 128 }) + settingCode!: string; + + @Column({ name: 'file_key', type: 'varchar', length: 128 }) + fileKey!: string; + + @Column({ name: 'file_record_id', type: 'uuid', nullable: true }) + fileRecordId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' }) + status!: ContractDocReviewStatus; + + @Column({ name: 'note', type: 'text', nullable: true }) + note?: string | null; + + @Column({ name: 'uploaded_by_role', type: 'varchar', length: 20, default: 'CUSTOMER' }) + uploadedByRole!: ContractDocUploaderRole; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts new file mode 100644 index 000000000..eb0a607cc --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -0,0 +1,47 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Contract } from './contract.entity'; + +/** + * Frozen UNIT rates at contract submit time β€” one row per rate line. The booking + * computes totals from these Γ— entered quantities. See Β§5.7. + */ +@Entity({ schema: 'freight', name: 'contract_rate_snapshots' }) +@Index(['contractId']) +export class ContractRateSnapshot extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.rateSnapshots, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'rate_id', type: 'uuid', nullable: true }) + rateId?: string | null; + + @Column({ name: 'rate_code', type: 'varchar', length: 64 }) + rateCode!: string; + + @Column({ name: 'description', type: 'varchar', length: 255, nullable: true }) + description?: string | null; + + @Column({ name: 'unit_price', type: 'numeric', precision: 14, scale: 2 }) + unitPrice!: number; + + /** per_container | per_ton | per_item | per_km | flat */ + @Column({ name: 'unit_of_measure', type: 'varchar', length: 32 }) + unitOfMeasure!: string; + + @Column({ name: 'currency', type: 'varchar', length: 5 }) + currency!: string; + + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + + @Column({ name: 'is_surcharge', type: 'boolean', default: false }) + isSurcharge!: boolean; + + /** is_hazardous | is_reefer when this is a conditional surcharge. */ + @Column({ name: 'conditional_on', type: 'varchar', length: 32, nullable: true }) + conditionalOn?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts new file mode 100644 index 000000000..5b744338e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts @@ -0,0 +1,36 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Contract } from './contract.entity'; + +export const CONTRACT_REVIEW_NOTE_TYPES = [ + 'CHANGES_REQUESTED', + 'REJECTION', + 'STAFF_NOTE', + 'CUSTOMER_NOTE', + 'AMENDMENT', +] as const; +export type ContractReviewNoteType = + (typeof CONTRACT_REVIEW_NOTE_TYPES)[number]; + +@Entity({ schema: 'freight', name: 'contract_review_notes' }) +@Index(['contractId']) +export class ContractReviewNote extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.reviewNotes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'note_type', type: 'varchar', length: 40 }) + noteType!: ContractReviewNoteType; + + @Column({ name: 'body', type: 'text' }) + body!: string; + + @Column({ name: 'author_role', type: 'varchar', length: 20, nullable: true }) + authorRole?: string | null; + + @Column({ name: 'author_user_id', type: 'uuid', nullable: true }) + authorUserId?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-route.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-route.entity.ts new file mode 100644 index 000000000..3d0040330 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-route.entity.ts @@ -0,0 +1,40 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { Contract } from './contract.entity'; + +/** + * An allowed originβ†’destination lane of a contract. Routes carry NO quantity β€” + * the contract scope just lists which lanes shipments may use. See Β§5.3. + */ +@Entity({ schema: 'freight', name: 'contract_routes' }) +@Index(['contractId']) +export class ContractRoute extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.routes, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'origin_yard_id', type: 'uuid' }) + originYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'origin_yard_id' }) + originYard?: Yard; + + @Column({ name: 'destination_yard_id', type: 'uuid' }) + destinationYardId!: string; + + @ManyToOne(() => Yard) + @JoinColumn({ name: 'destination_yard_id' }) + destinationYard?: Yard; + + /** Road billing distance; null for rail-only. */ + @Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + km?: number | null; + + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) + sortOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts new file mode 100644 index 000000000..bed79d8b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { FileRecord } from '../../files/entities/file.entity'; +import { Contract } from './contract.entity'; + +export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] as const; +export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number]; + +@Entity({ schema: 'freight', name: 'contract_signatures' }) +@Index(['contractId']) +export class ContractSignature extends BaseEntity { + @Column({ name: 'contract_id', type: 'uuid' }) + contractId!: string; + + @ManyToOne(() => Contract, (c) => c.signatures, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contract_id' }) + contract?: Contract; + + @Column({ name: 'role', type: 'varchar', length: 20 }) + role!: ContractSignerRole; + + @Column({ name: 'signer_display_name', type: 'varchar', length: 255 }) + signerDisplayName!: string; + + @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: 'signed_at', type: 'timestamptz', default: () => 'now()' }) + signedAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts new file mode 100644 index 000000000..0461d3736 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -0,0 +1,257 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; + +import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; +import { ServiceType } from '../../rule-engine/entities/service-type.entity'; +import { FileRecord } from '../../files/entities/file.entity'; +import { ContractRoute } from './contract-route.entity'; +import { ContractCargoScope } from './contract-cargo-scope.entity'; +import { ContractRateSnapshot } from './contract-rate-snapshot.entity'; +import { ContractSignature } from './contract-signature.entity'; +import { ContractApprovalStep } from './contract-approval-step.entity'; +import { ContractReviewNote } from './contract-review-note.entity'; +import { ContractClearanceCycle } from './contract-clearance-cycle.entity'; + +export const CONTRACT_STATUSES = [ + 'DRAFT', + 'SUBMITTED', + 'PRICE_CHANGED_PENDING_CONFIRM', + 'CHANGES_REQUESTED', + 'PENDING_APPROVAL', + 'APPROVED', + 'APPROVED_PENDING_SIGNATURE', + 'CONTRACT_READY', + 'SIGNED_CUSTOMER', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'CONTRACT_CLOSED', + 'EXPIRED', + 'REJECTED', + 'CANCELLED', + 'RENEWAL_DRAFT', + 'RENEWAL_SUBMITTED', + 'RENEWAL_PENDING_APPROVAL', + 'AMENDMENTS_PROPOSED', + 'ARCHIVED', +] as const; + +export type ContractStatus = (typeof CONTRACT_STATUSES)[number]; + +export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const; +export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; + +export const CONTRACT_CLEARANCE_STATUSES = [ + 'NOT_APPLICABLE', + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', // Path B β€” GL may create the booking + 'SELF_CLEARED', // Path A β€” Operations approved self-clearance; customer may book + 'ACTIVE_SHIPMENT_IN_PROGRESS', +] as const; +export type ContractClearanceStatusValue = + (typeof CONTRACT_CLEARANCE_STATUSES)[number]; + +/** Statuses where the customer may still edit contract fields. */ +export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [ + 'DRAFT', + 'CHANGES_REQUESTED', +]; + +/** + * The legal/commercial agreement. Defines what cargo sizes/commodities, routes, + * and flags are in scope plus the frozen unit rates β€” but NO quantities. Spawns + * shipment {@link Booking} rows via bookings.contract_id. See docs/new-doc.md Β§5.2. + */ +@Entity({ schema: 'freight', name: 'contracts' }) +@Index(['companyId']) +@Index(['status']) +@Index(['contractKind']) +export class Contract extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) + reference!: string; + + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company?: Company | null; + + @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) + companyProfileId?: string | null; + + @ManyToOne(() => CompanyProfile, { nullable: true }) + @JoinColumn({ name: 'company_profile_id' }) + companyProfile?: CompanyProfile | null; + + @Column({ name: 'is_government', type: 'boolean', default: false }) + isGovernment!: boolean; + + @Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true }) + governmentInstitution?: string | null; + + @Column({ name: 'contract_kind', type: 'varchar', length: 20 }) + contractKind!: ContractKindValue; + + @Column({ name: 'renewal_of_id', type: 'uuid', nullable: true }) + renewalOfId?: string | null; + + @ManyToOne(() => Contract, { nullable: true }) + @JoinColumn({ name: 'renewal_of_id' }) + renewalOf?: Contract | null; + + @Column({ name: 'trade_direction', type: 'varchar', length: 10 }) + tradeDirection!: string; + + @Column({ name: 'freight_type', type: 'varchar', length: 20 }) + freightType!: string; + + @Column({ name: 'service_type_id', type: 'uuid' }) + serviceTypeId!: string; + + @ManyToOne(() => ServiceType) + @JoinColumn({ name: 'service_type_id' }) + serviceType?: ServiceType; + + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) + paymentCurrency!: string; + + @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) + customsClearingEnabled!: boolean; + + @Column({ name: 'customs_clearing_agent', type: 'varchar', length: 200, nullable: true }) + customsClearingAgent?: string | null; + + @Column({ name: 'equipment_return', type: 'varchar', length: 20, nullable: true }) + equipmentReturn?: string | null; + + @Column({ name: 'first_mile_pickup_address', type: 'text', nullable: true }) + firstMilePickupAddress?: string | null; + + @Column({ name: 'first_mile_pickup_lat', type: 'numeric', precision: 10, scale: 7, nullable: true }) + firstMilePickupLat?: number | null; + + @Column({ name: 'first_mile_pickup_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) + firstMilePickupLng?: number | null; + + @Column({ name: 'last_mile_delivery_address', type: 'text', nullable: true }) + lastMileDeliveryAddress?: string | null; + + @Column({ name: 'last_mile_delivery_lat', type: 'numeric', precision: 10, scale: 7, nullable: true }) + lastMileDeliveryLat?: number | null; + + @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) + lastMileDeliveryLng?: number | null; + + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) + isHazardous!: boolean; + + @Column({ name: 'is_reefer', type: 'boolean', default: false }) + isReefer!: boolean; + + @Column({ name: 'estimated_shipment_date', type: 'timestamptz', nullable: true }) + estimatedShipmentDate?: Date | null; + + @Column({ name: 'contract_validity_days', type: 'int', nullable: true }) + contractValidityDays?: number | null; + + @Column({ name: 'contract_valid_from', type: 'timestamptz', nullable: true }) + contractValidFrom?: Date | null; + + @Column({ name: 'contract_valid_until', type: 'timestamptz', nullable: true }) + contractValidUntil?: Date | null; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date | null; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) + status!: string; + + @Column({ name: 'clearance_status', type: 'varchar', length: 40, default: 'NOT_APPLICABLE' }) + clearanceStatus!: string; + + @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) + clearanceCycleNumber!: number; + + @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) + pricingBreakdown?: Record | null; + + @Column({ name: 'pricing_display_mode', type: 'varchar', length: 20, default: 'UNIT_RATES', nullable: true }) + pricingDisplayMode?: string | null; + + @Column({ name: 'contract_type', type: 'varchar', length: 20, nullable: true }) + contractType?: string | null; + + @Column({ name: 'contract_template_key', type: 'varchar', length: 128, nullable: true }) + contractTemplateKey?: string | null; + + @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) + contractGeneratedAt?: Date | null; + + @Column({ name: 'contract_summary', type: 'text', nullable: true }) + contractSummary?: string | null; + + @Column({ name: 'version_number', type: 'int', default: 1 }) + versionNumber!: number; + + @Column({ name: 'financial_terms', type: 'jsonb', nullable: true }) + financialTerms?: Record | null; + + @Column({ name: 'approved_by_staff_id', type: 'uuid', nullable: true }) + approvedByStaffId?: string | null; + + @Column({ name: 'approved_by_staff_at', type: 'timestamptz', nullable: true }) + approvedByStaffAt?: Date | null; + + @Column({ name: 'signed_by_director_id', type: 'uuid', nullable: true }) + signedByDirectorId?: string | null; + + @Column({ name: 'signed_by_director_at', type: 'timestamptz', nullable: true }) + signedByDirectorAt?: Date | null; + + @Column({ name: 'signed_by_ceo_id', type: 'uuid', nullable: true }) + signedByCeoId?: string | null; + + @Column({ name: 'signed_by_ceo_at', type: 'timestamptz', nullable: true }) + signedByCeoAt?: Date | null; + + @Column({ name: 'customer_signed_at', type: 'timestamptz', nullable: true }) + customerSignedAt?: Date | null; + + @Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true }) + fullyExecutedAt?: Date | null; + + @Column({ name: 'locked_at', type: 'timestamptz', nullable: true }) + lockedAt?: Date | null; + + @OneToMany(() => ContractRoute, (r) => r.contract) + routes?: ContractRoute[]; + + @OneToMany(() => ContractCargoScope, (c) => c.contract) + cargoScope?: ContractCargoScope[]; + + @OneToMany(() => ContractRateSnapshot, (s) => s.contract) + rateSnapshots?: ContractRateSnapshot[]; + + @OneToMany(() => ContractSignature, (s) => s.contract) + signatures?: ContractSignature[]; + + @OneToMany(() => ContractApprovalStep, (s) => s.contract) + approvalSteps?: ContractApprovalStep[]; + + @OneToMany(() => ContractReviewNote, (n) => n.contract) + reviewNotes?: ContractReviewNote[]; + + @OneToMany(() => ContractClearanceCycle, (c) => c.contract) + clearanceCycles?: ContractClearanceCycle[]; + + @OneToMany(() => FileRecord, (file) => file.resourceId, { + createForeignKeyConstraints: false, + }) + files?: FileRecord[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts new file mode 100644 index 000000000..8fed3a8ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -0,0 +1,843 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; + +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; +import { FilesService } from '../files/files.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; +import { + ClearanceIncident, + IncidentType, +} from './entities/clearance-incident.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { + persistExportTransportUploads, + persistT1TransportUploads, +} from './phased-clearance.util'; + +/** + * Maps a GL post-booking document `code` to the milestone it auto-completes when + * uploaded (doc Β§11.3/Β§12.2 β€” doc-triggered milestones). Uploading the document + * marks the milestone done so the timeline advances without a separate click. + */ +const DOC_CODE_TO_MILESTONE: Record = { + release_order: 'RELEASE_ORDER_SECURED', // export β€” GL DJ + delivery_order: 'DO_COLLECTED', // import β€” GL DJ + // t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only + // when GL Ethiopia accepts the T1 set after the train arrives (closeT1). + import_release: 'IMPORT_RELEASE_GRANTED', // import β€” GL ET + full_in_interchange: 'OFFLOADED', // export β€” GL DJ + final_declaration: 'IMPORT_PROCESS_COMPLETED', // import β€” GL ET + export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export β€” GL ET post-allocation +}; + +/** + * Operational Global Logistics actions that hang off a shipment booking after GL + * creates it: station routing, damage/incident reporting, and the phased GL + * document uploads (Release Order, Delivery Order, T1, etc.) that advance + * doc-triggered milestones. See docs/new-doc.md Β§11–§13, gap matrix #14/#16/#18. + */ +@Injectable() +export class GlOperationsService { + constructor( + private readonly dataSource: DataSource, + private readonly filesService: FilesService, + private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, + ) {} + + private get bookings() { + return this.dataSource.getRepository(Booking); + } + + private get incidents() { + return this.dataSource.getRepository(ClearanceIncident); + } + + private async getBooking(bookingId: string): Promise { + const booking = await this.bookings.findOne({ where: { id: bookingId } }); + if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`); + return booking; + } + + /** + * Route a shipment to an origin station and (optionally) bind a GL staff user + * to it (GL US-02). Setting both moves the shipment to that station's queue. + */ + async assignStation( + bookingId: string, + input: { stationYardId: string; staffId?: string }, + ): Promise { + const booking = await this.getBooking(bookingId); + booking.glStationYardId = input.stationYardId; + if (input.staffId) { + booking.glAssignedStaffId = input.staffId; + booking.glAssignedAt = new Date(); + } + return this.bookings.save(booking); + } + + /** Log a cargo exception (seal broken, container damaged, etc.) with photos. */ + async reportIncident( + bookingId: string, + input: { + incidentType: IncidentType; + description: string; + files: Express.Multer.File[]; + }, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + if (!input.description?.trim()) { + throw new BadRequestException('A description is required for an incident report.'); + } + const photoFileIds: string[] = []; + for (const file of input.files ?? []) { + const record = await this.filesService.upload({ + resourceId: bookingId, + resource: 'bookings', + code: 'incident_photo', + file, + }); + photoFileIds.push(record.id); + } + const incident = this.incidents.create({ + bookingId, + incidentType: input.incidentType, + description: input.description.trim(), + photoFileIds, + reportedByUserId: userId ?? null, + reportedAt: new Date(), + }); + return this.incidents.save(incident); + } + + async listIncidents(bookingId: string): Promise { + return this.incidents.find({ + where: { bookingId }, + order: { reportedAt: 'DESC' }, + }); + } + + /** + * Customer uploads the duty/tax payment slip after GL advised the amount. The + * slip attaches to the booking and doc-triggers DUTY_TAX_PAID (Β§11.3 #7). + */ + async uploadDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt', + file, + }); + await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID'); + return { milestoneCompleted: true }; + } + + /** + * GL uploads a post-booking operational document (DO, RO, T1, import release, + * interchange…). The file attaches to the booking; if the code maps to a + * doc-triggered milestone, that milestone auto-completes. + */ + async uploadDocuments( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number; completedMilestones: string[] }> { + await this.getBooking(bookingId); + if (!files?.length) throw new BadRequestException('No documents uploaded'); + + const completedMilestones: string[] = []; + for (const file of files) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: file.fieldname, + file, + }); + const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname]; + if (milestoneCode) { + await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode); + completedMilestones.push(milestoneCode); + } + } + return { uploaded: files.length, completedMilestones }; + } + + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { + const booking = await this.getBooking(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonAllocated = + wagonMilestone?.status === 'COMPLETED' || + booking.schedulingStatus === 'SCHEDULED' || + booking.schedulingStatus === 'DISPATCHED' || + Boolean(booking.trainScheduleId); + + let schedule: TrainSchedule | null = null; + if (booking.trainScheduleId) { + schedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: booking.trainScheduleId } }); + } + + return { + wagonAllocated, + departedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + arrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + }; + } + + /** + * T1 transit-document lifecycle state for an import shipment booking. Wagon + * allocation opens the upload window; train departure locks it; train arrival + * lets GL Ethiopia close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + + const closedMilestone = milestones.find( + (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', + ); + + return { + bookingId, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, + closed: Boolean(closedMilestone), + closedAt: closedMilestone?.triggeredAt + ? new Date(closedMilestone.triggeredAt).toISOString() + : null, + }; + } + + /** + * GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation. + * Replaces the previous batch; locked once the train departs or T1 is closed. + */ + async uploadT1Documents( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: number }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'IMPORT') { + throw new BadRequestException('T1 transport documents apply to import shipments only.'); + } + + const state = await this.t1State(bookingId); + if (!state.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before T1 transport documents can be uploaded.', + ); + } + if (state.closed) { + throw new BadRequestException('T1 has been closed by GL Ethiopia β€” documents are final.'); + } + if (state.trainDepartedAt) { + throw new BadRequestException( + 'The train has departed β€” T1 transport documents can no longer be changed.', + ); + } + + await persistT1TransportUploads(this.filesService, bookingId, files); + return { uploaded: files.length }; + } + + /** + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes after the gate pass (transport document required). + */ + async closeT1( + bookingId: string, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + + const state = await this.t1State(bookingId); + if (state.closed) return state; + + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet β€” T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file β€” GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + 'The transport document must be uploaded before T1 can be closed.', + ); + } + if (!done('GATEPASS_GRANTED')) { + throw new BadRequestException('Grant the gate pass before closing T1.'); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); + } + + await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); + return this.t1State(bookingId); + } + + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + 'ARRIVED_AT_DJIBOUTI', + ]; + + /** + * GL Djibouti grants the gate pass for a customs booking, capturing the time. + * Export: requires the train to have arrived at Djibouti; back-fills the + * arrival-chain milestones. Import: requires wagon allocation (pre-loading). + */ + async grantGatepass( + bookingId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ bookingId: string; gatepassAt: string }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Gate pass applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + + const existing = byCode.get('GATEPASS_GRANTED'); + if (existing?.status === 'COMPLETED') { + return { + bookingId, + gatepassAt: + existing.metadata?.gatepassAt ?? + (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), + }; + } + + const train = await this.trainState(bookingId); + if (tradeDirection === 'EXPORT') { + if (!train.arrivedAt) { + throw new BadRequestException( + 'The train has not arrived at Djibouti yet β€” gate pass can be granted after arrival.', + ); + } + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code, userId); + } + } + } else if (!train.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before the gate pass can be granted.', + ); + } + + const at = gatepassAt?.trim() || new Date().toISOString(); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'GATEPASS_GRANTED', + { gatepassAt: at }, + userId, + ); + return { bookingId, gatepassAt: at }; + } + + /** Train schedules carrying β‰₯1 customs booking β€” the GL Djibouti gate-pass table. */ + async djSchedules(): Promise { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + scheduleBookings: { booking: true }, + originStation: true, + destinationStation: true, + }, + order: { scheduledDepartureDate: 'DESC' }, + }); + + const withCustoms = schedules + .filter((s) => s.status !== 'CANCELLED') + .map((s) => ({ + schedule: s, + customs: (s.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), + })) + .filter((s) => s.customs.length > 0); + + const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); + const gatepassRows = bookingIds.length + ? await this.dataSource.getRepository(ClearanceMilestone).find({ + where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, + }) + : []; + const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); + + return withCustoms.map(({ schedule, customs }) => { + const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; + return { + id: schedule.id, + trainNumber: schedule.trainNumber ?? null, + routeName: null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null, + actualDepartureAt: schedule.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + freightType: + freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, + customsBookings: customs.map((b) => { + const m = gatepassByBooking.get(b.id); + const granted = m?.status === 'COMPLETED'; + return { + bookingId: b.id, + reference: b.reference ?? b.id, + tradeDirection: b.tradeDirection ?? 'IMPORT', + contractId: b.contractId ?? null, + gatepassGranted: granted, + gatepassAt: granted + ? (m?.metadata?.gatepassAt ?? + (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) + : null, + }; + }), + }; + }); + } + + /** + * One-click gate pass for every customs booking on a train schedule. Per-booking + * guard failures are collected, not fatal. Import schedules also get the + * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. + */ + async grantScheduleGatepass( + scheduleId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { scheduleBookings: { booking: true } }, + }); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + + const customs = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); + if (customs.length === 0) { + throw new BadRequestException('No customs bookings ride this schedule.'); + } + + let granted = 0; + const skipped: Array<{ bookingId: string; error: string }> = []; + for (const booking of customs) { + try { + await this.grantGatepass(booking.id, gatepassAt, userId); + granted += 1; + } catch (e) { + skipped.push({ + bookingId: booking.id, + error: e instanceof Error ? e.message : 'Failed', + }); + } + } + + if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { + const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); + let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); + if (!operation) { + operation = opRepo.create({ trainScheduleId: scheduleId }); + } + if (!operation.gatepassGrantedAt) { + operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); + await opRepo.save(operation); + } + } + + return { granted, skipped }; + } + + /** + * GL Djibouti raises the post-offload final invoice (export): manual amount + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + throw new BadRequestException( + 'Cargo must be offloaded before the final invoice can be raised.', + ); + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip β€” settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + + /** + * GL ET uploads export transport document after wagon allocation (export ONE_TIME). + */ + async uploadTransportDocument( + bookingId: string, + files: Express.Multer.File[], + ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (booking.tradeDirection !== 'EXPORT') { + throw new BadRequestException('Transport document upload applies to export shipments only.'); + } + + const milestones = await this.milestoneService.listForBooking(bookingId); + const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED'); + const wagonDone = + wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED'; + if (!wagonDone) { + throw new BadRequestException( + 'Wagon must be allocated before the transport document can be uploaded.', + ); + } + + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } + + await persistExportTransportUploads(this.filesService, bookingId, files); + + if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { + await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); + } + + await this.milestoneService.completeByDocTrigger( + { bookingId }, + 'EXPORT_TRANSPORT_ISSUED', + ); + + return { uploaded: true, milestoneCompleted: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts new file mode 100644 index 000000000..40646ac47 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -0,0 +1,129 @@ +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue } from './phased-clearance.util'; + +describe('buildWorkflowFiles', () => { + const resourceFiles = [ + { code: 'im4', id: 'f-im4', name: 'im4.pdf', url: '/files/im4' }, + { code: 'im5', id: 'f-im5', name: 'im5.pdf', url: '/files/im5' }, + { + code: 'transit_permitted', + id: 'f-transit', + name: 'transit.png', + url: '/files/transit', + }, + { + code: 'duty_tax_notice', + id: 'f-duty', + name: 'notice.pdf', + url: '/files/duty', + }, + { code: 'commercial_invoice', id: 'f-inv', name: 'inv.pdf', url: '/files/inv' }, + ]; + + it('includes declaration and transit files even when they also appear in GL output document settings', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'transit_permitted', 'duty_tax_notice']), + ); + }); + + it('includes multi-file declaration uploads alongside catalog codes', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'declaration_0', + id: 'f-dec-0', + name: 'decl-a.pdf', + url: '/files/decl-a', + }, + { + code: 'declaration_1', + id: 'f-dec-1', + name: 'decl-b.pdf', + url: '/files/decl-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['im4', 'im5', 'declaration_0', 'declaration_1']), + ); + expect(result.find((f) => f.code === 'declaration_0')?.label).toBe( + 'Declaration document 1', + ); + }); + + it('includes multi-file import transit permit uploads', () => { + const result = buildWorkflowFiles( + [ + ...resourceFiles, + { + code: 'transit_permit_0', + id: 'f-tp-0', + name: 'permit-a.pdf', + url: '/files/tp-a', + }, + { + code: 'transit_permit_1', + id: 'f-tp-1', + name: 'permit-b.pdf', + url: '/files/tp-b', + }, + ], + 'IMPORT', + ); + + expect(result.map((f) => f.code)).toEqual( + expect.arrayContaining(['transit_permitted', 'transit_permit_0', 'transit_permit_1']), + ); + expect(result.find((f) => f.code === 'transit_permit_0')?.label).toBe('Transit permit 1'); + }); + + it('does not include non-catalog customer document codes', () => { + const result = buildWorkflowFiles(resourceFiles, 'IMPORT'); + + expect(result.some((f) => f.code === 'commercial_invoice')).toBe(false); + }); +}); + +describe('belongsOnDjClearanceQueue', () => { + it('keeps import contracts after pre-clearance is finalized (even post-booking)', () => { + expect( + belongsOnDjClearanceQueue( + 'IMPORT', + { preClearanceFinalizedAt: new Date('2026-01-01') }, + [], + ), + ).toBe(true); + }); + + it('keeps contracts with completed Djibouti milestones', () => { + expect( + belongsOnDjClearanceQueue('IMPORT', null, [ + { ownerRegion: 'DJ', status: 'COMPLETED' }, + ]), + ).toBe(true); + }); + + it('keeps import contracts from the start β€” DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); + }); +}); + +describe('belongsOnEtClearanceQueue', () => { + it('keeps contracts once phased clearance milestones exist', () => { + expect( + belongsOnEtClearanceQueue([{ ownerRegion: 'ET', status: 'COMPLETED' }]), + ).toBe(true); + }); + + it('excludes contracts with no clearance milestones', () => { + expect(belongsOnEtClearanceQueue([])).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts new file mode 100644 index 000000000..2e8682951 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -0,0 +1,381 @@ +import { BadRequestException } from '@nestjs/common'; +import { + catalogEntriesForTradeDirection, + declarationFileLabel, + isDeclarationFileCode, + isImportTransitPermitFileCode, + isExportTransportFileCode, + isT1TransportFileCode, + exportTransportFileLabel, + t1TransportFileLabel, + transitPermitFileLabel, + type ClearanceWorkflowFile, +} from '@edr/types'; + +/** Require at least one declaration file in the upload batch. */ +export function assertDeclarationFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No declaration documents uploaded'); + } +} + +/** Assign stable `declaration_*` codes so multi-file uploads always pass validation. */ +export function normalizeDeclarationFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `declaration_${index}`, + })); +} + +type DeclarationFileStore = { + findByResource( + resourceId: string, + resource: string, + ): Promise>; + deleteByCode(resourceId: string, resource: string, code: string): Promise; + upload(input: { + resourceId: string; + resource: string; + code: string; + file: Express.Multer.File; + }): Promise; +}; + +/** Replace all declaration files on a resource with a new multi-file upload batch. */ +export async function persistDeclarationUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeclarationFieldNames(files); + assertDeclarationFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeclarationFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `declaration_${index}`, + file, + }), + ), + ); +} + +/** Require at least one transit permit file in the upload batch. */ +export function assertTransitPermitFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +/** Assign stable `transit_permit_*` codes for multi-file import transit uploads. */ +export function normalizeTransitPermitFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `transit_permit_${index}`, + })); +} + +/** Replace all import transit permit files on a resource with a new multi-file batch. */ +export async function persistTransitPermitUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeTransitPermitFieldNames(files); + assertTransitPermitFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isImportTransitPermitFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `transit_permit_${index}`, + file, + }), + ), + ); +} + +/** Require at least one export transport document in the upload batch. */ +export function assertExportTransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +export function normalizeExportTransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `export_transport_document_${index}`, + })); +} + +/** Replace all export transport documents on a booking with a new multi-file batch. */ +export async function persistExportTransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeExportTransportFieldNames(files); + assertExportTransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isExportTransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `export_transport_document_${index}`, + file, + }), + ), + ); +} + +/** Require at least one T1 transport document in the upload batch. */ +export function assertT1TransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No T1 transport documents uploaded'); + } +} + +export function normalizeT1TransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `t1_transport_document_${index}`, + })); +} + +/** Replace all T1 transport documents on a booking with a new multi-file batch. */ +export async function persistT1TransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeT1TransportFieldNames(files); + assertT1TransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isT1TransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `t1_transport_document_${index}`, + file, + }), + ), + ); +} + +export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { + if (typeof value === 'boolean') return value; + if (value === undefined || value === '') return false; + return value === 'true' || value === '1'; +} + +type DjQueueMilestone = { + ownerRegion?: string | null; + status: string; +}; + +type DjQueueCycle = { + preClearanceFinalizedAt?: Date | null; + roHoldReason?: string | null; +} | null | undefined; + +/** Whether a customs clearance item belongs on the persistent GL Djibouti list. */ +export function belongsOnDjClearanceQueue( + tradeDirection: string | null | undefined, + cycle: DjQueueCycle, + milestones: DjQueueMilestone[], + extras?: { + roHoldReason?: string | null; + preClearanceFinalizedAt?: Date | null; + }, +): boolean { + const roHold = cycle?.roHoldReason ?? extras?.roHoldReason; + if (roHold) return true; + + const hasDjActivity = milestones.some( + (m) => m.ownerRegion === 'DJ' && (m.status === 'COMPLETED' || m.status === 'PENDING'), + ); + if (hasDjActivity) return true; + + // Import DO upload is un-gated β€” Djibouti GL must see import customs items from + // the start, not only after Ethiopia finalizes pre-clearance. + if (tradeDirection === 'IMPORT') return true; + + return false; +} + +/** Contract statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + 'ACTIVE_SHIPMENT_IN_PROGRESS', + 'FULLY_EXECUTED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */ +export function belongsOnEtClearanceQueue(milestones: DjQueueMilestone[]): boolean { + return milestones.some((m) => m.status === 'PENDING' || m.status === 'COMPLETED'); +} + +/** Contract statuses that may appear on the GL Djibouti clearance list (includes post-booking). */ +export const DJ_CONTRACT_QUEUE_STATUSES = PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES; + +/** Booking statuses for persistent phased customs clearance lists (ET + DJ). */ +export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [ + 'AWAITING_DOCUMENTS', + 'DOCUMENTS_UNDER_REVIEW', + 'CLEARANCE_READY', + 'FULLY_EXECUTED', + 'OPERATION_REQUEST_PENDING', + 'OPERATION_CHANGES_REQUESTED', + 'ROAD_DISPATCH_PENDING', + 'IN_TRANSIT', + 'PAID', + 'COMPLETED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', +] as const; + +/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */ +export const DJ_BOOKING_QUEUE_STATUSES = PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES; + +/** Build labeled phased-customs file rows from resource files. */ +export function buildWorkflowFiles( + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + tradeDirection: string, +): ClearanceWorkflowFile[] { + const fileByCode = new Map( + files.filter((f) => f.code).map((f) => [f.code as string, f]), + ); + const out: ClearanceWorkflowFile[] = []; + const included = new Set(); + + for (const entry of catalogEntriesForTradeDirection(tradeDirection)) { + const file = fileByCode.get(entry.code) ?? null; + if (!file) continue; + included.add(entry.code); + out.push({ + code: entry.code, + label: entry.label, + uploadedBy: entry.uploadedBy, + category: entry.category, + file: { id: file.id, name: file.name, url: file.url }, + }); + } + + const extraDeclarations = files + .filter((f) => f.code && isDeclarationFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeclarations.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: declarationFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'declaration', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + if (tradeDirection === 'IMPORT') { + const extraTransit = files + .filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraTransit.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: transitPermitFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + + const extraT1 = files + .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraT1.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: t1TransportFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + if (tradeDirection === 'EXPORT') { + const extraExportTransport = files + .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraExportTransport.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: exportTransportFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + + return out; +} diff --git a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts index d5176d14b..2464a26ac 100644 --- a/apps/edr-freight-api/src/modules/drivers/drivers.service.ts +++ b/apps/edr-freight-api/src/modules/drivers/drivers.service.ts @@ -48,12 +48,10 @@ export class DriversService { const qb = this.driverRepo.createQueryBuilder('d'); if (query.search) { - const searchTerm = `%${query.search}%`; - qb.where('d.firstName ILIKE :search', { search: searchTerm }) - .orWhere('d.lastName ILIKE :search', { search: searchTerm }) - .orWhere('d.email ILIKE :search', { search: searchTerm }) - .orWhere('d.licenseNumber ILIKE :search', { search: searchTerm }) - .orWhere('d.phoneNumber ILIKE :search', { search: searchTerm }); + qb.where( + '(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)', + { search: `%${query.search}%` }, + ); } if (query.status) { diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index acf274ff0..e0d876176 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -1,5 +1,13 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { Public } from "@edr/api-common"; import { Response } from "express"; import { FilesService } from "./files.service"; @@ -10,19 +18,39 @@ export class FilesController { constructor(private readonly filesService: FilesService) {} @Get(":fileId") + // Public so the browser can load the bytes directly via /