From 01d53c218c1bcec3b1089fe83b336b8d115d878b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 26 Jun 2026 23:24:48 +0000 Subject: [PATCH] contrat,booking,global logestic --- apps/edr-freight-api/src/app.module.ts | 4 +- .../contract-document-view-model.builder.ts | 262 +++ .../templates/_partials/article5_pricing.hbs | 21 + .../1822000000000-CreateContracts.ts | 357 ++++ ...000000000-BackfillContractsFromBookings.ts | 175 ++ .../1824000000000-DropLegacyContractTables.ts | 41 + .../bookings/booking-contract.service.ts | 20 - .../bookings/booking-transition.service.ts | 2 +- .../modules/bookings/bookings.repository.ts | 6 - .../src/modules/bookings/bookings.service.ts | 37 +- .../entities/booking-container-unit.entity.ts | 37 + .../entities/booking-container.entity.ts | 12 + .../bookings/entities/booking.entity.ts | 32 +- .../contracts/clearance-milestone.catalog.ts | 100 + .../contracts/clearance-milestone.service.ts | 140 ++ .../contracts/contract-booking.service.ts | 383 ++++ .../contracts/contract-clearance.service.ts | 395 ++++ .../contracts/contract-clearance.util.ts | 69 + .../contracts/contract-pricing.service.ts | 204 ++ .../contracts/contract-transition.service.ts | 528 +++++ .../modules/contracts/contracts.controller.ts | 461 ++++ .../src/modules/contracts/contracts.module.ts | 96 + .../modules/contracts/contracts.repository.ts | 521 +++++ .../modules/contracts/contracts.service.ts | 481 +++++ .../contracts/dto/accept-contract.dto.ts | 17 + .../modules/contracts/dto/approve-step.dto.ts | 36 + .../dto/contract-list-summary.dto.ts | 20 + .../dto/create-booking-under-contract.dto.ts | 138 ++ .../contracts/dto/create-contract.dto.ts | 245 +++ .../contracts/dto/filter-contract.dto.ts | 93 + .../contracts/dto/renew-contract.dto.ts | 9 + .../dto/review-clearance-document.dto.ts | 18 + .../contracts/dto/sign-contract.dto.ts | 23 + .../contracts/dto/update-contract.dto.ts | 6 + .../entities/clearance-milestone.entity.ts | 65 + .../entities/contract-approval-step.entity.ts | 46 + .../entities/contract-cargo-scope.entity.ts | 34 + .../contract-clearance-cycle.entity.ts | 37 + .../contract-document-review.entity.ts | 55 + .../entities/contract-rate-snapshot.entity.ts | 47 + .../entities/contract-review-note.entity.ts | 36 + .../entities/contract-route.entity.ts | 40 + .../entities/contract-signature.entity.ts | 37 + .../contracts/entities/contract.entity.ts | 256 +++ .../src/modules/payment/payment.service.ts | 52 +- .../src/seed/demo-freight-data.seeder.ts | 3 +- .../src/seed/edr-freight.seed.ts | 11 +- .../src/seed/file-upload-settings.seeder.ts | 84 + .../src/seed/freight-permissions.registry.ts | 76 +- .../src/seed/freight-staff-users.seeder.ts | 3 +- apps/edr-freight-web/backoffice/src/App.tsx | 69 + .../contracts/ClearanceMilestoneTimeline.tsx | 194 ++ .../contracts/ContractActionsToolbar.tsx | 268 +++ .../ContractApprovalProgressCell.tsx | 33 + .../contracts/ContractApprovalStepsCard.tsx | 184 ++ .../ContractClearanceReviewSection.tsx | 517 +++++ .../contracts/ContractStatusBadge.tsx | 71 + .../contracts/ContractStatusTabs.tsx | 92 + .../contracts/ContractWorkflowStepper.tsx | 142 ++ .../contracts/GlCreateBookingForm.tsx | 539 +++++ .../backoffice/src/constants/QUERY_KEYS.ts | 16 + .../backoffice/src/constants/URLS.ts | 27 + .../contracts/contract-approval-progress.ts | 86 + .../contracts/contract-status.config.ts | 355 ++++ .../features/contracts/mapContractListRow.ts | 55 + .../src/hooks/contracts/useContracts.ts | 225 ++ .../backoffice/src/lib/permissions.ts | 32 + .../pages/contracts/BookingMilestonesPage.tsx | 130 ++ .../contracts/ContractClearanceDetailPage.tsx | 318 +++ .../contracts/ContractClearanceListPage.tsx | 536 +++++ .../contracts/ContractRequestDetailPage.tsx | 393 ++++ .../pages/contracts/ContractRequestsPage.tsx | 385 ++++ .../src/services/contracts.service.ts | 233 ++ apps/edr-freight-web/portal/src/App.tsx | 21 +- .../portal/src/constants/URLS.ts | 21 + .../pages/contracts/ContractClearanceFlow.tsx | 443 ++++ .../pages/contracts/ContractDetailPage.tsx | 724 +++++-- .../src/pages/contracts/ContractsList.tsx | 158 +- .../src/pages/contracts/NewContractPage.tsx | 841 ++++++++ .../src/pages/contracts/NewShipmentPage.tsx | 777 +++++++ .../src/pages/contracts/PlaceOrderDialog.tsx | 416 ---- .../src/pages/contracts/contract-ui.tsx | 87 +- .../new-contract-form/StepIndicator.tsx | 105 + .../contracts/new-contract-form/helpers.ts | 9 + .../payment-currency-field.tsx | 112 + .../contracts/new-contract-form/schema.ts | 259 +++ .../contracts/new-contract-form/shared.tsx | 176 ++ .../new-contract-form/step-documents.tsx | 161 ++ .../step0-operation-type.tsx | 138 ++ .../new-contract-form/step1-contract-type.tsx | 289 +++ .../new-contract-form/step2-service-type.tsx | 513 +++++ .../new-contract-form/step3-cargo-scope.tsx | 380 ++++ .../new-contract-form/step4-route.tsx | 323 +++ .../new-contract-form/step8-review.tsx | 593 ++++++ .../contracts/new-contract-form/steps.tsx | 7 + .../contracts/new-contract-form/unit-rates.ts | 13 + .../new-contract-form/useContractDraft.ts | 125 ++ .../contracts/new-shipment-form/schema.ts | 88 + .../contracts/new-shipment-form/total.ts | 106 + .../portal/src/services/api.ts | 132 +- .../src/services/booking-orders.service.ts | 44 - .../portal/src/services/contracts.service.ts | 265 +++ docs/new-doc.md | 1872 +++++++++++++++++ packages/types/src/freight/contracts.ts | 512 +++++ packages/types/src/freight/index.ts | 1 + 105 files changed, 19573 insertions(+), 909 deletions(-) create mode 100644 apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts create mode 100644 apps/edr-freight-api/src/migrations/1822000000000-CreateContracts.ts create mode 100644 apps/edr-freight-api/src/migrations/1823000000000-BackfillContractsFromBookings.ts create mode 100644 apps/edr-freight-api/src/migrations/1824000000000-DropLegacyContractTables.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contracts.controller.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contracts.module.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contracts.repository.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/contracts.service.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/contract-list-summary.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/filter-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/renew-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/review-clearance-document.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/update-contract.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-approval-step.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-cargo-scope.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-clearance-cycle.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-document-review.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-review-note.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-route.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract-signature.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ClearanceMilestoneTimeline.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalProgressCell.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusBadge.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractWorkflowStepper.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx create mode 100644 apps/edr-freight-web/backoffice/src/features/contracts/contract-approval-progress.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/contracts/contract-status.config.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/contracts/mapContractListRow.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/BookingMilestonesPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceListPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/contracts.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractClearanceFlow.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx delete mode 100644 apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/StepIndicator.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/helpers.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/payment-currency-field.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/shared.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step-documents.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step2-service-type.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/unit-rates.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts delete mode 100644 apps/edr-freight-web/portal/src/services/booking-orders.service.ts create mode 100644 apps/edr-freight-web/portal/src/services/contracts.service.ts create mode 100644 docs/new-doc.md create mode 100644 packages/types/src/freight/contracts.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3b69d2812..a48d248a5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,7 +13,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"; @@ -95,7 +95,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, - BookingOrdersModule, + ContractsModule, SignaturesModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts new file mode 100644 index 000000000..caf161614 --- /dev/null +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -0,0 +1,262 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; + +import { ContractsRepository } from '../modules/contracts/contracts.repository'; +import { Contract } from '../modules/contracts/entities/contract.entity'; +import { ContractRoute } from '../modules/contracts/entities/contract-route.entity'; +import { + ContractSignature, + ContractSignerRole, +} from '../modules/contracts/entities/contract-signature.entity'; +import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing.service'; +import { ContractTemplateResolver } from './contract-template.resolver'; +import { getTemplateMeta } from './contract-template.registry'; +import { ContractViewModel } from './contract-view-model.builder'; + +/** + * Signature row for the contract PDF. Mirrors the booking builder's + * `ContractSignatureView` but widens `role` to the contract's signer roles + * (CUSTOMER | STAFF | DIRECTOR | CEO). + */ +export interface ContractDocumentSignatureView { + role: ContractSignerRole; + signerDisplayName: string; + signedAt: string; + signatureImageUrl?: string | null; +} + +/** A single unit-rate row on the contract PDF — price per unit, NO total. */ +export interface ContractUnitRateRow { + label: string; + unitPrice: number; + unit: string; + currency: string; +} + +/** + * Pricing schedule for a Contract document: a unit-rate schedule (one price per + * unit, e.g. "X ETB / container") with NO quantities and NO grand total. Shaped + * to stay structurally compatible with the renderer's expectations of + * {@link ContractViewModel.pricing} (it reads `currency`). + */ +export interface ContractUnitRateSchedule { + displayMode: 'UNIT_RATES'; + unitRates: ContractUnitRateRow[]; + currency: string; + equipmentReturn?: string; + originLabel: string; + destinationLabel: string; +} + +/** Map a stored contract unit to a human PDF suffix ("/ container", "/ ton", …). */ +function unitLabel(unit: string): string { + switch (unit) { + case 'per_container': + return 'container'; + case 'per_ton': + return 'ton'; + case 'per_item': + return 'item'; + case 'per_km': + return 'km'; + default: + return 'unit'; + } +} + +/** + * Builds the contract PDF view-model from the {@link Contract} aggregate (the new + * source of truth) — mirrors {@link ContractViewModelBuilder} but every field is + * sourced from the contract, its routes, cargo scope and unit-rate breakdown. + * The legacy booking-based builder remains untouched for the migration window. + */ +@Injectable() +export class ContractDocumentViewModelBuilder { + constructor( + private readonly contractsRepository: ContractsRepository, + private readonly templateResolver: ContractTemplateResolver, + ) {} + + async build( + contractId: string, + ): Promise<{ contract: Contract; view: ContractViewModel }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) { + throw new NotFoundException(`Contract ${contractId} not found`); + } + + const templateKey = + contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); + const template = getTemplateMeta(templateKey); + const pricing = this.buildPricing(contract); + const signatures = await this.loadSignatures(contractId); + + const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); + const hasStaff = signatures.some((s) => s.role === 'STAFF'); + const hasContractFile = Boolean( + contract.files?.some((f) => f.code === 'contract'), + ); + + const view: ContractViewModel = { + bookingId: contract.id, + reference: contract.reference, + status: contract.status, + templateKey, + template, + contractDate: new Date().toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }), + contractYear: new Date().getFullYear(), + client: { + companyName: contract.company?.name ?? 'Client', + companyAddress: this.valueOrDash(contract.company?.address), + companyLocation: this.valueOrDash(contract.company?.country), + phone: this.valueOrDash(contract.company?.phone), + email: this.valueOrDash(contract.company?.email), + tinNumber: this.valueOrDash(contract.company?.tin), + vatNumber: this.valueOrDash(contract.company?.vatNumber), + fanNumber: this.valueOrDash(contract.company?.fanNumber), + businessLicense: this.valueOrDash( + contract.company?.companyProfiles?.[0]?.businessLicense, + ), + }, + provider: { + name: 'Ethio-Djibouti Standard Gauge Railway Share Company', + address: 'Addis Ababa, Ethiopia', + phone: '+251 11 872 0000', + email: 'info@edr.gov.et', + tinNumber: '—', + }, + schedule: this.buildSchedule(contract), + pricing: pricing as unknown as ContractViewModel['pricing'], + // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking + // view-model's narrower CUSTOMER|STAFF role union. + signatures: signatures as unknown as ContractViewModel['signatures'], + canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer, + canSignStaff: + contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, + hasContractDocument: hasContractFile, + hasCustomerSignature: hasCustomer, + hasStaffSignature: hasStaff, + }; + + return { contract, view }; + } + + private async loadSignatures( + contractId: string, + ): Promise { + const rows = await this.contractsRepository.findSignatures(contractId); + return rows.map((s) => this.toSignatureView(s)); + } + + toSignatureView(row: ContractSignature): ContractDocumentSignatureView { + return { + role: row.role, + signerDisplayName: row.signerDisplayName, + signedAt: this.formatDate(row.signedAt), + signatureImageUrl: row.signatureFile?.url ?? null, + }; + } + + /** Unit-rate schedule from the contract's frozen pricing breakdown — NO totals. */ + private buildPricing(contract: Contract): ContractUnitRateSchedule { + const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null; + const currency = breakdown?.currency ?? contract.paymentCurrency; + const lineItems = breakdown?.lineItems ?? []; + const firstRoute = this.firstRoute(contract); + + return { + displayMode: 'UNIT_RATES', + unitRates: lineItems.map((line) => ({ + label: line.label, + unitPrice: line.unitPrice, + unit: unitLabel(line.unit), + currency, + })), + currency, + equipmentReturn: contract.equipmentReturn ?? '—', + originLabel: this.yardLabel(firstRoute?.originYard), + destinationLabel: this.yardLabel(firstRoute?.destinationYard), + }; + } + + private buildSchedule(contract: Contract): ContractViewModel['schedule'] { + const firstRoute = this.firstRoute(contract); + const cargoScope = (contract.cargoScope ?? [])[0]; + const cargoName = + cargoScope?.cargoType?.cargoTypeName || + cargoScope?.cargoFreeText || + (cargoScope?.containerSize + ? `${cargoScope.containerSize} container` + : 'Container cargo'); + + return { + originLabel: this.yardLabel(firstRoute?.originYard), + destinationLabel: this.yardLabel(firstRoute?.destinationYard), + tradeDirection: this.valueOrDash(contract.tradeDirection), + freightType: this.valueOrDash(contract.freightType), + serviceType: this.valueOrDash( + contract.serviceType?.serviceName ?? contract.serviceType?.code, + ), + scheduledDate: this.formatDate(contract.estimatedShipmentDate), + contractType: this.valueOrDash(contract.contractType), + cargoDescription: this.valueOrDash(cargoName), + totalWeightVgm: '—', + equipmentReturn: this.valueOrDash(contract.equipmentReturn), + hazardousLabel: contract.isHazardous ? 'Yes' : 'No', + firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress), + lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress), + }; + } + + /** The contract's primary route (lowest sortOrder), used for origin/destination labels. */ + private firstRoute(contract: Contract): ContractRoute | undefined { + const routes = [...(contract.routes ?? [])].sort( + (a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0), + ); + return routes[0]; + } + + /** + * The template resolver reads a Booking; a contract carries equivalent fields + * under a different shape (cargoType lives on cargoScope). Build a minimal, + * structurally-compatible adapter rather than widening the resolver signature. + */ + private toResolverInput( + contract: Contract, + ): Parameters[0] { + const cargoType = (contract.cargoScope ?? []).find((c) => c.cargoType)?.cargoType; + return { + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + paymentCurrency: contract.paymentCurrency, + cargoType: cargoType ?? undefined, + serviceType: contract.serviceType, + } as Parameters[0]; + } + + private yardLabel(yard?: { label?: string; code?: string } | null): string { + return this.valueOrDash(yard?.label ?? yard?.code); + } + + private formatDate(value?: Date | string | null): string { + if (!value) return '—'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + }); + } + + private valueOrDash(value?: string | number | null): string { + if (value === undefined || value === null || value === '') return '—'; + return String(value); + } +} + +// Re-export for callers that want the role union without importing the entity. +export type { ContractSignerRole }; diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs index cb3440739..64319612a 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/article5_pricing.hbs @@ -25,6 +25,26 @@

