diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 04edf4fba..78c15cba1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,7 +53,7 @@ jobs: 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$" 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/package.json b/apps/edr-freight-api/package.json index 822e6f5e1..9f55c1138 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -19,9 +19,17 @@ "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:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", + "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", + "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed: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" + "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" }, "dependencies": { "@edr/api-common": "workspace:*", @@ -40,14 +48,15 @@ "@nestjs/swagger": "^11.4.2", "@nestjs/typeorm": "^11.0.1", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz", - "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz", - + "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.7.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", "axios": "^1.16.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "cross-env": "^10.1.0", "dotenv": "^17.4.2", + "dotenv-cli": "^11.0.0", "handlebars": "^4.7.9", "libphonenumber-js": "^1.13.6", "minio": "7.1.3", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index fff3dc8f1..3ec9cab20 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -14,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"; @@ -53,9 +53,11 @@ 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 { 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'; @@ -69,6 +71,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: [ @@ -97,7 +100,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, - BookingOrdersModule, + ContractsModule, SignaturesModule, FilesModule, ConsignmentsModule, @@ -132,6 +135,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte FirstMileModule, LastMileModule, InterchangeDocumentsModule, + ImportOperationsModule, ], providers: [ EdrOrgSeeder, @@ -148,6 +152,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte Batch7TestDataSeeder, Batch8TestDataSeeder, WarehouseDemoSeeder, + ExportDjiboutiInterchangeDemoSeeder, + ApprovedFirstLastMileDemoBookingsSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -164,6 +170,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly batch7TestDataSeeder: Batch7TestDataSeeder, private readonly batch8TestDataSeeder: Batch8TestDataSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder, @@ -183,6 +190,7 @@ export class AppModule implements OnApplicationBootstrap { await this.batch7TestDataSeeder.run(); await this.batch8TestDataSeeder.run(); await this.warehouseDemoSeeder.run(); + await this.exportDjiboutiInterchangeDemoSeeder.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, 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/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 9e0acc6fb..db2f7693e 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -80,8 +80,15 @@ export class ContractPdfService { this.logger.error( `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } throw new InternalServerErrorException( - 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); } } @@ -113,4 +120,85 @@ export class ContractPdfService { buffer.subarray(0, 5).toString('ascii') === '%PDF-' ); } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 92).slice(0, 72); + const body = lines + .map((line, index) => { + const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; + return `${prefix} (${this.escapePdfText(line)}) Tj`; + }) + .join('\n'); + const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } } diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index cb3440739..64319612a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,6 +25,26 @@

Equipment return: {{pricing.equipmentReturn}}

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

Unit Rate Schedule

+

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

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

Charges

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

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts index b921a7194..0f9021ff2 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface { weight NUMERIC(14,3) NOT NULL DEFAULT 0, volume NUMERIC(12,3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + inspection_status VARCHAR(20) NULL, arrived_at TIMESTAMPTZ NULL, inspected_at TIMESTAMPTZ NULL, ready_for_loading_at TIMESTAMPTZ NULL, diff --git a/apps/edr-freight-api/src/migrations/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/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts new file mode 100644 index 000000000..523772b39 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Catch-up for environments where AddWarehouseInspection ran before the + * warehouse module table existed. Production needs this column for unload and + * inspection flows because the WarehouseInventory entity maps inspectionStatus. + */ +export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: 'inspection_status', + type: 'varchar', + length: '20', + isNullable: true, + }), + ); + } + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status + ON freight.warehouse_inventory(inspection_status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status + `); + + if (await queryRunner.hasColumn(this.table, 'inspection_status')) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts new file mode 100644 index 000000000..98fa8b228 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000002-AddDistanceColumnsToVehicles.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDistanceColumnsToVehicles1821000000002 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + ADD COLUMN IF NOT EXISTS estimated_distance_km NUMERIC, + ADD COLUMN IF NOT EXISTS actual_distance_km NUMERIC; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.vehicles + DROP COLUMN IF EXISTS estimated_distance_km, + DROP COLUMN IF EXISTS actual_distance_km; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/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/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/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/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-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-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.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 3feac4e67..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) { @@ -79,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 1b6ddd100..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 @@ -10,7 +10,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre 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'; @@ -497,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) ─────────────────────────── /** @@ -881,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']); @@ -914,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.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 578c181bc..50d15cc64 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, @@ -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'; @@ -283,6 +283,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,7 +304,6 @@ export class BookingsService { // } const isGovernment = dto.isGovernment === true; - const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT'; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { @@ -474,7 +482,6 @@ export class BookingsService { trainId: dto.trainId, trainScheduleId: dto.trainScheduleId ?? null, contractType: dto.contractType, - previousContractId: dto.previousContractId, serviceTypeId: dto.serviceTypeId, firstMilePickupAddress: dto.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? null, @@ -501,7 +508,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) @@ -528,27 +534,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); @@ -840,7 +825,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, @@ -1049,7 +1033,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, 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/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-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 d95f5997c..b9573d680 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 @@ -147,13 +147,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 @@ -223,13 +234,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; @@ -425,6 +429,18 @@ 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[]; 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/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..808fd8a63 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -0,0 +1,561 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + 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 { 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 { + 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 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); + 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 /