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 290ecec69..71e2fd60a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -18,14 +18,21 @@ "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", + "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: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" + "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" }, "dependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..db05ae26f 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'; @@ -67,6 +72,7 @@ import { DriversModule } from './modules/drivers/drivers.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 +81,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 +101,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, - BookingOrdersModule, + ContractsModule, SignaturesModule, FilesModule, ConsignmentsModule, @@ -130,6 +136,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -139,12 +146,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 +172,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 +193,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 +203,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/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..caf161614 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -0,0 +1,262 @@ +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, + ), + scheduledDate: this.formatDate(contract.estimatedShipmentDate), + 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/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/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/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..65a3e764b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -0,0 +1,109 @@ +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( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + + await queryRunner.query(` + CREATE TABLE 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 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-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/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/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5a801cf73..e954b7e1b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -8,7 +8,7 @@ import { BillingService } from "./billing.service"; @Controller("billing") @FreightAdmin() export class BillingController { - constructor(private readonly billingService: BillingService) {} + constructor(private readonly billingService: BillingService) { } @Get("invoices") @ApiOperation({ summary: "List all invoices" }) @@ -16,9 +16,9 @@ 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); } } 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..551fae6bf 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,24 @@ -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 { BillingService } from "./billing.service"; 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, + ], + controllers: [BillingController, PortalBillingController], + 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..0e6d97de0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -0,0 +1,250 @@ +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 + ); + }); + + 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(/^FRT-\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, links the payment, and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: "booking", + sourceId: "booking-1", + }; + 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 + ); + + 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" }, + ); + 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 + ); + + await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); + + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).not.toHaveBeenCalled(); + }); +}); + +describe("BillingService.settlePayable", () => { + it("settles the source's open invoice PAID and emits ${source}.invoice.paid", async () => { + const open = { + id: "inv-1", + status: Freight.InvoiceStatus.Pending, + source: Freight.InvoiceSource.Booking, + sourceId: "booking-1", + }; + 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 + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled?.status).toBe(Freight.InvoiceStatus.Paid); + expect(mg.update).toHaveBeenCalledWith( + expect.anything(), + { id: "inv-1" }, + { status: Freight.InvoiceStatus.Paid, paymentId: "pay-1" }, + ); + expect(events.emit).toHaveBeenCalledWith( + "booking.invoice.paid", + expect.anything(), + ); + }); + + it("is a no-op (returns null) when the source has no open invoice", async () => { + const mg = { + findOne: jest.fn().mockResolvedValue(null), + 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 + ); + + const settled = await service.settlePayable( + Freight.InvoiceSource.Booking, + "booking-1", + "pay-1", + mg as never, + ); + + expect(settled).toBeNull(); + expect(mg.update).not.toHaveBeenCalled(); + expect(events.emit).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..01b057a76 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,542 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; +import { forwardRef, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { EventEmitter2 } from "@nestjs/event-emitter"; +import { Freight, PaymentReferenceType } from "@edr/types"; +import { DataSource, EntityManager, In } from "typeorm"; 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 { PaymentService } from "../payment/payment.service"; +import { InitiateResponseDto } from "../payment/payments.dto"; +import { CompaniesService } from "../companies/companies.service"; + +/** Options forwarded to the payment gateway when settling an invoice. */ +export interface PayInvoiceOptions { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; +} + +/** 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.Draft, + Freight.InvoiceStatus.Pending, + 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 total; defaults to the sum of line amounts. */ + 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, + ) { } + + // ── 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[] }; + } + + // ── 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. */ + findByCompany(companyId: string): Promise { + return this.invoices.findAll({ + where: { companyId }, + relations: { company: true, companyProfile: true }, + order: { createdAt: "DESC" }, + }); + } + + /** Invoices for the signed-in customer; empty when they have no company. */ + async findForUser(userId: string): Promise { + const companyId = await this.resolveCompanyId(userId); + return companyId ? this.findByCompany(companyId) : []; + } + + /** 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 whichever open invoice the source currently has + * (see {@link payInvoice}). + */ + async payInvoiceForUser( + id: string, + userId: string, + opts: PayInvoiceOptions = {}, + ): Promise { + const invoice = await this.findByIdForUser(id, userId); + return this.payInvoice( + invoice.source as Freight.InvoiceSource, + invoice.sourceId, + opts, + ); + } + + // ── Generation ─────────────────────────────────────────────────────────────── + + /** `FRT-YYYYMMDD-00001` β€” sequential per day, within the active transaction. */ + private async nextInvoiceNumber(mg: EntityManager): Promise { + const now = new Date(); + const ymd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(now.getDate()).padStart(2, "0")}`; + const prefix = `FRT-${ymd}-`; + const [row] = await mg.query( + `SELECT COALESCE(MAX(CAST(split_part(invoice_number, '-', 3) AS int)), 0) AS seq + FROM freight.invoices WHERE invoice_number LIKE $1`, + [`${prefix}%`], + ); + const next = Number(row?.seq ?? 0) + 1; + return `${prefix}${String(next).padStart(5, "0")}`; + } + + /** + * 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 { + 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 totalAmount = + input.totalAmount ?? lines.reduce((sum, l) => sum + Number(l.amount), 0); + + 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, + totalAmount, + 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 ──────────────────────────────────────────────────────── + + /** + * Mark an invoice paid and 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. + */ + async markInvoiceAsPaid( + invoiceId: string, + paymentId: string | null = null, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Paid, + "paid", + { paymentId: paymentId ?? undefined }, + manager, + ); + } + + /** + * Mark an invoice refunded and emit `${source}.invoice.refunded`. + * No-op when already refunded. + */ + async markInvoiceAsRefunded( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Refunded, + "refunded", + {}, + manager, + ); + } + + /** + * Mark an invoice cancelled and emit `${source}.invoice.cancelled`. + * No-op when already cancelled. + */ + async cancelInvoice( + invoiceId: string, + manager?: EntityManager, + ): Promise { + return this.transition( + invoiceId, + Freight.InvoiceStatus.Cancelled, + "cancelled", + {}, + manager, + ); + } + + /** + * Load the invoice, apply the new status (+ extra columns), then emit + * `${source}.invoice.`. No-op (returns the invoice) when it is already + * in the target status. Throws when the invoice does not exist. + * + * Note: the event fires in-process synchronously. When a `manager` from an + * outer transaction is passed, listeners run before that transaction commits. + */ + private async transition( + invoiceId: string, + status: Freight.InvoiceStatus, + event: string, + extra: { paymentId?: string }, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { where: { id: invoiceId } }); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.status === status) return invoice; + + await mg.update(Invoice, { id: invoice.id }, { status, ...extra }); + + const updated = { ...invoice, ...extra, status } as Invoice; + this.emitInvoiceEvent(event, updated); + return 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.emit(`${invoice.source}.invoice.${event}`, payload); + } + + // ── Payment reconciliation (by source) ─────────────────────────────────────── + + /** + * The invoice a gateway payment should settle for a source record, or null if + * none. This is the billing document of record for "what is owed" β€” callers + * (e.g. {@link payInvoice}) charge `invoice.totalAmount` against it rather than + * recomputing from the source's own total, so discounts/penalties/adjustments + * carried on the invoice are honored. + * + * 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. + */ + findPayable( + source: Freight.InvoiceSource, + sourceId: string, + type?: string, + ): Promise { + return this.dataSource.getRepository(Invoice).findOne({ + where: { + source, + sourceId, + status: In(OPEN_STATUSES), + ...(type ? { type } : {}), + }, order: { issuedAt: "DESC" }, }); } + + /** + * Settle a source's currently-open invoice as paid and link the gateway + * payment, then emit `${source}.invoice.paid`. Resolves the open invoice then + * delegates to {@link markInvoiceAsPaid}. Full-payment only β€” no partial + * settlement. No-op (returns null) when the source has no open invoice. + * + * Type-blind by design: settles whichever invoice is due; any per-type reaction + * belongs in the `${source}.invoice.paid` handler, which reads `invoice.type`. + * Pass the caller's transaction `manager` to enlist in its DB transaction. + * + * NOTE: the booking flow settles via {@link payInvoice} + the `payment.succeeded` + * event ({@link settleByPaymentId}); this source-keyed settle is a generic helper + * for callers that settle by source rather than by gateway intent id. + */ + async settlePayable( + source: Freight.InvoiceSource, + sourceId: string, + paymentId: string | null, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: In(OPEN_STATUSES) }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsPaid(invoice.id, paymentId, mg); + } + + /** + * Refund a source's paid invoice, then emit `${source}.invoice.refunded`. + * Resolves the paid invoice then delegates to {@link markInvoiceAsRefunded}. + * No-op (returns null) when the source has no paid invoice. + * + * Pass the caller's transaction `manager` (e.g. from `payment.service.refund`) + * to enlist in its DB transaction. + */ + async refundPayable( + source: Freight.InvoiceSource, + sourceId: string, + manager?: EntityManager, + ): Promise { + const mg = manager ?? this.dataSource.manager; + const invoice = await mg.findOne(Invoice, { + where: { source, sourceId, status: Freight.InvoiceStatus.Paid }, + order: { issuedAt: "DESC" }, + }); + if (!invoice) return null; + + return this.markInvoiceAsRefunded(invoice.id, mg); + } + + // ── Payment initiation & settlement (the gateway boundary) ─────────────────── + + /** + * Charge a source's open 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 open + * invoice, opens an intent for `invoice.totalAmount`, 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 source has no open invoice. + */ + async payInvoice( + source: Freight.InvoiceSource, + sourceId: string, + opts: { + method?: string; + platform?: "web" | "mobile"; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + } = {}, + ): Promise { + const invoice = await this.findPayable(source, sourceId); + if (!invoice) { + throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); + } + + const result = await this.payment.initiate({ + referenceId: sourceId, + source: invoice.source, + // Gateway reference type derives from the invoice source by convention + // (source.toUpperCase() ∈ PaymentReferenceType) β€” no domain word here, and + // the domain never supplies it. New sources add their uppercased value to + // the PaymentReferenceType enum. + referenceType: invoice.source.toUpperCase() as PaymentReferenceType, + orderRef: invoice.invoiceNumber, + amountMinor: Math.round(Number(invoice.totalAmount)), + 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); + } } 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..61bc9c16b 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,35 @@ 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" }) +@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; + + @Column({ name: "total_amount", type: "numeric", precision: 14, scale: 2 }) + totalAmount!: number; @Column({ name: "currency", type: "varchar", length: 8, default: "ETB" }) currency!: string; @@ -19,13 +37,38 @@ 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; + + /** 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.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/portal-billing.controller.ts b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts new file mode 100644 index 000000000..5a007c320 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/portal-billing.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; + +import { + type AuthUserPayload, + resolveAuthUserId, +} from "../../common/resolve-auth-user-id"; +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) { + return this.billingService.findForUser(resolveAuthUserId(user)); + } + + @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)); + } + + @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..47338f196 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -0,0 +1,201 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Freight } from '@edr/types'; +import { DataSource } 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; +} + +/** 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. + * Returns `null` (and logs) 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): Promise { + const existing = await this.billing.findPayable( + Freight.InvoiceSource.Booking, + booking.id, + Freight.InvoiceType.Prepaid, + ); + if (existing) return existing; + + if (!booking.companyId) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no company to bill.`, + ); + return null; + } + + const input = this.buildInput(booking); + if (!input) { + this.logger.warn( + `Skipping invoice for booking ${booking.reference} (${booking.id}): no priced amount.`, + ); + return null; + } + + 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 { + switch (payload.type) { + case Freight.InvoiceType.Prepaid: + await this.advanceBookingOnPayment(payload.sourceId); + break; + default: + this.logger.warn( + `Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`, + ); + } + } + + /** + * 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' }, + ); + await this.firstMile.acceptBooking(bookingId); + }); + + 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): GenerateInvoiceInput | null { + 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) return null; + 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, + type: Freight.InvoiceType.Prepaid, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency, + lines, + totalAmount, + }; + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts new file mode 100644 index 000000000..ae01ebc36 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.controller.ts @@ -0,0 +1,180 @@ +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 { Freight } from "@edr/types"; + +import { BillingService } from "../billing/billing.service"; +import { + InitiatePaymentDto, + InitiateResponseDto, + PaymentMethodTypeEnum, + PaymentPlatformDto, +} from "../payment/payments.dto"; + +/** + * Booking-payment entrypoints. This is the ONE place that knows a payment is for a + * booking β€” it maps the request to {@link Freight.InvoiceSource.Booking} and hands + * off to billing, which resolves the invoice/amount and drives the gateway. Billing + * and payment stay source-agnostic; the booking knowledge lives here, in the domain. + * Routes are unchanged (`/payments/*`) so the portal is unaffected. + */ +@ApiTags("Payment") +@Controller("payments") +export class BookingPaymentController { + constructor(private readonly billing: BillingService) { } + + @Post("initiate") + @ApiOperation({ + summary: "Initiate payment for a freight booking", + description: "Charges the booking's open invoice through the payment gateway.", + }) + @ApiOkResponse({ type: InitiateResponseDto }) + initiate(@Body() dto: InitiatePaymentDto): Promise { + return this.billing.payInvoice(Freight.InvoiceSource.Booking, dto.bookingId, { + 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 booking's invoice and returns an HTML page that auto-redirects to the provider checkout URL. Open directly in a browser tab.", + }) + @ApiQuery({ name: "bookingId", required: true }) + @ApiQuery({ name: "method", enum: PaymentMethodTypeEnum, required: true }) + @ApiQuery({ name: "platform", enum: ["web", "mobile"], required: false }) + @ApiProduces("text/html") + async checkout( + @Query("bookingId") bookingId: string, + @Query("method") method: PaymentMethodTypeEnum, + @Query("platform") platform: PaymentPlatformDto = "web", + @Res() res: Response, + ) { + if (!bookingId) { + return res + .status(HttpStatus.BAD_REQUEST) + .type("html") + .send(this.buildErrorHtml("Missing required query parameter: bookingId")); + } + 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( + Freight.InvoiceSource.Booking, + bookingId, + { 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/bookings/booking-payment.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts index 21473eeb8..1fbe34e1f 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-payment.service.ts @@ -1,49 +1,37 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { Freight } from '@edr/types'; 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 { BillingService } from '../billing/billing.service'; 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, + private readonly billing: BillingService, ) { } + /** + * Start payment for a booking. The booking never touches the payment gateway + * directly β€” it charges its invoice through billing, which resolves the amount + * and drives the provider. Returns the provider redirect URL (empty when none). + */ 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, + const resp = await this.billing.payInvoice(Freight.InvoiceSource.Booking, bookingId, { method: PaymentMethodTypeEnum.TELEBIRR, - platform: "web", + platform: 'web', }); const action = resp.clientAction as { type?: string; url?: string } | undefined; return { - redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "", + redirectUrl: action?.type === 'REDIRECT' ? (action.url ?? '') : '', }; } 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-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index 676d1b5d4..a9806f1f7 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,6 +30,7 @@ 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 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..ff08784a6 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,6 +41,7 @@ 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 @@ -122,6 +123,7 @@ describe('BookingTransitionService β€” finalizeClearance customs output gate', ( {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, @@ -189,6 +191,7 @@ describe('BookingTransitionService β€” submitClearanceDocuments required-fields {} as never, {} as never, {} as never, + {} as never, // invoiceService filesService as never, fileUploadSettingsService as never, {} as never, 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..cc2288963 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,6 +33,7 @@ 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, @@ -78,18 +78,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..2ebceeabc 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,17 +3,19 @@ import { forwardRef, Inject, Injectable, + 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'; import { BookingContractService } from './booking-contract.service'; +import { BookingInvoiceService } from './booking-invoice.service'; import { BookingPricingService } from './booking-pricing.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; @@ -26,11 +28,14 @@ import { BookingsService } from './bookings.service'; @Injectable() export class BookingTransitionService { + private readonly logger = new Logger(BookingTransitionService.name); + constructor( private readonly bookingsRepository: BookingsRepository, private readonly ruleEngineService: RuleEngineService, private readonly pricingService: BookingPricingService, private readonly contractService: BookingContractService, + private readonly invoiceService: BookingInvoiceService, private readonly filesService: FilesService, private readonly fileUploadSettingsService: FileUploadSettingsService, @Inject(forwardRef(() => BookingBatchService)) @@ -404,7 +409,22 @@ export class BookingTransitionService { marketingApprovedAt: new Date(), lockedAt: new Date(), } as never); - return this.bookingsService.findById(updated!.id); + + const executed = await this.bookingsService.findById(updated!.id); + + // Billable state reached β€” generate the invoice payment will settle. + // Non-blocking: a billing hiccup must not undo the execution. + await this.invoiceService + .ensureInvoiceForBooking(executed) + .catch((err) => + this.logger.error( + `Failed to generate invoice for booking ${executed.reference}: ${ + err instanceof Error ? err.message : String(err) + }`, + ), + ); + + return executed; } async startTransit(bookingId: string): Promise { @@ -477,30 +497,6 @@ export class BookingTransitionService { 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) ─────────────────────────── /** @@ -861,17 +857,17 @@ 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']); @@ -894,48 +890,10 @@ export class BookingTransitionService { 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', - } as never); - return this.bookingsService.findById(bookingId); - } - // ACCEPT β€” enter the batch holding pool. 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. * 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..0f413199b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto'; import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto'; import { AcceptIntakeDto, - AdjustPriceDto, ApproveStepDto, CancelBookingDto, RejectBookingDto, @@ -52,7 +51,6 @@ import { ReviewDocumentDto, RequestOperationDto, OperationReviewDto, - ConfirmOperationPriceDto, StaffRejectDto, } from './dto/request-changes.dto'; import { ContractViewDto } from './dto/contract-view.dto'; @@ -404,24 +402,7 @@ export class BookingsController { id, dto.decision, resolveAuthUserId(user), - { note: dto.note, amount: dto.amount }, - ); - 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, + { note: dto.note }, ); return this.transitionService.enrichBookingResponse(booking); } @@ -521,25 +502,6 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } - @Post(':id/adjust-price') - @BookingStaff(FREIGHT_PERMS.bookings.staffAccept) - @ApiOperation({ - summary: 'Staff adjust booking total price (override; null clears it)', - }) - 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' }) 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..5d7e3b2c9 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -10,7 +10,11 @@ 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 { 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'; @@ -28,12 +32,12 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity'; import { BookingContractSignature } from './entities/booking-contract-signature.entity'; import { BookingReviewNote } from './entities/booking-review-note.entity'; import { Booking } from './entities/booking.entity'; +import { BookingContainerAllocation } from './entities/booking-container-allocation.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'; @Module({ @@ -47,8 +51,10 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingRateSnapshot, BookingReviewNote, BookingContractSignature, + BookingContainerAllocation, ]), - PaymentModule, + BillingModule, + forwardRef(() => FirstMileModule), forwardRef(() => TrainSchedulingModule), FilesModule, MinioModule, @@ -63,7 +69,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu config.get('app.cbeExchange') ?? {}, }), ], - controllers: [BookingsController, PayController], + controllers: [BookingsController, PayController, BookingPaymentController], providers: [ BookingsService, BookingsRepository, @@ -72,6 +78,7 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu BookingPricingService, BookingTransitionService, BookingContractService, + BookingInvoiceService, BookingPaymentService, ContractTemplateResolver, ContractViewModelBuilder, @@ -79,6 +86,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository, BookingPricingService], + exports: [BookingsService, BookingsRepository, BookingPricingService, BookingInvoiceService], }) 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..57e811e34 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; @@ -164,6 +164,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). @@ -693,11 +706,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..a4636cbf7 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,7 +28,6 @@ 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'; @@ -43,6 +43,7 @@ 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'; /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -283,6 +284,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 +305,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 +397,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 +436,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 +476,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, @@ -469,7 +509,6 @@ export class BookingsService { 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) @@ -496,27 +535,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); @@ -808,7 +826,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 +1034,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 +1338,35 @@ 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`); + } + + 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, + }); + } + }); + + 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/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 677ef03fd..9380faba5 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, @@ -104,19 +103,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() 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..19aa3a199 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'; @@ -105,8 +106,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 +119,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 +148,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 +235,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; @@ -422,9 +430,24 @@ 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; + + /** 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/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-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts new file mode 100644 index 000000000..9529cabbe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -0,0 +1,45 @@ +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 }, + relations: { contract: 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..3389933a7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -0,0 +1,100 @@ +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 }, + 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 }, + 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 }, + 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 }, + 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..947fc6979 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -0,0 +1,223 @@ +import { BadRequestException, 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 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' }, + }); + } + + /** 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') { + throw new BadRequestException(`Milestone ${code} is already completed.`); + } + 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') { + throw new BadRequestException(`Milestone ${code} is already completed.`); + } + milestone.status = 'COMPLETED'; + milestone.triggeredAt = new Date(); + milestone.triggeredByUserId = userId ?? null; + if (note) milestone.note = note; + return this.repo.save(milestone); + } + + /** 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/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts new file mode 100644 index 000000000..b077930b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -0,0 +1,582 @@ +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 { 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 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 post-booking milestones on the booking (no + // cycle needed) and leave the contract active. The booking now drives its + // own clearance via the booking-level pipeline. + 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 β€” UNCHANGED: requires the finalized contract cycle. + if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') { + throw new BadRequestException( + 'Contract clearance is not ready for booking 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..71cfa0580 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -0,0 +1,539 @@ +import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; + +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 { contractClearanceCodes } from './contract-clearance.util'; +import { Contract } from './entities/contract.entity'; +import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; +import { FilterContractDto } from './dto/filter-contract.dto'; + +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; +} + +@Injectable() +export class ContractClearanceService { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly contractsService: ContractsService, + private readonly filesService: FilesService, + private readonly fileUploadSettingsService: FileUploadSettingsService, + ) {} + + /** The pre-booking clearance document grid for a contract (Path B). */ + async getClearanceView(contractId: string): Promise { + const 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); + + return { + contractId, + status: contract.status, + clearanceStatus: contract.clearanceStatus, + cycleNumber: cycle?.cycleNumber ?? contract.clearanceCycleNumber, + includesCustoms, + inputCode, + outputCode, + documents, + allApproved, + }; + } + + /** + * 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', + ), + ); + } + + /** + * 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'); + } + 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); + // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has + // dropped back to AWAITING_CLEARANCE_DOCUMENTS β€” querying one document flips + // the contract to "awaiting" (the customer must re-upload), but the reviewer + // may still be working through the rest of the batch. Restricting to + // UNDER_REVIEW only would 409 every review after the first query. + if ( + contract.status !== 'CLEARANCE_UNDER_REVIEW' && + contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' + ) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + 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); + 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'); + } + } + + 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); + if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + 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 and required output docs present β†’ CLEARANCE_READY_FOR_BOOKING + * (GL then creates the 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.', + ); + } + if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + + const approved = await this.isClearanceFullyApproved(contract); + if (!approved) { + throw new BadRequestException( + 'All required documents must be approved before clearance can be finalized', + ); + } + + 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.', + ); + } + if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + + 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 that still needs + * customs clearance β€” awaiting the customer's documents, under GL review, or + * finalized and waiting for the customer to create the booking in the portal. + */ + async queue(filter: FilterContractDto): Promise { + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 100, + statuses: [ + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_UNDER_REVIEW', + 'CLEARANCE_READY_FOR_BOOKING', + ], + 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', + }); + } +} 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..4fcbc9a6c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -0,0 +1,642 @@ +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; + + // US-06 routing: bulk always needs director approval; container needs it only + // when its cargo type flags it. Resolve the chain via the same approval_rules + // source of truth the booking flow uses (no booking row is created here). + let requiresDirectorApproval = contract.freightType === 'BULK'; + if (cargoTypeId) { + const cargoType = await this.cargoTypesService.findById(cargoTypeId); + if (cargoType?.requiresDirectorApproval) { + requiresDirectorApproval = true; + } + } + + 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 }; + } + + /** + * 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..0ef013347 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -0,0 +1,729 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + UnauthorizedException, + UploadedFiles, + 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 } from '@nestjs/platform-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 { 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 { 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'; + +@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, + ) {} + + // ── 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, + }; + } + + @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) + @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' }) + finalizeClearance(@Param('id', ParseUUIDPipe) id: string) { + return this.clearanceService.finalize(id); + } + + // ── 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/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' }) + uploadDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]); + } + + @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..c595dbf5f --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -0,0 +1,110 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; + +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 { 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, + ]), + 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). + BookingsModule, + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), + ], + controllers: [ContractsController], + providers: [ + ContractsService, + ContractsRepository, + ContractPricingService, + ContractTransitionService, + ContractClearanceService, + 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, + 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..354f02e8c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -0,0 +1,555 @@ +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 + > = {}, + ): Promise { + await this.dataSource + .getRepository(ContractClearanceCycle) + .update(cycleId, { status, ...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..70d2632fa --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -0,0 +1,532 @@ +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, + estimatedShipmentDate: dto.estimatedShipmentDate + ? new Date(dto.estimatedShipmentDate) + : null, + 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.estimatedShipmentDate) { + updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); + } + 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..4108986cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -0,0 +1,138 @@ +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; +} + +/** 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..70dba1e90 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -0,0 +1,256 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + 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: 'Non-binding estimate from the wizard (NOT validated against departures)', + example: '2026-07-15T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + estimatedShipmentDate?: string; + + @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/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..502afaf6a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -0,0 +1,84 @@ +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; +} + +/** + * 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..e29985f2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts @@ -0,0 +1,37 @@ +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; +} 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..e8f53f4c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -0,0 +1,161 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { FilesService } from '../files/files.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { + ClearanceIncident, + IncidentType, +} from './entities/clearance-incident.entity'; +import { ClearanceMilestoneService } from './clearance-milestone.service'; + +/** + * 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: 'T1_CLOSED', // import β€” GL ET + import_release: 'IMPORT_RELEASE_GRANTED', // import β€” GL ET + full_in_interchange: 'OFFLOADED', // export β€” GL DJ + final_declaration: 'IMPORT_PROCESS_COMPLETED', // import β€” GL ET +}; + +/** + * 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 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 }; + } +} 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 /