Equipment return: {{pricing.equipmentReturn}}

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

Unit Rate Schedule

+

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

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

Charges

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

Terms of payment

Unless otherwise agreed in writing, the Client shall settle the contract value in diff --git a/apps/edr-freight-api/src/migrations/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/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/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/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-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index d819ee527..70941d12b 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 @@ -9,7 +9,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'; 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..8b50e5a49 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -34,7 +34,6 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; - bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -693,11 +692,6 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } - if (options.bookingType) { - qb.andWhere('booking.booking_type = :bookingType', { - bookingType: options.bookingType, - }); - } if (options.createdFrom) { qb.andWhere('booking.created_at >= :createdFrom', { createdFrom: options.createdFrom, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index c47064014..3d3bab20b 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) { @@ -442,7 +450,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, @@ -469,7 +476,6 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, - bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, estimatedShipmentDate: dto.estimatedShipmentDate ? new Date(dto.estimatedShipmentDate) @@ -496,27 +502,6 @@ export class BookingsService { warnings.push(`Estimated wagons required: ${wagonCount}`); } - // Multi-route general contracts: persist the contracted routes (lanes). Routes - // carry NO quantity — the contract has a single shared pool (the cargo-step - // total / container quantities). Each drawdown order picks one lane for - // scheduling + road billing and draws from that shared pool. `quantity` on the - // route line is retained for legacy rows but is no longer meaningful (0). - if (isGeneralContract && dto.routes?.length) { - const routeRepo = this.dataSource.getRepository(ContractRouteLine); - await routeRepo.save( - dto.routes.map((r) => - routeRepo.create({ - contractBookingId: booking.id, - originYardId: r.originYardId, - destinationYardId: r.destinationYardId, - containerTypeId: null, - quantity: 0, - km: r.km ?? null, - }), - ), - ); - } - if (files.length > 0) { try { await this.filesService.uploadMany(booking.id, 'bookings', files); @@ -808,7 +793,6 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, - bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -1017,7 +1001,6 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, - bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts new file mode 100644 index 000000000..e8ef1b138 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container-unit.entity.ts @@ -0,0 +1,37 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { BookingContainer } from './booking-container.entity'; + +/** + * One physical container under a booking_container line — its number, seal, and + * per-unit VGM. Entered at booking time (by the customer in Path A or by GL ET + * in Path B). See §5.10. + */ +@Entity({ schema: 'freight', name: 'booking_container_units' }) +@Index(['bookingContainerId']) +export class BookingContainerUnit extends BaseEntity { + @Column({ name: 'booking_container_id', type: 'uuid' }) + bookingContainerId!: string; + + @ManyToOne(() => BookingContainer, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_container_id' }) + bookingContainer?: BookingContainer; + + @Column({ name: 'container_number', type: 'varchar', length: 64 }) + containerNumber!: string; + + @Column({ name: 'seal_number', type: 'varchar', length: 64, nullable: true }) + sealNumber?: string | null; + + @Column({ name: 'vgm_tons', type: 'numeric', precision: 10, scale: 3 }) + vgmTons!: number; + + @Column({ name: 'is_hazardous', type: 'boolean', default: false }) + isHazardous!: boolean; + + @Column({ name: 'is_reefer', type: 'boolean', default: false }) + isReefer!: boolean; + + @Column({ name: 'sort_order', type: 'smallint', default: 0 }) + sortOrder!: number; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index 8a09245ea..db9746c09 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -25,9 +25,21 @@ export class BookingContainer extends BaseEntity { @Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true }) containerNumber?: string | null; + /** Contract container size this line covers (20ft | 40ft). Null for legacy rows. */ + @Column({ name: 'container_size', type: 'varchar', length: 10, nullable: true }) + containerSize?: string | null; + @Column({ name: 'quantity', type: 'smallint' }) quantity!: number; + /** How many units of this line are hazardous (≤ quantity). */ + @Column({ name: 'hazardous_quantity', type: 'smallint', default: 0 }) + hazardousQuantity!: number; + + /** How many units of this line are refrigerated (≤ quantity). */ + @Column({ name: 'reefer_quantity', type: 'smallint', default: 0 }) + reeferQuantity!: number; + @Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 }) vgmPerUnitTons!: number; diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 00f6e41c1..e90bd92ef 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 @@ -144,13 +144,24 @@ export class Booking extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - /** - * ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an - * umbrella contract that is signed/paid once and then drawn down by many - * orders (each order spawns its own ONE_TIME child booking). - */ - @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) - bookingType!: string; + /** The contract this shipment booking was created under (contract–booking split). */ + @Column({ name: 'contract_id', type: 'uuid', nullable: true }) + contractId?: string | null; + + /** The contract route (lane) this shipment uses. */ + @Column({ name: 'contract_route_id', type: 'uuid', nullable: true }) + contractRouteId?: string | null; + + /** Denormalized contract kind (ONE_TIME | GENERAL) for the single-active-booking index. */ + @Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true }) + contractKind?: string | null; + + /** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */ + @Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true }) + createdByRole?: string | null; + + @Column({ name: 'created_by_user_id', type: 'uuid', nullable: true }) + createdByUserId?: string | null; /** * Nullable: general contracts have no shipment date at creation — the date is @@ -220,13 +231,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; 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..1190d1f10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -0,0 +1,140 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { ClearanceMilestone } 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; + } + + /** 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..9811d03d5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -0,0 +1,383 @@ +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: only one active booking at a time (also enforced by partial unique index). + 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.', + ); + } + } + + const route = await this.resolveRoute(contract, dto.contractRouteId); + const warnings: string[] = []; + + const reference = await this.generateReference(); + const freightType = contract.freightType; + + // 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: '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); + } + + // Path B side effects: link the clearance cycle, seed post-booking + // milestones onto the booking, and advance the contract. + if (contract.customsClearingEnabled) { + 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); + } + + 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 — GL Ethiopia only. + if (!isGlActor) { + throw new ForbiddenException( + 'Only Global Logistics Ethiopia can create bookings for customs-clearance contracts.', + ); + } + 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(); + } + + 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..0ccc933d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -0,0 +1,395 @@ +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; +} + +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, + }); + } + }; + + 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, + }); + } + + 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}`, + ); + } + } + + /** GL ET reviews a single document: APPROVED or QUERIED (→ back to upload). */ + async reviewDocument( + contractId: string, + fileKey: string, + status: 'APPROVED' | 'QUERIED', + staffId: string, + note?: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { + 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, + 'GL_ET', + ); + // 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 pre-booking clearance: requires every customer document + * APPROVED (and required output docs present) → CLEARANCE_READY_FOR_BOOKING. + */ + async finalize(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + 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); + } + + /** + * GL ET queue: contracts awaiting pre-booking document review. Scoped to + * CLEARANCE_UNDER_REVIEW (customs contracts only). + */ + async queue( + filter: FilterContractDto, + region?: string, + ): Promise { + void region; // single ET pre-booking queue today; region reserved for split + return this.contractsRepository.findAllPaginated({ + page: filter.page ?? 1, + pageSize: filter.pageSize ?? 100, + statuses: ['CLEARANCE_UNDER_REVIEW'], + sortBy: filter.sortBy, + sortOrder: filter.sortOrder, + }); + } +} 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..91b2eba58 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.util.ts @@ -0,0 +1,69 @@ +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. */ +export function contractClearanceSettingCode( + tradeDirection: string, + freightType: string, + includesCustoms: boolean, +): string | null { + if (!includesCustoms) return null; + const op = operationFor(tradeDirection); + if (!op) return null; + const freight = freightFor(freightType); + 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..60fc9dc99 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -0,0 +1,204 @@ +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, + }); + } + } + + // Conditional surcharges — shown only when the contract toggles them on. + if (contract.isHazardous) { + const hazard = liveRates.find( + (r) => r.rateType === 'HAZARD_SURCHARGE' && r.currency === 'USD', + ); + if (hazard) { + 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) { + 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..1b5cbe8ba --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -0,0 +1,528 @@ +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 { 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 { Contract } from './entities/contract.entity'; +import { ContractSignerRole } from './entities/contract-signature.entity'; +import { SignContractDto } from './dto/sign-contract.dto'; + +/** 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 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.', + ); + } + + 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); + } + + /** + * 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'); + + const updates: Record = {}; + const now = new Date(); + if (requiredRole === 'LINE_STAFF') { + updates.status = 'APPROVED_PENDING_SIGNATURE'; + updates.approvedByStaffId = actorId; + updates.approvedByStaffAt = now; + } else if (requiredRole === 'DIRECTOR') { + updates.signedByDirectorId = actorId; + updates.signedByDirectorAt = now; + } else if (requiredRole === 'CEO') { + updates.signedByCeoId = actorId; + updates.signedByCeoAt = now; + } + + const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId); + if (allDone) { + updates.status = 'APPROVED'; + } + + 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 }; + } + + /** 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; + const raw = dto.signatureImageBase64.includes(',') + ? dto.signatureImageBase64.split(',')[1]! + : dto.signatureImageBase64; + 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: dto.signerDisplayName, + signedAt: new Date(), + signatureFileId: fileRecord.id, + consentText: dto.consentText ?? null, + }); + + if (options.signerUserId) { + try { + await this.signaturesService.upsertForUser({ + userId: options.signerUserId, + signerDisplayName: dto.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); + return this.contractsService.findById(contractId); + } + + return this.counterSign(contractId, dto, options); + } + + /** + * Staff/Director/CEO counter-sign → branch on customs: + * - customs: AWAITING_CLEARANCE_DOCUMENTS + clearance gate opened (Path B) + * - transport: FULLY_EXECUTED (ONE_TIME) / CONTRACT_ACTIVE (GENERAL) + */ + 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, + }; + + if (contract.customsClearingEnabled) { + // Path B — open a clearance cycle, seed the pre-booking milestones, and + // route the customer to the document upload. + 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 { + // Path A — transport only; ready for the customer to book. + updates.status = + contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED'; + updates.clearanceStatus = 'NOT_APPLICABLE'; + } + + await this.contractsRepository.update(contractId, updates as never); + 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..56c3d5a63 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -0,0 +1,461 @@ +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 { 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'; + +@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, + ) {} + + @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, + @Query('region') region?: string, + ) { + return this.clearanceService.queue(filter, region); + } + + @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); + 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, + }; + } + + @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); + } + + // ── 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, + ); + } + + // ── 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, + ); + } +} 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..1c8dbb3b1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -0,0 +1,96 @@ +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 { 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 { 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 { 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, + BookingContainerUnit, + ]), + RuleEngineModule, + FileUploadSettingsModule, + 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, + // 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..6cbb14000 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -0,0 +1,521 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, 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; + 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('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(); + + const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0; + return { + items, + total, + meta: { + page, + pageSize, + total, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }, + }; + } + + 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.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..c29baf4b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -0,0 +1,481 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { CompaniesService } from '../companies/companies.service'; +import { 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); + + 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.'); + } + } + + return { contract: await this.findById(contract.id), warnings }; + } + + 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'], + ): Promise { + const repo = this.dataSource.getRepository(ContractCargoScope); + await repo.save( + cargoScope.map((c) => + repo.create({ + contractId, + containerSize: c.containerSize ?? null, + cargoTypeId: c.cargoTypeId ?? null, + cargoFreeText: c.cargoFreeText ?? 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); + } + + 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-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..e70d436e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -0,0 +1,245 @@ +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; +} + +/** 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/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..0f4460376 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; + +export class SignContractDto { + @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) + @IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO']) + role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO'; + + @ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) + @IsString() + @MinLength(20) + signatureImageBase64!: string; + + @ApiProperty() + @IsString() + @MinLength(1) + signerDisplayName!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + consentText?: string; +} diff --git a/apps/edr-freight-api/src/modules/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/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts new file mode 100644 index 000000000..ddf61c0fb --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -0,0 +1,65 @@ +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]; + +/** + * 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: '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..51a975752 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-cargo-scope.entity.ts @@ -0,0 +1,34 @@ +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; +} 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..101afc2fe --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -0,0 +1,256 @@ +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', + '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/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 582e7467e..dbd635d86 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -34,13 +34,8 @@ import { RefundDto, } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; -import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; import { FirstMileService } from "../first-mile/first-mile.service"; -/** Setting code holding the global ordering window (months) for general contracts. */ -const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; -const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; - const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, "processing": ProviderPaymentStatus.PROCESSING, @@ -60,24 +55,9 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, - private readonly dropdownSettings: DropdownSettingsService, private readonly firstMileService: FirstMileService, ) { } - /** Configured general-contract ordering window in months (defaults to 3). */ - private async contractPeriodMonths(): Promise { - try { - const setting = await this.dropdownSettings.getByCode( - CONTRACT_PERIOD_SETTING_CODE, - ); - const months = Number(setting.children?.[0]?.value); - if (Number.isFinite(months) && months > 0) return months; - } catch { - // Setting not seeded — fall back to the default. - } - return DEFAULT_CONTRACT_PERIOD_MONTHS; - } - async getAll(filters: { search?: string; status?: string; @@ -315,22 +295,8 @@ export class PaymentService { const paidAt = input.paidAt ?? new Date(); - // A general contract is paid once, up front; it does NOT enter the train - // queue (nothing has been ordered yet). Instead it becomes ACTIVE and - // opens its ordering window. Orders placed later spawn their own paid - // child bookings that go through the normal pipeline. - const booking = await this.datasource - .getRepository(Booking) - .findOne({ where: { id: input.bookingId } }); - const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; - - let contractExpiresAt: Date | null = null; - if (isGeneralContract) { - const months = await this.contractPeriodMonths(); - contractExpiresAt = new Date(paidAt); - contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); - } - + // Every booking is a real shipment now (contracts are a separate aggregate), + // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { await mg.update( PaymentEntity, @@ -340,21 +306,11 @@ export class PaymentService { await mg.update( Booking, { id: input.bookingId }, - isGeneralContract - ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } - : { paymentStatus: "PAID", status: "PAID" }, + { paymentStatus: "PAID", status: "PAID" }, ); - await this.firstMileService.acceptBooking(input.bookingId); - + await this.firstMileService.acceptBooking(input.bookingId); }); - if (isGeneralContract) { - this.logger.log( - `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, - ); - return { alreadyFinalized: false }; - } - try { await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); } catch (err) { diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts index 5936e7c93..da2da818f 100644 --- a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -26,7 +26,8 @@ const DEMO_STAFF_USERS = [ { email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' }, { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, ] as const; /** diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 9c0e62001..3c295c9d0 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -238,9 +238,14 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [...ROLE_PERMISSION_PRESETS.marketing], }, { - key: "edr_global_logistics", - name: { en: "EDR Global Logistics" }, - permissionKeys: [...ROLE_PERMISSION_PRESETS.globalLogistics], + key: "edr_gl_ethiopia", + name: { en: "EDR Global Logistics — Ethiopia" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.glEthiopia], + }, + { + key: "edr_gl_djibouti", + name: { en: "EDR Global Logistics — Djibouti" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.glDjibouti], }, { key: "edr_org_manager", diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index bb497f8f8..74ebd9d93 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -352,6 +352,80 @@ const CLEARANCE_DOCUMENT_SETTINGS: OnboardingDocumentSetting[] = [ }, ]; +// ── Contract pre-booking clearance settings (Path B) ──────────────────────── +// For customs-clearance contracts the customer uploads clearance documents on +// the CONTRACT (before any booking exists). Same customer doc sets as the legacy +// booking clearance, plus the GL output sets, keyed on the contract. Resolved by +// contract-clearance.util.ts (codes: contract_clearance_{op}_{freight} and +// contract_clearance_output_{op}_container). +const CONTRACT_CLEARANCE_ENTITY = "contract_clearance"; + +const CONTRACT_CLEARANCE_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "contract_clearance_import_container", + label: "Contract clearance documents (import container)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_FIELDS, + }, + { + code: "contract_clearance_export_container", + label: "Contract clearance documents (export container)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_FIELDS, + }, + { + code: "contract_clearance_import_bulk", + label: "Contract clearance documents (import bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_BULK_FIELDS, + }, + { + code: "contract_clearance_export_bulk", + label: "Contract clearance documents (export bulk)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_BULK_FIELDS, + }, + // GL ET output sets uploaded during pre-booking clearance. + { + code: "contract_clearance_output_import_container", + label: "Contract customs output documents (import container)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: IMPORT_CONTAINER_OUTPUT_FIELDS, + }, + { + code: "contract_clearance_output_export_container", + label: "Contract customs output documents (export container)", + entity: CONTRACT_CLEARANCE_ENTITY, + fields: EXPORT_CONTAINER_OUTPUT_FIELDS, + }, +]; + +// ── Contract intake settings ──────────────────────────────────────────────── +// Commercial/framework documents attached at contract submission (wizard step 5), +// distinct from the post-sign clearance docs above. +const CONTRACT_INTAKE_ENTITY = "contract_intake"; + +const CONTRACT_INTAKE_FIELDS: OnboardingField[] = [ + clearanceField("commercial_framework", "Commercial Framework / Agreement", 1, { + required: false, + }), + clearanceField("onboarding_attachment", "Onboarding Attachment", 2, { + required: false, + }), + clearanceField("supporting_document", "Supporting Document", 3, { + required: false, + }), +]; + +const CONTRACT_INTAKE_SETTINGS: OnboardingDocumentSetting[] = [ + { + code: "contract_intake_documents", + label: "Contract intake documents", + entity: CONTRACT_INTAKE_ENTITY, + fields: CONTRACT_INTAKE_FIELDS, + }, +]; + const CLEARANCE_DESCRIPTION = "Operation/clearance documents collected after contract execution, by operation, freight type and customs."; @@ -377,6 +451,16 @@ export class FileUploadSettingsSeeder { ...s, description: CLEARANCE_DESCRIPTION, })), + ...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({ + ...s, + description: + "Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.", + })), + ...CONTRACT_INTAKE_SETTINGS.map((s) => ({ + ...s, + description: + "Commercial/framework documents attached at contract submission.", + })), ]; for (const documentSetting of allSettings) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 12cca3f53..b1a84139f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -62,6 +62,25 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), ]; +/** + * Contract-phase permissions (contract–booking separation). Mirror the booking + * approval/sign/clearance permissions but scoped to the new contracts module. + */ +export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'), + perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'), + perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'), + perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'), + perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'), + perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'), + perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'), + perm('a3000001-0001-4000-8000-000000000008', 'edr_freight_app:contracts:generate_contract', 'Generate contract document'), + perm('a3000001-0001-4000-8000-000000000009', 'edr_freight_app:contracts:sign_staff', 'Staff contract signature'), + perm('a3000001-0001-4000-8000-00000000000a', 'edr_freight_app:contracts:clearance_review', 'Review pre-booking clearance docs'), + perm('a3000001-0001-4000-8000-00000000000b', 'edr_freight_app:contracts:finalize_clearance', 'Finalize pre-booking clearance'), + perm('a3000001-0001-4000-8000-00000000000c', 'edr_freight_app:contracts:create_booking', 'GL ET create booking under contract'), +]; + const RULE_ENGINE_PERMISSION_IDS: Record = { 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, @@ -88,6 +107,7 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, + ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, ]; @@ -114,6 +134,20 @@ export const FREIGHT_PERMS = { uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output', finalizeClearance: 'edr_freight_app:bookings:finalize_clearance', }, + contracts: { + view: 'edr_freight_app:contracts:view', + staffAccept: 'edr_freight_app:contracts:staff_accept', + requestChanges: 'edr_freight_app:contracts:request_changes', + reject: 'edr_freight_app:contracts:reject', + approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', + approveDirector: 'edr_freight_app:contracts:approve_director', + approveCeo: 'edr_freight_app:contracts:approve_ceo', + generateContract: 'edr_freight_app:contracts:generate_contract', + signStaff: 'edr_freight_app:contracts:sign_staff', + clearanceReview: 'edr_freight_app:contracts:clearance_review', + finalizeClearance: 'edr_freight_app:contracts:finalize_clearance', + createBooking: 'edr_freight_app:contracts:create_booking', + }, trainScheduling: { view: 'edr_freight_app:train_scheduling:view', manage: 'edr_freight_app:train_scheduling:manage', @@ -146,6 +180,11 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.staffAccept, + FREIGHT_PERMS.contracts.requestChanges, + FREIGHT_PERMS.contracts.reject, + FREIGHT_PERMS.contracts.approveLineStaff, ...allRuleEngineViewKeys(), ], // Operations Officer: train scheduling + wagon allocation + transit/complete @@ -164,12 +203,17 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveDirector, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.generateContract, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.approveDirector, + FREIGHT_PERMS.contracts.generateContract, ...allRuleEngineViewKeys(), ], ceo: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.approveCeo, FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.approveCeo, ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], @@ -178,11 +222,28 @@ export const ROLE_PERMISSION_PRESETS = { // clearance:view permission lists the clearance bookings. Reviews customer // clearance documents, uploads customs output documents, and finalizes the // clearance gate. - globalLogistics: [ + // GL Ethiopia (edr_gl_ethiopia): reviews pre-booking clearance docs on the + // contract, uploads ET output docs, finalizes clearance, and EXCLUSIVELY creates + // the booking under a customs contract (Path B). Also runs post-booking ET + // milestones + the legacy booking-clearance permissions during migration. + glEthiopia: [ + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.clearanceReview, + FREIGHT_PERMS.contracts.finalizeClearance, + FREIGHT_PERMS.contracts.createBooking, FREIGHT_PERMS.bookings.clearanceView, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.uploadClearanceOutput, FREIGHT_PERMS.bookings.finalizeClearance, + FREIGHT_PERMS.bookings.operations, + ], + // GL Djibouti (edr_gl_djibouti): DO/RO collection, gatepass, loading milestones, + // damage reports. Read-only on the contract; no booking creation. + glDjibouti: [ + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.clearanceView, + FREIGHT_PERMS.bookings.uploadClearanceOutput, + FREIGHT_PERMS.bookings.operations, ], // Marketing handles intake through contract (same as line staff here) and, // for non-customs bookings, reviews/finalizes the customer's clearance @@ -199,6 +260,13 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.signStaff, FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.contracts.staffAccept, + FREIGHT_PERMS.contracts.requestChanges, + FREIGHT_PERMS.contracts.reject, + FREIGHT_PERMS.contracts.approveLineStaff, + FREIGHT_PERMS.contracts.generateContract, + FREIGHT_PERMS.contracts.signStaff, ], orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; @@ -206,5 +274,9 @@ export const ROLE_PERMISSION_PRESETS = { export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, - module: p.key.includes(':bookings:') ? 'bookings' : 'rule_engine', + module: p.key.includes(':bookings:') + ? 'bookings' + : p.key.includes(':contracts:') + ? 'contracts' + : 'rule_engine', })); diff --git a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts index 03016cf76..b6d83c138 100644 --- a/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-staff-users.seeder.ts @@ -18,7 +18,8 @@ const STAFF_USERS = [ { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff' }, { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, - { email: 'gl@edr.local', username: 'gl', roleKey: 'edr_global_logistics' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, ] as const; @Injectable() diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 4e268c99f..75027aefc 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -2,6 +2,7 @@ import { Boxes, Building2, Container, + FileSignature, FileText, LayoutDashboard, LayoutGrid, @@ -31,6 +32,12 @@ import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import ContractRequestsPage from "./pages/contracts/ContractRequestsPage"; +import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage"; +import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; +import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; +import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -97,6 +104,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Contract requests", + href: "/dashboard/contract-requests", + icon: , + permission: FREIGHT_PERMS.contracts.view, + }, { label: "Customers", href: "/dashboard/customers", @@ -120,6 +133,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.reviewDocuments, }, + { + label: "Contract Clearance", + href: "/dashboard/contracts/clearance", + icon: , + permission: FREIGHT_PERMS.contracts.clearanceReview, + }, { label: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", @@ -413,6 +432,56 @@ const App = () => { } /> + + {/* Contracts (Path A/B) */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceMilestoneTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceMilestoneTimeline.tsx new file mode 100644 index 000000000..ad900e67c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ClearanceMilestoneTimeline.tsx @@ -0,0 +1,194 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Group, + Stack, + Text, + Textarea, + ThemeIcon, +} from "@mantine/core"; +import { Check, Circle, Clock, MinusCircle } from "lucide-react"; +import type { Freight } from "@edr/types"; + +export interface ClearanceMilestoneTimelineProps { + milestones: Freight.IClearanceMilestone[]; + /** Complete a milestone by code (omit to render read-only). */ + onComplete?: (code: string, note?: string) => void; + /** True while a complete mutation is in flight. */ + busy?: boolean; +} + +const STATUS_META: Record< + Freight.MilestoneStatus, + { color: string; label: string } +> = { + COMPLETED: { color: "edr-green", label: "Completed" }, + PENDING: { color: "gray", label: "Pending" }, + SKIPPED: { color: "gray", label: "Skipped" }, +}; + +/** Vertical timeline of GL clearance milestones with inline complete actions. */ +export function ClearanceMilestoneTimeline({ + milestones, + onComplete, + busy, +}: ClearanceMilestoneTimelineProps) { + const [openNote, setOpenNote] = useState>({}); + const [notes, setNotes] = useState>({}); + + const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder); + const nextPending = sorted.find((m) => m.status === "PENDING"); + + if (sorted.length === 0) { + return ( + + No milestones for this shipment yet. + + ); + } + + return ( + + {sorted.map((m, index) => { + const isLast = index === sorted.length - 1; + const meta = STATUS_META[m.status]; + const isNext = nextPending?.id === m.id; + const Icon = + m.status === "COMPLETED" + ? Check + : m.status === "SKIPPED" + ? MinusCircle + : isNext + ? Clock + : Circle; + + return ( + + + + + + {!isLast && ( + + )} + + + + + + + {m.milestoneLabel} + + + + {meta.label} + + {m.ownerRegion ? ( + + {m.ownerRegion} + + ) : null} + + {m.note ? ( + + {m.note} + + ) : null} + + + {onComplete && m.status === "PENDING" && isNext ? ( + !openNote[m.milestoneCode] ? ( + + ) : null + ) : null} + + + {onComplete && openNote[m.milestoneCode] && ( + +