diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fcd560a95..72ad6de66 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -170,13 +170,36 @@ jobs: - name: Build ${{ matrix.service }} run: | set -euo pipefail + IMAGE_TAG="${COMPOSE_PROJECT_NAME}-${{ matrix.service }}:${GITHUB_SHA::8}" docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + # Tag with git SHA for rollback capability + CONTAINER_NAME=$(docker compose --project-name "${COMPOSE_PROJECT_NAME}" config --services | grep "${{ matrix.service }}" | head -1) + docker tag "${COMPOSE_PROJECT_NAME}-${{ matrix.service }}" "${IMAGE_TAG}" 2>/dev/null || true + echo "IMAGE_TAG=${IMAGE_TAG}" >> "${GITHUB_ENV}" - name: Deploy ${{ matrix.service }} run: | set -euo pipefail docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate + - name: Verify deployment health + if: contains(fromJson('["passenger-api", "payment-api"]'), matrix.service) + run: | + set -euo pipefail + PORT=$(grep '^PORT=' "${SERVICE_ENV_FILE}" | cut -d= -f2) + echo "Waiting for service to become healthy on port ${PORT}..." + for i in $(seq 1 12); do + if wget -qO- "http://localhost:${PORT}/health/ready" 2>/dev/null | grep -q '"status":"ok"'; then + echo "Service is healthy." + exit 0 + fi + echo "Attempt ${i}/12 — not ready yet, waiting 10s..." + sleep 10 + done + echo "Service failed health check after 120s — rolling back" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" --force-recreate || true + exit 1 + - name: Remove npm credentials from workspace if: always() run: rm -f .npmrc .npmrc_temp diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 2818dcd97..463cea535 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:->/tcp` with matching ports. ### Runtime -The final image runs: +The final image uses Next.js `output: 'standalone'` and runs: ```bash -npx next start +node server.js ``` -Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose) to determine which port to listen on. +Next.js reads `PORT` from the runtime environment (supplied via `env_file` in docker-compose). The standalone output bundles only the required `node_modules`, producing a significantly smaller image than a full `pnpm deploy`. + +## Rollback Procedure + +Each build is tagged with the short git SHA (`${COMPOSE_PROJECT_NAME}-:`). + +### Rollback a single service + +```bash +# 1. Find the last known-good image tag +docker images | grep passenger-api + +# 2. Re-tag it as the current image +docker tag edr-passenger-main-passenger-api: edr-passenger-main-passenger-api:latest + +# 3. Restart the container from the previous image +docker compose --project-name edr-passenger-main up -d passenger-api --force-recreate +``` + +### Rollback via re-run + +Alternatively, trigger a `workflow_dispatch` on the last known-good commit SHA from the GitHub Actions UI — this rebuilds and redeploys that exact commit. + +## Production Security Checklist + +Before deploying to production, verify: + +- [ ] `JWT_SECRET`, `JWT_ACCESS_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET` are set to random 32+ char strings (`openssl rand -hex 32`) +- [ ] `DATABASE_URL` includes `?sslmode=require&connection_limit=10` +- [ ] `WAAFI_INSECURE_TLS` is `false` (app will refuse to start if `true` in production) +- [ ] `NODE_ENV=production` is set +- [ ] `GITHUB_PACKAGE_TOKEN` is a scoped read-only token, not a personal admin token +- [ ] No `.env` files are committed to the repository (`git status` should show none) + +## Data Retention Policy + +The `TasksService` runs a daily purge cron at 02:00 EAT that automatically deletes: + +| Table | Retention | +|---|---| +| `OtpCode` | 1 hour after expiry or verification | +| `FaydaVerificationSession` | 1 hour after expiry or completion | +| `AuditLog` | 365 days | +| `PaymentWebhookEvent` | 90 days | +| `GateValidationLog` | 180 days | + +No manual intervention is required. Monitor the `TasksService` log output for purge counts. ## GitHub Actions Deployment Flow @@ -147,9 +193,10 @@ For each service: - Computes branch slug and sets: - `COMPOSE_PROJECT_NAME=-` - Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`. -- Runs: - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" build ` - - `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d ` +- For `passenger-api` and `payment-api`: builds and runs the migration image as a gated step before the app image. +- Builds the service image and tags it with the short git SHA. +- Runs `docker compose up -d --force-recreate`. +- For API services: polls `GET /health/ready` every 10s for up to 120s. Fails the job if the service does not become healthy. - Cleans `.npmrc`/`.npmrc_temp`. ## Branch/Environment Isolation diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index 49ea7c637..5bce9de95 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -55,13 +55,13 @@ REDIS_HOST=localhost REDIS_PORT=6379 # --- Notification broker (RabbitMQ) --------------------------------------------- -# SMS OTP / notifications are queued to RabbitMQ (consumed by the shared SMS service). -# Set RABBITMQ_ENABLED=false to skip the broker entirely (dev without a local broker). +# SMS/email OTP + notifications are queued to RabbitMQ (consumed by the shared +# SMS/email services). Set RABBITMQ_ENABLED=false to skip the broker entirely +# (dev without a local broker). RABBITMQ_ENABLED=false RABBITMQ_URL=amqp://localhost:5672 SMS_QUEUE=sms_queue - # ── VeriFayda 2.0 (eSignet OIDC) identity verification ────────────────────── # Disabled by default; /fayda/verification/start returns 503 until enabled. FAYDA_ENABLED=false @@ -81,4 +81,5 @@ FAYDA_ACR_VALUES=mosip:idp:acr:generated-code FAYDA_CLAIMS_LOCALES=en am FAYDA_SESSION_TTL_MINUTES=10 EXPIRATION_TIME=15 -ALGORITHM=RS256 \ No newline at end of file +ALGORITHM=RS256 +EMAIL_QUEUE=email_queue diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d72c8b720..40161209e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -1,4 +1,8 @@ -import { Module, OnApplicationBootstrap } from "@nestjs/common"; +import { + MiddlewareConsumer, + Module, + OnApplicationBootstrap, +} from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; import { ScheduleModule } from "@nestjs/schedule"; @@ -62,21 +66,22 @@ import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { OverviewModule } from './modules/overview/overview.module'; -import { VehiclesModule } from './modules/vehicles/vehicles.module'; -import { DriversModule } from './modules/drivers/drivers.module'; -import { FuelModule } from './modules/fuel/fuel.module'; -import { MaintenanceModule } from './modules/maintenance/maintenance.module'; -import { FirstMileModule } from './modules/first-mile/first-mile.module'; -import { LastMileModule } from './modules/last-mile/last-mile.module'; -import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; -import { ImportOperationsModule } from './modules/import-operations/import-operations.module'; import { VerifaydaModule } from './modules/verifayda/verifayda.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { OverviewModule } from "./modules/overview/overview.module"; +import { VehiclesModule } from "./modules/vehicles/vehicles.module"; +import { DriversModule } from "./modules/drivers/drivers.module"; +import { FuelModule } from "./modules/fuel/fuel.module"; +import { MaintenanceModule } from "./modules/maintenance/maintenance.module"; +import { FirstMileModule } from "./modules/first-mile/first-mile.module"; +import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; +import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; +import { LoggerMiddleware } from "./logger.middleware"; @Module({ imports: [ @@ -214,4 +219,8 @@ export class AppModule implements OnApplicationBootstrap { // bookings bill to. Idempotent — keyed by fixed IDs. await this.govCompaniesSeeder.run(); } + + configure(consumer: MiddlewareConsumer) { + consumer.apply(LoggerMiddleware).forRoutes("*"); + } } diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 8d55f1dc4..7eae4e94d 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -28,3 +28,7 @@ export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); + +/** Container allocation on a booking (allocate-containers endpoint). */ +export const AllocationManage = () => + BookingStaff(FREIGHT_PERMS.allocation.manage); diff --git a/apps/edr-freight-api/src/logger.middleware.ts b/apps/edr-freight-api/src/logger.middleware.ts new file mode 100644 index 000000000..dd7532ec8 --- /dev/null +++ b/apps/edr-freight-api/src/logger.middleware.ts @@ -0,0 +1,21 @@ +import { Injectable, NestMiddleware, Logger } from "@nestjs/common"; +import { Request, Response, NextFunction } from "express"; + +@Injectable() +export class LoggerMiddleware implements NestMiddleware { + private readonly logger = new Logger("HTTP"); + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + + res.on("finish", () => { + const duration = Date.now() - start; + + this.logger.log( + `${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`, + ); + }); + + next(); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts new file mode 100644 index 000000000..a98fcd80d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000000-AddBookingWindowGlobalRules.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface { + name = "AddBookingWindowGlobalRules1861000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN import_window_lead_days integer NOT NULL DEFAULT 3, + ADD COLUMN export_booking_lead_hours integer NOT NULL DEFAULT 24, + ADD COLUMN window_open_hour integer NOT NULL DEFAULT 8, + ADD COLUMN window_duration_hours numeric(4, 2) NOT NULL DEFAULT 3, + ADD COLUMN doc_review_minutes integer NOT NULL DEFAULT 30, + ADD COLUMN payment_window_minutes integer NOT NULL DEFAULT 60, + ADD COLUMN reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS import_window_lead_days, + DROP COLUMN IF EXISTS export_booking_lead_hours, + DROP COLUMN IF EXISTS window_open_hour, + DROP COLUMN IF EXISTS window_duration_hours, + DROP COLUMN IF EXISTS doc_review_minutes, + DROP COLUMN IF EXISTS payment_window_minutes, + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts new file mode 100644 index 000000000..0d5155391 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1861000000001-ReleaseStuckAssignedLocomotives.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * arriveSchedule used to release only the primary locomotive of a train set, leaving + * secondary locomotives ASSIGNED forever. Locomotives are now only ASSIGNED while out + * on a dispatched train — release every ASSIGNED locomotive that is not attached to a + * currently-DISPATCHED schedule. + */ +export class ReleaseStuckAssignedLocomotives1861000000001 implements MigrationInterface { + name = "ReleaseStuckAssignedLocomotives1861000000001"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.locomotives l + SET status = 'AVAILABLE' + WHERE l.status = 'ASSIGNED' + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND loco.locomotive_id = l.id + ); + `); + } + + public async down(): Promise { + // Data fix — not reversible. + } +} diff --git a/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts new file mode 100644 index 000000000..cae33a534 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1862000000000-AddScheduleWindowPhases.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddScheduleWindowPhases1862000000000 implements MigrationInterface { + name = "AddScheduleWindowPhases1862000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN window_phase varchar(20) NULL, + ADD COLUMN window_opens_at timestamptz NULL, + ADD COLUMN window_closes_at timestamptz NULL, + ADD COLUMN doc_review_ends_at timestamptz NULL, + ADD COLUMN doc_review_completed_at timestamptz NULL, + ADD COLUMN payment_phase_ends_at timestamptz NULL, + ADD COLUMN booking_cycle_no integer NOT NULL DEFAULT 0; + `); + await queryRunner.query(` + CREATE INDEX idx_train_schedules_window_phase + ON freight.train_schedules (window_phase) + WHERE window_phase IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_window_phase;`); + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS window_phase, + DROP COLUMN IF EXISTS window_opens_at, + DROP COLUMN IF EXISTS window_closes_at, + DROP COLUMN IF EXISTS doc_review_ends_at, + DROP COLUMN IF EXISTS doc_review_completed_at, + DROP COLUMN IF EXISTS payment_phase_ends_at, + DROP COLUMN IF EXISTS booking_cycle_no; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts new file mode 100644 index 000000000..779807a57 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1863000000000-CreateBookingBatchOffers.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateBookingBatchOffers1863000000000 implements MigrationInterface { + name = "CreateBookingBatchOffers1863000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE freight.booking_batch_offers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + train_schedule_id uuid NOT NULL REFERENCES freight.train_schedules(id) ON DELETE CASCADE, + offered_wagons integer NOT NULL, + total_wagons integer NOT NULL, + offered_lines jsonb NULL, + offered_weight_tons numeric(12, 3) NOT NULL, + offered_amount numeric(14, 2) NOT NULL, + offered_pricing_breakdown jsonb NULL, + invoice_id uuid NULL, + payment_deadline timestamptz NOT NULL, + status varchar(10) NOT NULL DEFAULT 'OFFERED', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ); + `); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_booking ON freight.booking_batch_offers (booking_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_schedule ON freight.booking_batch_offers (train_schedule_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_booking_batch_offers_status ON freight.booking_batch_offers (status);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts new file mode 100644 index 000000000..1bd3bbc27 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddEmailToOtpVerifications.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Support email as a second OTP channel alongside phone (e.g. signup lets the + * user choose which one to verify). `phone` becomes nullable since an + * email-channel row has none, and `email` is added as a nullable unique column + * mirroring `phone`'s shape. + */ +export class AddEmailToOtpVerifications1900000000000 + implements MigrationInterface +{ + name = "AddEmailToOtpVerifications1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone DROP NOT NULL + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ADD COLUMN IF NOT EXISTS email varchar UNIQUE + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE public.otp_verifications + DROP COLUMN IF EXISTS email + `); + await queryRunner.query(` + ALTER TABLE public.otp_verifications + ALTER COLUMN phone SET NOT NULL + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts new file mode 100644 index 000000000..6c7235e3b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-AddLoadingStatusToTrainScheduleBookings.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Track per-booking loading confirmation (LOADED/UNLOADED) on train_schedule_bookings. + * Tracking only — does not gate dispatch. + */ +export class AddLoadingStatusToTrainScheduleBookings1900000000000 + implements MigrationInterface +{ + name = "AddLoadingStatusToTrainScheduleBookings1900000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + ADD COLUMN IF NOT EXISTS loading_status varchar(20) NOT NULL DEFAULT 'UNLOADED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedule_bookings + DROP COLUMN IF EXISTS loading_status + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts new file mode 100644 index 000000000..7a661bc7c --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1900000000000-SimplifyRatesAndWeightLimitRules.ts @@ -0,0 +1,126 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Simplify the rate + weight-limit configuration model: + * + * 1. Drop the effective_from / effective_to validity window from both + * `rates` and `weight_limit_rules`. Rates are now activated purely by + * the approval workflow (status = LIVE) and weight limits are always + * active for their container + direction. No time-travel scheduling. + * + * 2. Enforce "one rate per pattern" with partial unique indexes so the same + * configuration (e.g. FIRST_MILE for a given container type) cannot be + * duplicated. NULL scope columns are COALESCE-normalised because Postgres + * treats NULLs as distinct in a plain unique index. + * + * This migration is destructive on the date columns — existing effective_* + * values are dropped. + */ +export class SimplifyRatesAndWeightLimitRules1900000000000 implements MigrationInterface { + name = 'SimplifyRatesAndWeightLimitRules1900000000000'; + + public async up(queryRunner: QueryRunner): Promise { + // ── 1. De-duplicate existing data so the unique indexes can be created ── + // Keep the most recently-created row per pattern, soft-delete the rest. + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.rates + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED' + ) + UPDATE freight.rates r + SET deleted_at = now() + FROM ranked + WHERE r.id = ranked.id AND ranked.rn > 1; + `); + + await queryRunner.query(` + WITH ranked AS ( + SELECT id, + row_number() OVER ( + PARTITION BY container_type_id, trade_direction + ORDER BY created_at DESC, id DESC + ) AS rn + FROM freight.weight_limit_rules + WHERE deleted_at IS NULL + ) + UPDATE freight.weight_limit_rules w + SET deleted_at = now() + FROM ranked + WHERE w.id = ranked.id AND ranked.rn > 1; + `); + + // ── 2. Drop the effective-date indexes + columns ─────────────────────── + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_rates_effective_from";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_weight_limit_rules_effective_from";`); + // Indexes created by TypeORM's @Index carry generated hashed names — drop + // any index that references the effective_from column defensively. + await queryRunner.query(` + DO $$ + DECLARE idx record; + BEGIN + FOR idx IN + SELECT indexname FROM pg_indexes + WHERE schemaname = 'freight' + AND tablename IN ('rates', 'weight_limit_rules') + AND indexdef ILIKE '%effective_from%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS freight.%I', idx.indexname); + END LOOP; + END $$; + `); + + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.rates DROP COLUMN IF EXISTS effective_to;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_from;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules DROP COLUMN IF EXISTS effective_to;`); + + // ── 3. One-rate-per-pattern partial unique indexes ───────────────────── + // The unit is part of the identity so a surcharge can legitimately carry two + // rows that bill different ways (e.g. reefer PER_CONTAINER + reefer PER_TON), + // while still blocking a true duplicate (same rateType + scope + unit). + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_rates_pattern" + ON freight.rates ( + rate_type, + COALESCE(container_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(cargo_type_id, '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE(trade_direction, ''), + rate_unit + ) + WHERE deleted_at IS NULL AND status <> 'SUPERSEDED'; + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_weight_limit_rules_pattern" + ON freight.weight_limit_rules (container_type_id, trade_direction) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_rates_pattern";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_weight_limit_rules_pattern";`); + + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`UPDATE freight.rates SET effective_from = COALESCE(effective_from, created_at::date);`); + await queryRunner.query(`ALTER TABLE freight.rates ALTER COLUMN effective_from SET NOT NULL;`); + await queryRunner.query(`ALTER TABLE freight.rates ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_from date;`); + await queryRunner.query(`ALTER TABLE freight.weight_limit_rules ADD COLUMN IF NOT EXISTS effective_to date;`); + + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_rates_effective_from" ON freight.rates (effective_from);`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_weight_limit_rules_effective_from" ON freight.weight_limit_rules (effective_from);`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 7da5e2d66..b9f0a74c0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,21 +1,31 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; +import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @ApiTags("billing") @Controller("billing") -@FreightAdmin() +@BookingView() @ApiBearerAuth() export class BillingController { - constructor(private readonly billingService: BillingService) { } + constructor(private readonly billingService: BillingService) {} @Get("invoices") - @ApiOperation({ summary: "List all invoices" }) - findAll() { - return this.billingService.findAll(); + @ApiOperation({ + summary: "List invoices (paginated, filterable by company/status/search)", + }) + findAll(@Query() query: FilterInvoiceDto) { + return this.billingService.findAllPaginated(query); } @Get("invoices/:id") diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 82a47fa29..536129122 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -125,7 +125,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, - ) { } + ) {} // ── Reads ────────────────────────────────────────────────────────────────── @@ -134,9 +134,56 @@ export class BillingService { return this.invoices.findAll({ order: { issuedAt: "DESC" } }); } + /** + * Paginated invoice list for the backoffice — optionally narrowed to a + * company (customer detail "Invoices" tab) and/or status/search (global + * invoices page). + */ + async findAllPaginated( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ items: Invoice[]; total: number }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + return { items, total }; + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { - const invoice = await this.invoices.findById(id); + const invoice = await this.invoices.findById(id, { + relations: { company: true, companyProfile: true }, + }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, @@ -375,7 +422,7 @@ export class BillingService { input.dueAt ?? new Date( Date.now() + - (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, + (input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000, ); const invoiceNumber = await this.nextInvoiceNumber(mg); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts new file mode 100644 index 000000000..e8942d586 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -0,0 +1,42 @@ +import { Freight } from "@edr/types"; +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { Transform } from "class-transformer"; +import { + IsIn, + IsInt, + IsOptional, + IsString, + IsUUID, + Min, +} from "class-validator"; + +export class FilterInvoiceDto { + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ default: 20 }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + pageSize?: number = 20; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + companyId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ enum: Freight.InvoiceStatus }) + @IsOptional() + @IsIn(Object.values(Freight.InvoiceStatus)) + status?: Freight.InvoiceStatus; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts index cfb9887c3..bb159c538 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-allocation.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { BookingsService } from './bookings.service'; import { AllocateContainersDto } from './dto/allocate-containers.dto'; +import { AllocationManage } from '../../common/booking-guards'; @ApiTags('bookings') @Controller('bookings') @@ -10,6 +11,7 @@ export class BookingAllocationController { constructor(private readonly bookingsService: BookingsService) {} @Post(':bookingId/allocate-containers') + @AllocationManage() @ApiOperation({ summary: 'Allocate containers to vehicles' }) async allocateContainers( @Param('bookingId', ParseUUIDPipe) bookingId: string, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 1c1b490dd..db6b70eae 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, exchangeService as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index c5e5b710e..e8469e627 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -17,6 +17,14 @@ import { import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; import { Booking } from './entities/booking.entity'; import { assertBookingStatus } from './booking-status.util'; +import { ContainerValidationService } from './container-validation.service'; + +export interface OverweightLine { + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; +} export interface ComputedPriceResult { lineItems: PriceLineItemDto[]; @@ -27,6 +35,7 @@ export interface ComputedPriceResult { priorityScore: number; warnings: string[]; hardBlocked: string[]; + overweightLines: OverweightLine[]; } type StoredPricingBreakdown = { @@ -67,6 +76,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, + private readonly containerValidationService: ContainerValidationService, ) {} async generatePrice(bookingId: string): Promise { @@ -94,12 +104,19 @@ export class BookingPricingService { }, } as never); + // 20ft weight-pairing preview: surfaced now so the customer sees the problem + // (and the overweight warning + surcharge) at the confirm step, before submit. + // Submit re-runs this and HARD-BLOCKS on a non-empty result. + const pairing = await this.containerValidationService.validate20ftPairing(booking); + return { bookingId, totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors: pairing.map((p) => p.message), }; } @@ -169,6 +186,35 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Overweight detail for the customer: map the engine's per-line results back + // to the booking's container lines (same order) for code + weights. maxAllowed + // is derived from the line total minus the excess the engine computed. + const overweightLines: OverweightLine[] = []; + const containerLines = (booking.bookingContainers ?? []).filter( + (bc) => bc.containerTypeId != null, + ); + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const line = containerLines[i]; + const totalVgmTons = Number(line?.totalVgmTons ?? 0); + const excessTons = Number(wr.overweightExcessTons ?? 0); + let code = line?.containerSize ?? ''; + if (line?.containerTypeId) { + try { + code = (await this.containerTypesService.findById(line.containerTypeId)).code; + } catch { + // fall back to the container size label + } + } + overweightLines.push({ + containerTypeCode: code, + totalVgmTons, + maxAllowedTons: Math.max(0, totalVgmTons - excessTons), + excessTons, + }); + } + return { lineItems, totalAmount: total, @@ -178,6 +224,7 @@ export class BookingPricingService { priorityScore: ruleResult.priorityScore, warnings: ruleResult.warnings, hardBlocked: ruleResult.hardBlocked, + overweightLines, }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts index f9b160672..3c535f450 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.accept.spec.ts @@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, ruleEngineService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index d62883d53..9f9aa5713 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository }; } @@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, filesService }; } diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts index 66df02ac4..ea3618a08 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => { bookingsService as never, { isPhasedGeneralCustomsBooking: () => false } as never, {} as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, ); return { service, bookingsRepository, bookingBatchService }; } 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 898f78cd5..3e1ea3cd4 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 @@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; import { BookingContractService } from './booking-contract.service'; import { BookingPricingService } from './booking-pricing.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsRepository } from './bookings.repository'; import { assertBookingStatus } from './booking-status.util'; import { clearanceCodesForBooking } from './clearance.util'; @@ -51,6 +52,7 @@ export class BookingTransitionService { @Inject(forwardRef(() => ClearanceWorkflowService)) private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, + private readonly containerValidationService: ContainerValidationService, ) {} @@ -58,6 +60,19 @@ export class BookingTransitionService { return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking); } + /** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */ + private async assert20ftPairable(booking: Booking): Promise { + const violations = + await this.containerValidationService.validate20ftPairing(booking); + if (violations.length) { + throw new BadRequestException( + `Cannot submit — 20ft containers cannot be paired on wagons: ${violations + .map((v) => v.message) + .join(' ')}`, + ); + } + } + async submit(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]); @@ -78,6 +93,11 @@ export class BookingTransitionService { requiresDirectorApproval: false, }); + // 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap. + // If no balanced pairing exists the booking cannot proceed (overweight only + // warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation. + await this.assert20ftPairable(booking); + const stored = booking.pricingBreakdown as { lineItems?: PriceLineItemDto[]; totalAmount?: number; @@ -158,6 +178,7 @@ export class BookingTransitionService { hardBlocked: computed.hardBlocked, requiresDirectorApproval: false, }); + await this.assert20ftPairable(booking); await this.pricingService.createPricingSnapshots( bookingId, @@ -991,6 +1012,17 @@ export class BookingTransitionService { private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + // Export is FCFS: fail the accept up-front (409) when no export train on the + // booking's day still has capacity — nothing below runs and the request stays + // pending for staff to move/decline. (For a consolidated pair this is a rough + // solo pre-check; the real combined-capacity reservation happens after the + // booking is FULLY_EXECUTED, once both partners are ready.) + const isExportTrain = + booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType); + if (isExportTrain) { + await this.bookingBatchService.pickExportSchedule(booking); + } + const invoice = await this.invoiceService.ensureInvoiceForBooking(booking); this.logger.log( `Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`, @@ -1014,7 +1046,16 @@ export class BookingTransitionService { lockedAt: booking.lockedAt ?? now, } as never); - if (booking.scheduledDate) { + if (isExportTrain) { + // FCFS: reserve the slot and send the payment notification immediately; + // paid → auto-allocated by the settle/paid pipeline. Consolidated bookings + // only reserve once both partners are FULLY_EXECUTED (handled inside). + const fresh = await this.bookingsService.findById(booking.id); + await this.bookingBatchService.acceptExportBooking(fresh); + } else if (booking.tradeDirection === "IMPORT") { + // Import bookings wait for their booking-day window cycle — the batch runs + // after staff document review, never at accept time. + } else if (booking.scheduledDate) { this.bookingBatchService.enqueueRouteDayProcessing( booking.originYardId, booking.destinationYardId, @@ -1029,6 +1070,12 @@ export class BookingTransitionService { latestChangeRequestNote?: string | null; contractSummary?: string | null; nextStep: BookingNextStep | null; + activeBatchOffer?: { + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null; } > { const note = await this.bookingsRepository.findLatestReviewNote( @@ -1044,11 +1091,16 @@ export class BookingTransitionService { ? await this.bookingsRepository.findNextPendingApprovalStep(booking.id) : null; const nextStep = computeNextStep(booking, nextPending); + const activeBatchOffer = + booking.status === "SELECTED_FOR_BATCH" + ? await this.bookingBatchService.getOpenOfferSummary(booking.id) + : null; return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, + activeBatchOffer, }; } } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 688c6ee18..c5574377a 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -617,12 +617,14 @@ export class BookingsController { async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, file, resolveAuthUserId(user), + vesselDepartureDate, ); return this.transitionService.enrichBookingResponse(booking); } diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index b54b6b49e..92790c273 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller'; // import { PayController } from './pay.controller'; import { BookingsRepository } from './bookings.repository'; import { ConsolidationService } from './consolidation.service'; +import { ContainerValidationService } from './container-validation.service'; import { BookingsService } from './bookings.service'; import { BookingApprovalStep } from './entities/booking-approval-step.entity'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; @@ -79,6 +80,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, ConsolidationService, + ContainerValidationService, BookingReferenceDataService, BookingPricingService, BookingTransitionService, 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 b99611c35..3f696bc6b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository { /** * Find another booking whose container quantity complements this one to fill whole wagon(s) - * (same route, same container type, partial wagon on both sides). + * (same route, same container type, partial wagon on both sides). Only 20ft lines ever + * reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial. + * + * Partners must also ride the SAME booking day: consolidation shares one physical wagon, + * and the window/batch pool is keyed on the EAT departure day, so a pair that can't board + * the same train is useless. The day filter is applied only when THIS booking already has + * a scheduled_date (draft bookings without a date match on route/type alone until they pick one). */ async findComplementaryConsolidationPartner( booking: Booking, @@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository { ): Promise { const { containerTypeId, quantity, containersPerWagon: perWagon } = slot; - return this.repository + const qb = this.repository .createQueryBuilder('b') .innerJoinAndSelect('b.bookingContainers', 'bc') .innerJoin('bc.containerType', 'ct') @@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository { .andWhere('((:quantity + bc.quantity) % :perWagon) = 0', { quantity, perWagon, - }) - .orderBy('b.createdAt', 'ASC') - .getOne(); + }); + + // Same EAT booking day, so the pair can share a wagon on one train. Skip only + // when this booking has no date yet (matched again once it picks its day). + if (booking.scheduledDate) { + qb.andWhere( + `DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`, + { bookingDate: booking.scheduledDate }, + ); + } + + return qb.orderBy('b.createdAt', 'ASC').getOne(); } /** Try each partial-wagon line until a complementary partner booking is found. */ diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts new file mode 100644 index 000000000..6aed9bf1a --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.spec.ts @@ -0,0 +1,55 @@ +import { validate20ftWeightPairing } from './container-pairing.util'; + +describe('validate20ftWeightPairing', () => { + const MAX_DIFF = 10; + + it('passes when a balanced pairing exists (adjacent diffs within cap)', () => { + // sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10. + const units = [ + { label: 'A', grossWeightTons: 24 }, + { label: 'B', grossWeightTons: 8 }, + { label: 'C', grossWeightTons: 18 }, + { label: 'D', grossWeightTons: 15 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('flags a pair whose weight difference exceeds the cap', () => { + // sorted: 5, 25 → single pair diff 20 > 10. + const units = [ + { label: 'HEAVY', grossWeightTons: 25 }, + { label: 'LIGHT', grossWeightTons: 5 }, + ]; + const result = validate20ftWeightPairing(units, MAX_DIFF); + expect(result).toHaveLength(1); + expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']); + expect(result[0].diffTons).toBe(20); + }); + + it('allows an odd leftover unit (goes to consolidation, not a violation)', () => { + // sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover. + const units = [ + { label: 'A', grossWeightTons: 10 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 30 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => { + // Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail), + // but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation. + const units = [ + { label: 'A', grossWeightTons: 20 }, + { label: 'B', grossWeightTons: 12 }, + { label: 'C', grossWeightTons: 22 }, + { label: 'D', grossWeightTons: 10 }, + ]; + expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]); + }); + + it('returns nothing for fewer than two units', () => { + expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]); + expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts new file mode 100644 index 000000000..cf1cfe944 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-pairing.util.ts @@ -0,0 +1,64 @@ +/** + * Booking-time 20ft weight-pairing rule. + * + * A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the + * same wagon their gross-weight difference must not exceed `maxPairDiffTons` + * (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays + * balanced. 40ft containers occupy a whole wagon alone and never pair. + * + * At booking time the customer enters every 20ft container's weight but not its + * wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent + * (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY + * valid pairing exists this one finds it — a violation here means no balanced + * pairing is possible and the booking must be blocked. An odd leftover 20ft is + * fine: it has no partner in this booking and flows to consolidation. + */ + +export interface Container20ftUnit { + /** Human label for messages, e.g. the container number. */ + label: string; + grossWeightTons: number; +} + +export interface PairingViolation { + message: string; + /** The two container labels whose pairing exceeds the diff cap. */ + labels: [string, string]; + diffTons: number; +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Validate that the given 20ft units can all be paired onto wagons within the + * weight-difference cap. Returns one violation per over-cap adjacent pair (empty + * when every wagon pair is balanced or there is nothing to pair). A single + * leftover unit (odd count) is not a violation. + */ +export function validate20ftWeightPairing( + units: Container20ftUnit[], + maxPairDiffTons: number, +): PairingViolation[] { + if (units.length < 2 || maxPairDiffTons == null) return []; + + // Ascending by weight: adjacent pairs have the smallest possible diffs. + const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons); + const violations: PairingViolation[] = []; + + for (let i = 0; i + 1 < sorted.length; i += 2) { + const a = sorted[i]; + const b = sorted[i + 1]; + const diff = Math.abs(a.grossWeightTons - b.grossWeightTons); + if (diff > maxPairDiffTons) { + violations.push({ + message: + `20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` + + `${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` + + `weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`, + labels: [a.label, b.label], + diffTons: round2(diff), + }); + } + } + return violations; +} diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts new file mode 100644 index 000000000..de4dca661 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, In } from 'typeorm'; + +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { Booking } from './entities/booking.entity'; +import { BookingContainerUnit } from './entities/booking-container-unit.entity'; +import { + Container20ftUnit, + PairingViolation, + validate20ftWeightPairing, +} from './container-pairing.util'; + +/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */ +const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10; + +/** + * Booking-time container validations that need the customer-entered per-unit + * weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the + * rule engine (which works on line totals) because pairing is per physical unit. + */ +@Injectable() +export class ContainerValidationService { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + private async maxPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const v = row?.max20ftPairWeightDiffTons; + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS; + } + + /** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */ + private async load20ftUnits(booking: Booking): Promise { + const lines = (booking.bookingContainers ?? []).filter( + (bc) => (bc.containerSize ?? '').includes('20'), + ); + if (!lines.length) return []; + + const units = await this.dataSource + .getRepository(BookingContainerUnit) + .find({ + where: { bookingContainerId: In(lines.map((l) => l.id)) }, + order: { sortOrder: 'ASC' }, + }); + + return units.map((u) => ({ + label: u.containerNumber || u.id.slice(0, 8), + grossWeightTons: Number(u.vgmTons ?? 0), + })); + } + + /** + * Validate the 20ft weight-pairing rule for a booking. Returns one message per + * pair whose weight difference exceeds the cap; empty when all 20ft can be + * balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine — + * it flows to consolidation. Callers hard-block a non-empty result. + */ + async validate20ftPairing(booking: Booking): Promise { + // Only bookings whose 20ft lines actually carry per-unit weights can be + // checked; contract-drawdown bookings do (units are required there). + const containerLines = booking.bookingContainers ?? []; + const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); + if (!has20ft) return []; + + const units = await this.load20ftUnits(booking); + if (units.length < 2) return []; + + const maxDiff = await this.maxPairDiffTons(); + return validate20ftWeightPairing(units, maxDiff); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts index 532d6b1a7..0ee77aeed 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/generate-price-response.dto.ts @@ -27,6 +27,20 @@ export class PriceLineItemDto { currency!: string; } +export class OverweightLineDto { + @ApiProperty() + containerTypeCode!: string; + + @ApiProperty() + totalVgmTons!: number; + + @ApiProperty() + maxAllowedTons!: number; + + @ApiProperty() + excessTons!: number; +} + export class GeneratePriceResponseDto { @ApiProperty() bookingId!: string; @@ -42,4 +56,16 @@ export class GeneratePriceResponseDto { @ApiProperty({ type: [String] }) warnings!: string[]; + + /** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */ + @ApiProperty({ type: [OverweightLineDto] }) + overweightLines!: OverweightLineDto[]; + + /** + * 20ft weight-pairing violations. Non-empty means the booking cannot be + * balanced onto wagons and submit is HARD-BLOCKED — the customer must fix + * container weights/quantities. (Overweight, by contrast, only warns.) + */ + @ApiProperty({ type: [String] }) + pairingErrors!: string[]; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 4e9a7b69d..3f169c49c 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,5 +1,11 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -66,6 +72,21 @@ export interface BookingClearanceView { workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until wagon allocation). */ t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -175,6 +196,20 @@ export class BookingClearanceService { } } + let train: ClearanceTrainState | null = null; + try { + train = await this.glOperationsService.trainState(bookingId); + } catch { + train = null; + } + const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); + const bookingMilestone = (code: string) => + milestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState(milestones, files); + return { bookingId, status: booking.status, @@ -206,6 +241,33 @@ export class BookingClearanceService { dutyAdvice, workflowFiles, t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -302,6 +364,12 @@ export class BookingClearanceService { : ContractDocPhase.CustomerDuty, } as never); + // Export: the declaration is the last GL ET pre-operation action — release + // immediately so the customer can proceed without a separate confirm click. + if (tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleasedForBooking(bookingId, userId); + } + return this.bookingsService.findById(bookingId); } @@ -449,6 +517,7 @@ export class BookingClearanceService { bookingId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const booking = await this.loadBooking(bookingId); if (booking.tradeDirection !== 'IMPORT') { @@ -467,6 +536,12 @@ export class BookingClearanceService { file, }); + if (vesselDepartureDate?.trim()) { + await this.bookingsRepository.update(bookingId, { + vesselDepartureDate: vesselDepartureDate.trim(), + } as never); + } + if (booking.preClearanceFinalizedAt) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForOperation(bookingId); 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 index ce6f7bea4..648d9666f 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.catalog.ts @@ -39,6 +39,16 @@ const IMPORT_DEFS: Record> = { OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false }, T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false }, RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false }, + SECOND_DUTY_ADVISED: { + label: 'Additional Duty and Taxes Advised', + ownerRegion: 'ET', + triggeredByDoc: false, + }, + SECOND_DUTY_PAID: { + label: 'Additional Duty and Tax Paid', + ownerRegion: 'CUST', + triggeredByDoc: true, + }, IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true }, IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true }, STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false }, @@ -68,6 +78,7 @@ const EXPORT_DEFS: Record> = { DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false }, ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false }, GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false }, + T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false }, OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true }, }; 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 index 7d30b1c5b..e033f8e9b 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -91,6 +91,39 @@ export class ClearanceMilestoneService { }); } + /** + * Find-or-create a post-booking milestone row from the catalog. Needed for codes + * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). + */ + async ensureForBooking( + bookingId: string, + code: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); + if (existing) return existing; + + const { postBooking } = splitMilestones(tradeDirection); + const idx = postBooking.findIndex((d) => d.code === code); + if (idx < 0) { + throw new NotFoundException( + `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, + ); + } + const def = postBooking[idx]!; + return this.repo.save( + this.repo.create({ + bookingId, + milestoneCode: def.code, + milestoneLabel: def.label, + ownerRegion: def.ownerRegion, + triggeredByDoc: def.triggeredByDoc, + status: 'PENDING', + sortOrder: idx, + }), + ); + } + /** Mark a milestone complete (by code) on a booking. */ async completeForBooking( bookingId: string, 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 index b0a5cc636..01b35fc71 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,11 +1,14 @@ import { BadRequestException, ForbiddenException, + Inject, Injectable, Logger, NotFoundException, + forwardRef, } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { ExchangeService } from '@edr/api-common'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; +import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.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'; @@ -60,6 +66,9 @@ export class ContractBookingService { private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, private readonly dataSource: DataSource, + private readonly exchangeService: ExchangeService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, ) {} async createUnderContract( @@ -111,6 +120,20 @@ export class ContractBookingService { const generalCustoms = contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + // Booking-window gate (config-driven): an operations booking may only be + // created while the route's booking window is open — import: the day's window + // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); + // export: within exportBookingLeadHours of departure. Customs Path B bookings + // enter clearance first and are scheduled later, so they are not gated here. + if (!generalCustoms) { + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + scheduledDate: dto.scheduledDate ?? null, + direction: contract.tradeDirection ?? null, + }); + } + // Denormalize route/direction/freight onto the booking for the scheduling engine. const booking = await this.bookingsRepository.create({ reference, @@ -563,6 +586,145 @@ export class ContractBookingService { } } + /** + * Pre-create validation for the shipment form: run the overweight rule + the + * 20ft weight-pairing rule against the entered containers WITHOUT persisting a + * booking. The portal calls this from the price-confirm modal so the customer + * sees the overweight warning (+ surcharge basis) and is blocked on an + * un-pairable 20ft set before the booking is created. + */ + async validateShipment( + contractId: string, + dto: CreateBookingUnderContractDto, + ): Promise<{ + overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }>; + overweightSurchargeAmount: number; + currency: string | null; + pairingErrors: string[]; + }> { + const contract = await this.contractsRepository.findByIdWithRelations(contractId); + if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); + + const lines = dto.containers ?? []; + if (!lines.length) { + return { + overweightLines: [], + overweightSurchargeAmount: 0, + currency: null, + pairingErrors: [], + }; + } + + // Resolve each line's container type + total VGM (sum of unit weights) so the + // rule engine can flag overweight per line (maxVgmTons × quantity vs total). + const resolved = await Promise.all( + lines.map(async (line) => { + const ct = await this.resolveContainerTypeForSize( + line.containerSize, + contract.isReefer || (line.reeferQuantity ?? 0) > 0, + ); + const totalVgmTons = (line.units ?? []).reduce( + (s, u) => s + Number(u.vgmTons ?? 0), + 0, + ); + return { line, ct, totalVgmTons }; + }), + ); + + const ruleResult = await this.ruleEngineService.evaluate({ + freightType: 'CONTAINER', + cargoTypeId: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + tradeDirection: contract.tradeDirection, + isHazardous: false, + isReefer: contract.isReefer ?? false, + isGovernment: false, + allowConsolidation: false, + shippingLineId: null, + totalWagons: 0, + bulkTons: 0, + containers: resolved.map((r) => ({ + containerTypeId: r.ct.id, + quantity: r.line.quantity, + vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0, + totalVgmTons: r.totalVgmTons, + isReefer: r.ct.isReefer, + })), + } as never); + + const overweightLines: Array<{ + containerTypeCode: string; + totalVgmTons: number; + maxAllowedTons: number; + excessTons: number; + }> = []; + for (let i = 0; i < ruleResult.containerWeightResults.length; i++) { + const wr = ruleResult.containerWeightResults[i]; + if (!wr?.isOverweight) continue; + const r = resolved[i]; + const excessTons = Number(wr.overweightExcessTons ?? 0); + overweightLines.push({ + containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '', + totalVgmTons: r?.totalVgmTons ?? 0, + maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons), + excessTons, + }); + } + + // 20ft weight-pairing: gather every 20ft unit weight and check the pair rule. + const twentyFtUnits = resolved + .filter((r) => (r.line.containerSize ?? '').includes('20')) + .flatMap((r) => + (r.line.units ?? []).map((u, idx) => ({ + label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`, + grossWeightTons: Number(u.vgmTons ?? 0), + })), + ); + const maxDiff = await this.max20ftPairDiffTons(); + const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map( + (v) => v.message, + ); + + // Real overweight surcharge (same rate the rule engine bills at booking-create + // time) so the confirm-modal total isn't missing the charge the warning refers to. + // Rates are stored in USD; convert to the contract's payment currency the same + // way BookingPricingService does so this preview matches the eventual booking total. + const overweightModifier = ruleResult.appliedModifiers.find( + (m) => m.surchargeCode === 'OVERWEIGHT_PER_TON', + ); + let overweightSurchargeAmount = 0; + if (overweightModifier) { + const isEtb = contract.paymentCurrency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + overweightSurchargeAmount = isEtb + ? Math.round(overweightModifier.calculatedAmount * usdToEtb) + : overweightModifier.calculatedAmount; + } + + return { + overweightLines, + overweightSurchargeAmount, + currency: overweightLines.length ? contract.paymentCurrency : null, + pairingErrors, + }; + } + + private async max20ftPairDiffTons(): Promise { + const row = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ order: { createdAt: 'ASC' }, take: 1 }) + .then((rows) => rows[0] ?? null) + .catch(() => null); + const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons); + return Number.isFinite(n) ? n : 10; + } + /** Pick the default container type for a size; prefer reefer when requested. */ private async resolveContainerTypeForSize( size: string, 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 index 34e3d3d94..73a4fe117 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -1,5 +1,11 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; -import { ContractDocPhase, type ClearanceT1State } from '@edr/types'; +import { + ContractDocPhase, + type ClearanceFinalInvoiceSummary, + type ClearanceSecondDuty, + type ClearanceT1State, + type ClearanceTrainState, +} from '@edr/types'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -80,6 +86,21 @@ export interface ContractClearanceView { workflowFiles?: ReturnType; /** Import post-allocation T1 transit document state (null until a booking is linked). */ t1?: ClearanceT1State | null; + /** Train link state for the booking (both directions; null until a booking is linked). */ + train?: ClearanceTrainState | null; + gatepassGranted?: boolean; + gatepassAt?: string | null; + t1Closed?: boolean; + t1ClosedAt?: string | null; + offloaded?: boolean; + /** GL Djibouti post-offload final invoice (export). */ + finalInvoice?: ClearanceFinalInvoiceSummary | null; + /** Customs risk level assigned by GL ET (import; visible to the customer). */ + riskLevel?: string | null; + riskAssignedAt?: string | null; + /** Post-arrival additional duty/tax round (import). */ + secondDuty?: ClearanceSecondDuty | null; + importReleaseGranted?: boolean; } @Injectable() @@ -212,8 +233,9 @@ export class ContractClearanceService { files, contract.tradeDirection ?? 'IMPORT', ); + let bookingFiles: Awaited> = []; if (cycle?.bookingId) { - const bookingFiles = await this.filesService.findByResource( + bookingFiles = await this.filesService.findByResource( cycle.bookingId, 'bookings', ); @@ -237,11 +259,32 @@ export class ContractClearanceService { } } - let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); - if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { - const bookingMilestones = await this.workflowService.listMilestonesForBooking( + let train: ClearanceTrainState | null = null; + let bookingMilestones: ClearanceMilestone[] = []; + let finalInvoice: ClearanceFinalInvoiceSummary | null = null; + if (cycle?.bookingId) { + try { + train = await this.glOperationsService.trainState(cycle.bookingId); + } catch { + train = null; + } + bookingMilestones = await this.workflowService.listMilestonesForBooking( cycle.bookingId, ); + finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId); + } + const bookingMilestone = (code: string) => + bookingMilestones.find((m) => m.milestoneCode === code); + const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); + const riskMilestone = bookingMilestone('RISK_ASSIGNED'); + const secondDuty = this.glOperationsService.secondDutyState( + bookingMilestones, + bookingFiles, + ); + + let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones); + if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { nextAction = this.workflowService.computeNextActionForBooking( @@ -286,6 +329,33 @@ export class ContractClearanceService { dutyAdvice, workflowFiles, t1, + train, + gatepassGranted: gatepassMilestone?.status === 'COMPLETED', + gatepassAt: + gatepassMilestone?.status === 'COMPLETED' + ? (gatepassMilestone.metadata?.gatepassAt ?? + (gatepassMilestone.triggeredAt + ? gatepassMilestone.triggeredAt.toISOString() + : null)) + : null, + t1Closed: t1ClosedMilestone?.status === 'COMPLETED', + t1ClosedAt: + t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt + ? t1ClosedMilestone.triggeredAt.toISOString() + : null, + offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED', + finalInvoice, + riskLevel: + riskMilestone?.status === 'COMPLETED' + ? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null) + : null, + riskAssignedAt: + riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt + ? riskMilestone.triggeredAt.toISOString() + : null, + secondDuty, + importReleaseGranted: + bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', }; } @@ -873,6 +943,12 @@ export class ContractClearanceService { }); } + // Export: the declaration is the last GL ET pre-booking action — release + // immediately so booking creation unlocks without a separate confirm click. + if (contract.tradeDirection === 'EXPORT') { + await this.workflowService.onExportReleased(contractId, userId); + } + return this.contractsService.findById(contractId); } @@ -1039,6 +1115,7 @@ export class ContractClearanceService { contractId: string, file: Express.Multer.File, userId?: string, + vesselDepartureDate?: string, ): Promise { const contract = await this.contractsService.findById(contractId); this.assertPhasedCustoms(contract); @@ -1059,6 +1136,11 @@ export class ContractClearanceService { }); const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle && vesselDepartureDate?.trim()) { + await this.contractsRepository.updateCycle(cycle.id, { + vesselDepartureDate: vesselDepartureDate.trim(), + }); + } if (cycle?.preClearanceFinalizedAt) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId); await this.workflowService.markReadyForBooking(contractId); 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 index e87112bc2..9bb4b2de6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; +import { OtpService } from '../otp/otp.service'; import { ContractPricingService } from './contract-pricing.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -63,6 +64,7 @@ export class ContractTransitionService { private readonly renderer: ContractRendererService, private readonly pdfService: ContractPdfService, private readonly minioService: MinioService, + private readonly otpService: OtpService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -520,6 +522,12 @@ export class ContractTransitionService { if (existing) { throw new BadRequestException('Customer has already signed this contract'); } + // Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone) + // must be verified before the signature is applied. + if (!dto.otpPhone || !dto.otp) { + throw new BadRequestException('OTP verification is required to sign the contract'); + } + await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp); await this.applySignature(contract, dto, options); await this.contractsRepository.update(contractId, { status: 'SIGNED_CUSTOMER', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 872d13a0c..09e4a7ffd 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,6 +77,7 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, + GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -610,9 +611,15 @@ export class ContractsController { uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File, + @Body('vesselDepartureDate') vesselDepartureDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user)); + return this.clearanceService.uploadDeliveryOrder( + id, + file, + resolveAuthUserId(user), + vesselDepartureDate, + ); } @Post(':id/clearance/release-order') @@ -681,6 +688,30 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } + @Get('clearance/dj-schedules') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) + djClearanceSchedules() { + return this.glOperationsService.djSchedules(); + } + + @Post('clearance/schedules/:scheduleId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', + }) + grantScheduleGatepass( + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantScheduleGatepass( + scheduleId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -757,6 +788,18 @@ export class ContractsController { ); } + @Post(':id/validate-shipment') + @ApiOperation({ + summary: + 'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).', + }) + validateShipment( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CreateBookingUnderContractDto, + ) { + return this.contractBookingService.validateShipment(id, dto); + } + @Get(':id/capacity') @ApiOperation({ summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)', @@ -889,9 +932,13 @@ export class ContractsController { } @Post('bookings/:bookingId/t1-close') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceEtActions, + FREIGHT_PERMS.contracts.clearanceDjActions, + ]) @ApiOperation({ - summary: 'GL Ethiopia closes (accepts) the T1 document set after the train arrives', + summary: + 'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)', }) closeT1( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -900,6 +947,116 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } + @Post('bookings/:bookingId/gatepass') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) + grantGatepass( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: GatepassDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.grantGatepass( + bookingId, + dto?.gatepassAt, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)', + }) + createFinalInvoice( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('amount') amountRaw: string, + @Body('currency') currency: string | undefined, + @Body('description') description: string | undefined, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.createFinalInvoice( + bookingId, + { + amount: Number(amountRaw), + currency: currency?.trim() || 'ETB', + description, + }, + file, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/final-invoice-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' }) + uploadFinalInvoiceSlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file); + } + + @Post('bookings/:bookingId/final-invoice/confirm') + @BookingStaff([ + FREIGHT_PERMS.contracts.clearanceDjActions, + FREIGHT_PERMS.contracts.clearanceEtActions, + ]) + @ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' }) + confirmFinalInvoicePaid( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.confirmFinalInvoicePaid( + bookingId, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @UseInterceptors(FileInterceptor('attachment')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ + summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)', + }) + adviseSecondDuty( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body('dutyRequired') dutyRequiredRaw: string, + @Body('amount') amountRaw: string | undefined, + @Body('currency') currency: string | undefined, + @Body('declarationSerial') declarationSerial: string | undefined, + @UploadedFile() attachment: Express.Multer.File | undefined, + @CurrentUser() user: AuthUserPayload, + ) { + return this.glOperationsService.adviseSecondDuty( + bookingId, + { + dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1', + amount: + amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined, + currency: currency ?? 'ETB', + declarationSerial, + }, + attachment, + resolveAuthUserId(user), + ); + } + + @Post('bookings/:bookingId/second-duty-slip') + @UseInterceptors(FileInterceptor('file')) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' }) + uploadSecondDutySlip( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.glOperationsService.uploadSecondDutySlip(bookingId, file); + } + @Post('bookings/:bookingId/documents') @BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 94f469112..a9f9dcf5d 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; +import { BillingModule } from '../billing/billing.module'; import { CompaniesModule } from '../companies/companies.module'; import { FilesModule } from '../files/files.module'; import { MinioModule } from '../minio/minio.module'; @@ -10,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; import { SignaturesModule } from '../signatures/signatures.module'; +import { OtpModule } from '../otp/otp.module'; import { BookingsModule } from '../bookings/bookings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; @@ -64,16 +67,22 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum Booking, BookingContainerUnit, ]), + BillingModule, RuleEngineModule, FileUploadSettingsModule, DropdownSettingsModule, FilesModule, MinioModule, SignaturesModule, + OtpModule, CompaniesModule, // BookingsModule provides BookingsRepository/BookingPricingService used by the // contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3). forwardRef(() => BookingsModule), + // TrainSchedulingModule provides the config-driven booking-window gate used + // by ContractBookingService.createUnderContract. forwardRef because + // TrainSchedulingModule already imports ContractsModule. + forwardRef(() => TrainSchedulingModule), ExchangeModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService): ExchangeOptions => diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index f48653822..6b784073b 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -35,3 +35,12 @@ export class RoAmendmentDto { @IsString() note?: string; } + +export class GatepassDto { + @ApiPropertyOptional({ + description: 'When the gate pass was granted (ISO datetime; defaults to now)', + }) + @IsOptional() + @IsString() + gatepassAt?: string; +} diff --git a/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/sign-contract.dto.ts index febe7a83b..f0676b629 100644 --- 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 @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsIn, IsOptional, IsString, MinLength } from 'class-validator'; +import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator'; export class SignContractDto { @ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] }) @@ -26,4 +26,19 @@ export class SignContractDto { @IsOptional() @IsString() consentText?: string; + + // Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code + // SMS'd to the signer's phone, verified server-side before the signature is + // applied. `otpPhone` is the number the code was sent to (the signed-in + // customer's registered phone). + @ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + @Matches(/^\d{6}$/, { message: 'otp must be 6 digits' }) + otp?: string; + + @ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' }) + @IsOptional() + @IsString() + otpPhone?: string; } 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 index 502afaf6a..d4676b8cd 100644 --- 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 @@ -23,6 +23,8 @@ export interface MilestoneMetadata { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; + /** When the gate pass was physically granted (GL DJ captures the time). */ + gatepassAt?: string; } /** diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index f900c564f..8fed3a8ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -1,14 +1,24 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { isT1TransportFileCode, type Freight } from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, In, IsNull } from 'typeorm'; +import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; +import { BillingService } from '../billing/billing.service'; +import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { FilesService } from '../files/files.service'; import { Booking } from '../bookings/entities/booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity'; import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; +import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { persistExportTransportUploads, @@ -43,6 +53,7 @@ export class GlOperationsService { private readonly dataSource: DataSource, private readonly filesService: FilesService, private readonly milestoneService: ClearanceMilestoneService, + private readonly billingService: BillingService, ) {} private get bookings() { @@ -167,12 +178,8 @@ export class GlOperationsService { return { uploaded: files.length, completedMilestones }; } - /** - * T1 transit-document lifecycle state for an import shipment booking. Wagon - * allocation opens the upload window; train departure locks it; train arrival - * lets GL Ethiopia close (accept) the T1 set. - */ - async t1State(bookingId: string): Promise { + /** Wagon-allocation + train-schedule actuals for a booking (both directions). */ + async trainState(bookingId: string): Promise { const booking = await this.getBooking(bookingId); const milestones = await this.milestoneService.listForBooking(bookingId); @@ -190,19 +197,35 @@ export class GlOperationsService { .findOne({ where: { id: booking.trainScheduleId } }); } + return { + wagonAllocated, + departedAt: schedule?.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + arrivedAt: schedule?.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + }; + } + + /** + * T1 transit-document lifecycle state for an import shipment booking. Wagon + * allocation opens the upload window; train departure locks it; train arrival + * lets GL Ethiopia close (accept) the T1 set. + */ + async t1State(bookingId: string): Promise { + const train = await this.trainState(bookingId); + const milestones = await this.milestoneService.listForBooking(bookingId); + const closedMilestone = milestones.find( (m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED', ); return { bookingId, - wagonAllocated, - trainDepartedAt: schedule?.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - trainArrivedAt: schedule?.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, + wagonAllocated: train.wagonAllocated, + trainDepartedAt: train.departedAt, + trainArrivedAt: train.arrivedAt, closed: Boolean(closedMilestone), closedAt: closedMilestone?.triggeredAt ? new Date(closedMilestone.triggeredAt).toISOString() @@ -243,38 +266,541 @@ export class GlOperationsService { } /** - * GL Ethiopia closes (accepts) the T1 document set once the train has arrived. - * Completes the T1_CLOSED milestone; the document set becomes final. + * Close (accept) the T1/transport document set. + * Import: GL Ethiopia closes once the train has arrived (T1 files required). + * Export: GL Djibouti closes after the gate pass (transport document required). */ async closeT1( bookingId: string, userId?: string, ): Promise { const booking = await this.getBooking(bookingId); - if (booking.tradeDirection !== 'IMPORT') { - throw new BadRequestException('T1 closure applies to import shipments only.'); - } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; const state = await this.t1State(bookingId); if (state.closed) return state; - if (!state.trainArrivedAt) { - throw new BadRequestException( - 'The train has not arrived yet — T1 can be closed only after arrival.', - ); - } - const files = await this.filesService.findByResource(bookingId, 'bookings'); - const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); - if (!hasT1) { - throw new BadRequestException( - 'No T1 transport documents on file — GL Djibouti must upload them first.', - ); + if (tradeDirection === 'IMPORT') { + if (!state.trainArrivedAt) { + throw new BadRequestException( + 'The train has not arrived yet — T1 can be closed only after arrival.', + ); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasT1 = files.some((f) => isT1TransportFileCode(f.code)); + if (!hasT1) { + throw new BadRequestException( + 'No T1 transport documents on file — GL Djibouti must upload them first.', + ); + } + } else { + const milestones = await this.milestoneService.listForBooking(bookingId); + const done = (code: string) => + milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED'; + if (!done('EXPORT_TRANSPORT_ISSUED')) { + throw new BadRequestException( + 'The transport document must be uploaded before T1 can be closed.', + ); + } + if (!done('GATEPASS_GRANTED')) { + throw new BadRequestException('Grant the gate pass before closing T1.'); + } + // Export bookings seeded before T1_CLOSED joined the catalog lack the row. + await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); } await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId); return this.t1State(bookingId); } + /** Milestones GL DJ implicitly confirms when granting an export gate pass. */ + private static readonly EXPORT_ARRIVAL_CHAIN = [ + 'CARGO_ARRIVED', + 'READY_FOR_LOADING', + 'LOADED', + 'DEPARTED_TO_DJIBOUTI', + 'ARRIVED_AT_DJIBOUTI', + ]; + + /** + * GL Djibouti grants the gate pass for a customs booking, capturing the time. + * Export: requires the train to have arrived at Djibouti; back-fills the + * arrival-chain milestones. Import: requires wagon allocation (pre-loading). + */ + async grantGatepass( + bookingId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ bookingId: string; gatepassAt: string }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Gate pass applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + + const existing = byCode.get('GATEPASS_GRANTED'); + if (existing?.status === 'COMPLETED') { + return { + bookingId, + gatepassAt: + existing.metadata?.gatepassAt ?? + (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), + }; + } + + const train = await this.trainState(bookingId); + if (tradeDirection === 'EXPORT') { + if (!train.arrivedAt) { + throw new BadRequestException( + 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', + ); + } + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code, userId); + } + } + } else if (!train.wagonAllocated) { + throw new BadRequestException( + 'Wagons must be allocated before the gate pass can be granted.', + ); + } + + const at = gatepassAt?.trim() || new Date().toISOString(); + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'GATEPASS_GRANTED', + { gatepassAt: at }, + userId, + ); + return { bookingId, gatepassAt: at }; + } + + /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ + async djSchedules(): Promise { + const schedules = await this.dataSource.getRepository(TrainSchedule).find({ + relations: { + scheduleBookings: { booking: true }, + originStation: true, + destinationStation: true, + }, + order: { scheduledDepartureDate: 'DESC' }, + }); + + const withCustoms = schedules + .filter((s) => s.status !== 'CANCELLED') + .map((s) => ({ + schedule: s, + customs: (s.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), + })) + .filter((s) => s.customs.length > 0); + + const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); + const gatepassRows = bookingIds.length + ? await this.dataSource.getRepository(ClearanceMilestone).find({ + where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, + }) + : []; + const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); + + return withCustoms.map(({ schedule, customs }) => { + const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; + return { + id: schedule.id, + trainNumber: schedule.trainNumber ?? null, + routeName: null, + origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, + destination: + schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + status: schedule.status, + scheduledDepartureDate: schedule.scheduledDepartureDate + ? new Date(schedule.scheduledDepartureDate).toISOString() + : null, + actualDepartureAt: schedule.actualDepartureAt + ? new Date(schedule.actualDepartureAt).toISOString() + : null, + actualArrivalAt: schedule.actualArrivalAt + ? new Date(schedule.actualArrivalAt).toISOString() + : null, + freightType: + freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, + customsBookings: customs.map((b) => { + const m = gatepassByBooking.get(b.id); + const granted = m?.status === 'COMPLETED'; + return { + bookingId: b.id, + reference: b.reference ?? b.id, + tradeDirection: b.tradeDirection ?? 'IMPORT', + contractId: b.contractId ?? null, + gatepassGranted: granted, + gatepassAt: granted + ? (m?.metadata?.gatepassAt ?? + (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) + : null, + }; + }), + }; + }); + } + + /** + * One-click gate pass for every customs booking on a train schedule. Per-booking + * guard failures are collected, not fatal. Import schedules also get the + * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. + */ + async grantScheduleGatepass( + scheduleId: string, + gatepassAt?: string, + userId?: string, + ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { + const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ + where: { id: scheduleId }, + relations: { scheduleBookings: { booking: true } }, + }); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + + const customs = (schedule.scheduleBookings ?? []) + .map((sb) => sb.booking) + .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); + if (customs.length === 0) { + throw new BadRequestException('No customs bookings ride this schedule.'); + } + + let granted = 0; + const skipped: Array<{ bookingId: string; error: string }> = []; + for (const booking of customs) { + try { + await this.grantGatepass(booking.id, gatepassAt, userId); + granted += 1; + } catch (e) { + skipped.push({ + bookingId: booking.id, + error: e instanceof Error ? e.message : 'Failed', + }); + } + } + + if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { + const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); + let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); + if (!operation) { + operation = opRepo.create({ trainScheduleId: scheduleId }); + } + if (!operation.gatepassGrantedAt) { + operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); + await opRepo.save(operation); + } + } + + return { granted, skipped }; + } + + /** + * GL Djibouti raises the post-offload final invoice (export): manual amount + + * attached invoice document. The customer pays offline and attaches a slip; + * GL (ET or DJ) then confirms to settle it. + */ + async createFinalInvoice( + bookingId: string, + input: { amount: number; currency: string; description?: string }, + file: Express.Multer.File, + userId?: string, + ): Promise { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Final invoice applies to customs bookings only.'); + } + if (!(input.amount > 0)) { + throw new BadRequestException('Invoice amount must be greater than zero.'); + } + if (!file) throw new BadRequestException('Attach the invoice document.'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const offloaded = milestones.find( + (m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED', + ); + if (!offloaded) { + throw new BadRequestException( + 'Cargo must be offloaded before the final invoice can be raised.', + ); + } + + const existing = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if ( + existing && + existing.status !== Freight.InvoiceStatus.Cancelled && + existing.status !== Freight.InvoiceStatus.Expired + ) { + throw new ConflictException('A final invoice already exists for this shipment.'); + } + + const description = input.description?.trim() || 'Post-offload charges (Djibouti)'; + await this.billingService.generateInvoice({ + source: Freight.InvoiceSource.Booking, + sourceId: bookingId, + type: GL_FINAL_INVOICE_TYPE, + companyId: booking.companyId, + companyProfileId: booking.companyProfileId, + currency: input.currency, + lines: [ + { + chargeType: GL_FINAL_INVOICE_TYPE, + description, + quantity: 1, + unitRate: input.amount, + amount: input.amount, + }, + ], + status: Freight.InvoiceStatus.Issued, + }); + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice', + file, + }); + + // Export clearance is administratively done once the final invoice goes out. + await this.dataSource + .getRepository(ContractClearanceCycle) + .update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() }); + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice could not be created.'); + return summary; + } + + /** Customer attaches the payment slip for the final invoice. */ + async uploadFinalInvoiceSlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ uploaded: boolean }> { + await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status === Freight.InvoiceStatus.Paid) { + throw new BadRequestException('The final invoice is already paid.'); + } + if ( + invoice.status === Freight.InvoiceStatus.Cancelled || + invoice.status === Freight.InvoiceStatus.Expired + ) { + throw new BadRequestException('The final invoice is no longer payable.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'final_invoice_slip', + file, + }); + return { uploaded: true }; + } + + /** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */ + async confirmFinalInvoicePaid( + bookingId: string, + userId?: string, + ): Promise { + await this.getBooking(bookingId); + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) { + throw new BadRequestException('No final invoice has been issued for this shipment.'); + } + if (invoice.status !== Freight.InvoiceStatus.Paid) { + const files = await this.filesService.findByResource(bookingId, 'bookings'); + if (!files.some((f) => f.code === 'final_invoice_slip')) { + throw new BadRequestException( + 'The customer has not attached a payment slip yet.', + ); + } + await this.billingService.markInvoiceAsPaid(invoice.id); + } + + void userId; + const summary = await this.finalInvoiceSummary(bookingId); + if (!summary) throw new NotFoundException('Final invoice not found.'); + return summary; + } + + /** + * GL ET advises (or skips) the post-arrival additional duty/tax round (import). + * Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload. + */ + async adviseSecondDuty( + bookingId: string, + input: { + dutyRequired: boolean; + amount?: number; + currency?: string; + declarationSerial?: string; + }, + attachment?: Express.Multer.File, + userId?: string, + ): Promise<{ advised: boolean; skipped: boolean }> { + const booking = await this.getBooking(bookingId); + if (!booking.customsClearingEnabled) { + throw new BadRequestException('Additional duty applies to customs bookings only.'); + } + const tradeDirection = booking.tradeDirection ?? 'IMPORT'; + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException('Additional duty applies to import shipments only.'); + } + + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection); + await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection); + + if (!input.dutyRequired) { + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED'); + await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { advised: false, skipped: true }; + } + + if (!input.amount || input.amount <= 0) { + throw new BadRequestException('Duty amount must be greater than zero.'); + } + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2'); + if (!attachment && !hasNotice) { + throw new BadRequestException('Attach the additional duty/tax notice.'); + } + if (attachment) { + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_notice_2', + file: attachment, + }); + } + + await this.milestoneService.completeWithMetadataForBooking( + bookingId, + 'SECOND_DUTY_ADVISED', + { + dutyAmount: input.amount, + dutyCurrency: input.currency ?? 'ETB', + declarationSerial: input.declarationSerial, + }, + userId, + ); + return { advised: true, skipped: false }; + } + + /** Customer attaches the payment slip for the additional duty round. */ + async uploadSecondDutySlip( + bookingId: string, + file: Express.Multer.File, + ): Promise<{ milestoneCompleted: boolean }> { + const booking = await this.getBooking(bookingId); + if (!file) throw new BadRequestException('No payment slip uploaded'); + + const milestones = await this.milestoneService.listForBooking(bookingId); + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + if (advised?.status !== 'COMPLETED') { + throw new BadRequestException('No additional duty has been advised for this shipment.'); + } + + await this.filesService.upsertByCode({ + resourceId: bookingId, + resource: 'bookings', + code: 'duty_tax_receipt_2', + file, + }); + + await this.milestoneService.ensureForBooking( + bookingId, + 'SECOND_DUTY_PAID', + booking.tradeDirection ?? 'IMPORT', + ); + await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID'); + return { milestoneCompleted: true }; + } + + /** Second duty round state for clearance views. */ + secondDutyState( + milestones: Array<{ + milestoneCode: string; + status: string; + metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null; + }>, + files: Array<{ code?: string | null; id: string; name: string; url: string }>, + ): Freight.ClearanceSecondDuty | null { + const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED'); + const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID'); + if (!advised && !paid) return null; + + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + + return { + advised: advised?.status === 'COMPLETED', + skipped: advised?.status === 'SKIPPED', + amount: advised?.metadata?.dutyAmount ?? null, + currency: advised?.metadata?.dutyCurrency ?? null, + declarationSerial: advised?.metadata?.declarationSerial ?? null, + noticeFile: toRef('duty_tax_notice_2'), + slipFile: toRef('duty_tax_receipt_2'), + paid: paid?.status === 'COMPLETED', + }; + } + + /** Final-invoice state joined with its document + slip files, for clearance views. */ + async finalInvoiceSummary( + bookingId: string, + ): Promise { + const invoice = await this.billingService.findInvoice( + Freight.InvoiceSource.Booking, + bookingId, + GL_FINAL_INVOICE_TYPE, + ); + if (!invoice) return null; + + const files = await this.filesService.findByResource(bookingId, 'bookings'); + const toRef = (code: string) => { + const f = files.find((x) => x.code === code); + return f ? { id: f.id, name: f.name, url: f.url } : null; + }; + const line = await this.dataSource + .getRepository(InvoiceLine) + .findOne({ where: { invoiceId: invoice.id } }); + + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + status: invoice.status, + totalAmount: Number(invoice.totalAmount), + currency: invoice.currency, + description: line?.description ?? null, + invoiceFile: toRef('final_invoice'), + slipFile: toRef('final_invoice_slip'), + confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null, + }; + } + /** * GL ET uploads export transport document after wagon allocation (export ONE_TIME). */ diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts index cf044d6a9..40646ac47 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.spec.ts @@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => { ).toBe(true); }); - it('excludes import contracts still on Ethiopia-side clearance only', () => { - expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false); + it('keeps import contracts from the start — DO upload is un-gated', () => { + expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true); + }); + + it('excludes export contracts with no DJ activity or RO hold', () => { + expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false); }); }); diff --git a/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts new file mode 100644 index 000000000..79a6547bc --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/dtos/email.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator"; + +export class SendEmailDto { + @ApiProperty({ + description: "Recipient email address", + example: "customer@example.com", + }) + @IsEmail() + @IsNotEmpty() + to!: string; + + @ApiProperty({ + description: "Email subject", + example: "Your EDR Freight verification code", + }) + @IsString() + @IsNotEmpty() + subject!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + html?: string; +} diff --git a/apps/edr-freight-api/src/modules/notifications/email-client.service.ts b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts new file mode 100644 index 000000000..161b2486a --- /dev/null +++ b/apps/edr-freight-api/src/modules/notifications/email-client.service.ts @@ -0,0 +1,51 @@ +import { + Inject, + Injectable, + Logger, + OnApplicationBootstrap, +} from "@nestjs/common"; +import { ClientProxy } from "@nestjs/microservices"; +import { SendEmailDto } from "./dtos/email.dto"; + +@Injectable() +export class EmailClientService implements OnApplicationBootstrap { + private readonly logger = new Logger(EmailClientService.name); + + constructor( + @Inject("EMAIL_SERVICE") + private readonly emailClient: ClientProxy, + ) {} + + private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + + async onApplicationBootstrap() { + if (!this.enabled) return; + this.emailClient + .connect() + .then(() => this.logger.log("connected to Email service")) + .catch((err) => { + console.error("Error happened at Email service", err); + }); + } + + async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> { + if (!this.enabled) { + this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`); + return { queued: false }; + } + this.emailClient.emit("send-email", { + to: dto.to, + subject: dto.subject, + text: dto.text, + html: dto.html, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + // Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery. + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + // Recipient + content are PII — debug only. + this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`); + return { queued: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts index 663f931ef..4e56c8b70 100644 --- a/apps/edr-freight-api/src/modules/notifications/notifications.module.ts +++ b/apps/edr-freight-api/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices"; import { NotificationsService } from "./notifications.service"; import { SmsClientService } from "./sms-client.service"; +import { EmailClientService } from "./email-client.service"; import { EmailNotificationStrategy } from "./strategies/notification.email.strategy"; import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"; @@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy" queueOptions: { durable: true }, }, }, + { + name: "EMAIL_SERVICE", + transport: Transport.RMQ, + options: { + urls: [process.env.RABBITMQ_URL as string], + queue: process.env.EMAIL_QUEUE ?? "email_queue", + queueOptions: { durable: true }, + }, + }, ]), ], controllers: [], - providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService], - exports: [NotificationsService, SmsClientService], + providers: [ + EmailNotificationStrategy, + SmsNotificationStrategy, + NotificationsService, + SmsClientService, + EmailClientService, + ], + exports: [NotificationsService, SmsClientService, EmailClientService], }) export class NotificationsModule {} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts index 5850cbb1a..155657a74 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -1,15 +1,24 @@ // otp.controller.ts import { + BadRequestException, Body, Controller, Post, } from "@nestjs/common"; -import { OtpService } from "./otp.service"; +import { OtpService, OtpTarget } from "./otp.service"; import { Public } from "@edr/api-common"; +// Exactly one of phone/email must be present per request — the channel the +// code is sent through / checked against. +function toTarget(phone?: string, email?: string): OtpTarget { + if (email) return { email }; + if (phone) return { phone }; + throw new BadRequestException("phone or email is required"); +} + @Controller("otp") @Public() export class OtpController { @@ -24,9 +33,12 @@ export class OtpController { @Post("send") async sendOtp( @Body("phone") - phone: string + phone?: string, + + @Body("email") + email?: string ) { - return this.otpService.sendOtp(phone); + return this.otpService.sendOtp(toTarget(phone, email)); } // --------------------------------------------------------------------------- @@ -36,13 +48,16 @@ export class OtpController { @Post("verify") async verifyOtp( @Body("phone") - phone: string, + phone: string | undefined, + + @Body("email") + email: string | undefined, @Body("otp") otp: string ) { return this.otpService.verifyOtp( - phone, + toTarget(phone, email), otp ); } diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts index f5900f6b8..022bbf767 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.entity.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common"; name: "otp_verifications", }) export class OtpVerification extends BaseEntity{ + // Exactly one of phone/email is set per row — the channel the code was sent + // through. @Column({ unique: true, + nullable: true, }) - phone!: string; + phone?: string; + + @Column({ + unique: true, + nullable: true, + }) + email?: string; @Column() otp!: string; diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts index ec1d9f9ed..511fe4bbb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.module.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module"; exports: [ OtpRepository, + OtpService, ], }) export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts index 8aa69dcd6..7abd434d8 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.repository.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -31,17 +31,44 @@ export class OtpRepository { }); } + // --------------------------------------------------------------------------- + // Find By Email + // --------------------------------------------------------------------------- + + async findByEmail( + email: string + ) { + return this.repository.findOne({ + where: { + email, + }, + }); + } + + // --------------------------------------------------------------------------- + // Find By Target (either channel) + // --------------------------------------------------------------------------- + + async findByTarget( + target: { phone?: string; email?: string } + ) { + return target.email + ? this.findByEmail(target.email) + : this.findByPhone(target.phone!); + } + // --------------------------------------------------------------------------- // Create OTP // --------------------------------------------------------------------------- async createOtp( - phone: string, + target: { phone?: string; email?: string }, otp: string ) { const entity = this.repository.create({ - phone, + phone: target.phone, + email: target.email, otp, verified: false, }); @@ -70,10 +97,10 @@ export class OtpRepository { } // --------------------------------------------------------------------------- - // Verify Phone + // Mark Verified // --------------------------------------------------------------------------- - async verifyPhone( + async markVerified( otpVerification: OtpVerification ) { otpVerification.verified = @@ -83,4 +110,18 @@ export class OtpRepository { otpVerification ); } + + // --------------------------------------------------------------------------- + // Delete OTP (single-use consume) + // --------------------------------------------------------------------------- + + // Hard delete so the unique `phone` row is freed and a fresh code can be + // requested for the same number on the next action. + async deleteOtp( + otpVerification: OtpVerification + ) { + return this.repository.remove( + otpVerification + ); + } } \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index ffa9c4e68..67fbdec9b 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -1,80 +1,80 @@ // otp.service.ts -import { - BadRequestException, - Injectable, -} from "@nestjs/common"; +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { OtpRepository } from "./otp.repository"; import { SmsClientService } from "../notifications/sms-client.service"; +import { EmailClientService } from "../notifications/email-client.service"; + +// Exactly one of phone/email is set — enforced by the controller before it +// reaches here. +export type OtpTarget = { phone?: string; email?: string }; @Injectable() export class OtpService { + logger = new Logger(OtpService.name); constructor( private readonly otpRepository: OtpRepository, - private readonly smsClient: SmsClientService - ) {} + private readonly smsClient: SmsClientService, + private readonly emailClient: EmailClientService, + ) { } // --------------------------------------------------------------------------- // Generate OTP // --------------------------------------------------------------------------- generateOtp(): string { - return Math.floor( - 100000 + Math.random() * 900000 - ).toString(); + return Math.floor(100000 + Math.random() * 900000).toString(); } // --------------------------------------------------------------------------- // Send OTP // --------------------------------------------------------------------------- - async sendOtp(phone: string) { + async sendOtp(target: OtpTarget) { try { // The verification code is generated server-side — never supplied by the // caller — so the OTP stays a secret known only to the server and the - // recipient of the SMS. + // recipient of the SMS/email. const otp = this.generateOtp(); - // find existing phone - const existingPhone = - await this.otpRepository.findByPhone( - phone - ); + // find existing row for this channel + const existing = await this.otpRepository.findByTarget(target); // update existing otp - if (existingPhone) { - await this.otpRepository.updateOtp( - existingPhone, - otp - ); + if (existing) { + await this.otpRepository.updateOtp(existing, otp); } else { // create new otp - await this.otpRepository.createOtp( - phone, - otp - ); + await this.otpRepository.createOtp(target, otp); } - // send sms (queued to RabbitMQ via the shared SMS service) - await this.smsClient.sendSms({ - to: phone, - message: `Your verification code is ${otp}`, - }); + if (target.email) { + // send email (queued to RabbitMQ via the shared Email service) + await this.emailClient.sendEmail({ + to: target.email, + subject: "Your EDR Freight verification code", + text: `Your verification code is ${otp}`, + }); + } else { + // send sms (queued to RabbitMQ via the shared SMS service) + await this.smsClient.sendSms({ + to: target.phone as string, + message: `Your verification code is ${otp}`, + }); + } + this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`); return { success: true, - message: - "OTP sent successfully", + message: "OTP sent successfully", }; } catch (error) { console.log(error); - throw new BadRequestException( - "Failed to send OTP" - ); + throw new BadRequestException("Failed to send OTP"); } } @@ -82,40 +82,70 @@ export class OtpService { // Verify OTP // --------------------------------------------------------------------------- - async verifyOtp( - phone: string, - otp: string - ) { - // find phone - const otpData = - await this.otpRepository.findByPhone( - phone - ); + async verifyOtp(target: OtpTarget, otp: string) { + // find the channel's row + const otpData = await this.otpRepository.findByTarget(target); - // phone not found + // not found if (!otpData) { throw new BadRequestException( - "Phone number not found" + target.email ? "Email address not found" : "Phone number not found", ); } // invalid otp if (otpData.otp !== otp) { - throw new BadRequestException( - "Invalid OTP" - ); + throw new BadRequestException("Invalid OTP"); } - // verify phone - await this.otpRepository.verifyPhone( - otpData - ); + // mark verified + await this.otpRepository.markVerified(otpData); return { success: true, - message: - "Phone verified successfully", + message: target.email + ? "Email verified successfully" + : "Phone verified successfully", }; } -} \ No newline at end of file + + // --------------------------------------------------------------------------- + // Verify OTP for a sensitive action (sudo mode) + // --------------------------------------------------------------------------- + + // Fresh, single-use challenge gating a sensitive action (e.g. applying a + // contract signature). Unlike verifyOtp above — which marks a phone verified + // and leaves the code in place — this enforces a short TTL and consumes the + // code on success so it can never be replayed. + private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000; + + async verifyOtpForAction(phone: string, otp: string) { + const otpData = await this.otpRepository.findByPhone(phone); + + if (!otpData) { + throw new BadRequestException( + "No verification code was requested for this phone", + ); + } + + const ageMs = Date.now() - new Date(otpData.updatedAt).getTime(); + + if (ageMs > this.ACTION_OTP_TTL_MS) { + await this.otpRepository.deleteOtp(otpData); + + throw new BadRequestException( + "Verification code has expired. Request a new one.", + ); + } + + if (otpData.otp !== otp) { + throw new BadRequestException("Invalid verification code"); + } + + // single-use: consume on success + await this.otpRepository.deleteOtp(otpData); + + return { success: true }; + } +} diff --git a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts index 57c95eab3..0fc5a6ba5 100644 --- a/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/internal-payment.controller.ts @@ -1,9 +1,10 @@ import { - Body, - Controller, - HttpCode, - HttpStatus, - Post, + Body, + Controller, + HttpCode, + HttpStatus, + Logger, + Post, } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { Public } from "@edr/api-common"; @@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service"; @Public() @Controller("internal/payments") export class InternalPaymentController { - constructor(private readonly paymentService: PaymentService) { } + private readonly logger = new Logger(InternalPaymentController.name); + constructor(private readonly paymentService: PaymentService) { } - @Post("mark-paid") - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", - }) - async markPaid(@Body() event: PaymentEventDto): Promise { - return this.paymentService.handlePaymentEvent(event); - } + @Post("mark-paid") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: + "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", + }) + async markPaid(@Body() event: PaymentEventDto): Promise { + this.logger.log(`Marking payment ${event} as PAID`); + return this.paymentService.handlePaymentEvent(event); + } } diff --git a/apps/edr-freight-api/src/modules/payment/payment.repository.ts b/apps/edr-freight-api/src/modules/payment/payment.repository.ts index 25c3bdd6b..96a4994ea 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.repository.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.repository.ts @@ -93,9 +93,8 @@ export class PaymentRepository { p.paid_at, p.created_at FROM freight.payments p - JOIN freight.bookings b ON b.id = p.ref_id + JOIN freight.bookings b ON b.id = p.ref_id::uuid WHERE b.company_id = $1 - AND p.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY p.created_at DESC`, [companyId], diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 995718135..9a4cd642b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { RATE_APPLIES_TO, RATE_TRIGGERS, @@ -51,15 +51,6 @@ export class CreateRateDto { @ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' }) @IsIn([...RATE_UNITS]) rateUnit!: string; - - @ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } export class SubmitRateForApprovalDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts index 6be37214b..eea223ae3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-weight-limit-rule.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; -import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator'; +import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; @@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto { @Min(0) @Transform(({ value }) => Number(value)) maxVgmTons!: number; - - @ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' }) - @IsDateString() - effectiveFrom!: string; - - @ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' }) - @IsOptional() - @IsDateString() - effectiveTo?: string; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts new file mode 100644 index 000000000..cef613412 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -0,0 +1,71 @@ +import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; + +/** + * Which rate units make sense for a given rate shape. The weighting basis is + * driven by the *type* of thing being billed — a container leg bills per + * container, bulk freight per ton, an intercity move can be per-km, a + * cancellation is a flat/per-invoice fee, and overweight is always per excess + * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * only pick a unit the pricing engine knows how to apply. + * + * Returned lists are ordered with the most natural/default unit first. + */ +export function allowedRateUnits(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; +}): RateUnit[] { + const { appliesTo, trigger } = input; + + // Surcharges (Applies to = Other) are governed by their trigger. + if (appliesTo === 'OTHER') { + switch (trigger) { + case 'OVERWEIGHT': + // Overweight always bills the excess tonnage — per ton, nothing else. + return ['PER_TON']; + case 'REEFER': + case 'HAZARDOUS': + // Scale with the freight shape: per container for boxes, per ton for bulk. + return ['PER_CONTAINER', 'PER_TON']; + case 'DEMURRAGE': + return ['PER_CONTAINER', 'PER_TON']; + case 'CANCELLATION': + return ['FLAT', 'PER_INVOICE']; + case 'CONSOLIDATION': + return ['PER_CONTAINER', 'FLAT']; + case 'SHIPPING_LINE': + case 'PIL_EXTRA_FEE': + return ['PER_CONTAINER', 'FLAT']; + default: + return ['FLAT', 'PER_TON', 'PER_CONTAINER']; + } + } + + // Base freight + first/last mile scale with the cargo type. + switch (appliesTo) { + case 'CONTAINER': + return ['PER_CONTAINER', 'PER_WAGON']; + case 'BULK': + return ['PER_TON', 'PER_WAGON']; + case 'INTERCITY': + return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM']; + case 'FIRST_MILE': + case 'LAST_MILE': + return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT']; + default: + return ['FLAT']; + } +} + +/** The default (first / most natural) unit for a rate shape. */ +export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit { + return allowedRateUnits(input)[0]; +} + +/** True when `unit` is a valid weighting basis for the given rate shape. */ +export function isRateUnitAllowed(input: { + appliesTo: RateAppliesTo; + trigger: RateTrigger; + unit: RateUnit; +}): boolean { + return allowedRateUnits(input).includes(input.unit); +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index b57b48cd8..50f8b3b99 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number]; @Entity({ schema: 'freight', name: 'rates' }) @Index(['rateType']) @Index(['status']) -@Index(['effectiveFrom']) @Index(['containerTypeId']) @Index(['trigger']) export class Rate extends BaseEntity { @@ -131,10 +130,4 @@ export class Rate extends BaseEntity { @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) approvedAt?: Date | null; - - @Column({ name: 'effective_from', type: 'date' }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts index 39557eec9..b6b87b285 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/weight-limit-rule.entity.ts @@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity'; @Entity({ schema: 'freight', name: 'weight_limit_rules' }) @Index(['containerTypeId']) @Index(['tradeDirection']) -@Index(['effectiveFrom']) export class WeightLimitRule extends BaseEntity { @Column({ name: 'container_type_id', type: 'uuid' }) containerTypeId!: string; @@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity { @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) maxVgmTons!: number; - - @Column({ name: 'effective_from', type: 'date', nullable: true }) - effectiveFrom!: Date; - - @Column({ name: 'effective_to', type: 'date', nullable: true }) - effectiveTo?: Date | null; } diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts index 52b991155..96db214c4 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/rates.repository.interface.ts @@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity'; export interface IRatesRepository { findById(id: string): Promise; findLiveRates(): Promise; + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[Rate[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts index cedbd1eee..3c175df4e 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/interfaces/weight-limit-rules.repository.interface.ts @@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise; + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise; findAll(options?: FindManyOptions): Promise; findAndCount(options?: FindManyOptions): Promise<[WeightLimitRule[], number]>; create(data: Partial): Promise; diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 0d49a0bf3..a7260f7f1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository { } findLiveRates(): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rate') .where('rate.status = :status', { status: 'LIVE' }) - .andWhere('rate.effective_from <= :now', { now }) - .andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now }) .getMany(); } + /** + * Find a non-superseded rate matching an identity pattern — the same tuple the + * `UQ_rates_pattern` unique index enforces. Used to reject duplicates before + * insert so the admin gets a friendly error instead of a raw constraint fault. + * NULL scope columns are matched with IS NULL, mirroring the COALESCE index. + */ + findByPattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId?: string | null; + cargoTypeId?: string | null; + tradeDirection?: string | null; + }): Promise { + const qb = this.repo + .createQueryBuilder('rate') + .where('rate.rate_type = :rateType', { rateType: pattern.rateType }) + .andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit }) + .andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' }); + + if (pattern.containerTypeId) { + qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId }); + } else { + qb.andWhere('rate.container_type_id IS NULL'); + } + if (pattern.cargoTypeId) { + qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId }); + } else { + qb.andWhere('rate.cargo_type_id IS NULL'); + } + if (pattern.tradeDirection) { + qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection }); + } else { + qb.andWhere('rate.trade_direction IS NULL'); + } + + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts index 0d151c561..87d2febba 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/weight-limit-rules.repository.ts @@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { containerTypeId: string, tradeDirection: string, ): Promise { - const now = new Date(); return this.repo .createQueryBuilder('rule') .innerJoinAndSelect('rule.containerType', 'ct') @@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository { dir: tradeDirection, both: 'BOTH', }) - .andWhere('rule.effective_from <= :now', { now }) - .andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now }) .getMany(); } + /** + * Find a rule matching the (containerType, tradeDirection) identity — the + * tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates + * before insert. Optionally excludes a row by id so updates don't self-collide. + */ + findByPattern( + containerTypeId: string, + tradeDirection: string, + excludeId?: string, + ): Promise { + const qb = this.repo + .createQueryBuilder('rule') + .where('rule.container_type_id = :containerTypeId', { containerTypeId }) + .andWhere('rule.trade_direction = :tradeDirection', { tradeDirection }); + if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId }); + return qb.getOne(); + } + findAll(options?: FindManyOptions): Promise { return this.repo.find(options); } diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index c3ab6bab0..0027e18c1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -1,8 +1,15 @@ -import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { CreateRateDto } from '../dto/create-rate.dto'; import { UpdateRateDto } from '../dto/update-rate.dto'; import { Rate } from '../entities/rate.entity'; import { deriveRateType } from '../entities/rate-type.util'; +import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util'; import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface'; @Injectable() @@ -27,7 +34,7 @@ export class RatesService { const [data, total] = await this.repository.findAndCount({ where, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -46,6 +53,50 @@ export class RatesService { return entity; } + /** + * Normalise + validate the weighting unit for a rate shape. Overweight is + * always billed per excess ton, so its unit is forced to PER_TON regardless + * of what the client sent. Every other shape must pick a unit the pricing + * engine can actually apply (see `allowedRateUnits`). + */ + private resolveRateUnit( + appliesTo: Rate['appliesTo'], + trigger: Rate['trigger'], + requestedUnit: Rate['rateUnit'], + ): Rate['rateUnit'] { + // Overweight is per-ton, full stop. + if (trigger === 'OVERWEIGHT') return 'PER_TON'; + + if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + const allowed = allowedRateUnits({ appliesTo, trigger }).join(', '); + throw new BadRequestException( + `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`, + ); + } + return requestedUnit; + } + + /** + * Reject a second rate with the same identity pattern (rateType + scope). With + * effective-date windows gone, two LIVE/DRAFT rates for the same pattern would + * make pricing ambiguous — so we allow exactly one per pattern. + */ + private async assertNoDuplicatePattern(pattern: { + rateType: string; + rateUnit: string; + containerTypeId: string | null; + cargoTypeId: string | null; + tradeDirection: string | null; + ignoreId?: string; + }): Promise { + const existing = await this.repository.findByPattern(pattern); + if (existing && existing.id !== pattern.ignoreId) { + throw new ConflictException( + 'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.', + ); + } + } + /** Create a rate in DRAFT status. */ async create(dto: CreateRateDto, proposedByStaffId: string): Promise { const appliesTo = dto.appliesTo as Rate['appliesTo']; @@ -57,25 +108,28 @@ export class RatesService { const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null); + const rateType = deriveRateType({ + appliesTo, + trigger, + tradeDirection, + isBulk: Boolean(cargoTypeId), + }); + const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']); + + await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection }); + return this.repository.create({ appliesTo, trigger, - rateType: deriveRateType({ - appliesTo, - trigger, - tradeDirection, - isBulk: Boolean(cargoTypeId), - }), + rateType, containerTypeId, cargoTypeId, tradeDirection, currency: dto.currency ?? 'USD', rateValue: dto.rateValue, - rateUnit: dto.rateUnit as Rate['rateUnit'], + rateUnit, status: 'DRAFT', proposedByStaffId, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined, }); } @@ -110,22 +164,35 @@ export class RatesService { ? dto.tradeDirection : existing.tradeDirection; - updates.containerTypeId = containerTypeId; - updates.cargoTypeId = cargoTypeId; - updates.tradeDirection = tradeDirection; + updates.containerTypeId = containerTypeId ?? null; + updates.cargoTypeId = cargoTypeId ?? null; + updates.tradeDirection = tradeDirection ?? null; // Keep the derived rateType in sync with whatever changed. - updates.rateType = deriveRateType({ + const rateType = deriveRateType({ appliesTo, trigger, tradeDirection, isBulk: Boolean(cargoTypeId), }); + updates.rateType = rateType; + + // Re-validate the unit against the (possibly changed) shape; overweight is + // forced to PER_TON. + const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + + // Guard the pattern uniqueness for the new identity, ignoring this row. + await this.assertNoDuplicatePattern({ + rateType, + rateUnit: updates.rateUnit, + containerTypeId: updates.containerTypeId, + cargoTypeId: updates.cargoTypeId, + tradeDirection: updates.tradeDirection, + ignoreId: id, + }); updates.currency = dto.currency ?? existing.currency ?? 'USD'; if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue; - if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit']; - if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo); const updated = await this.repository.update(id, updates); if (!updated) throw new NotFoundException(`Rate ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts index d171f55aa..bbd042296 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/weight-limit-rules.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; @@ -30,7 +30,7 @@ export class WeightLimitRulesService { const [data, total] = await this.repository.findAndCount({ where, relations: { containerType: true }, - order: { effectiveFrom: 'DESC' }, + order: { createdAt: 'DESC' }, skip: (page - 1) * pageSize, take: pageSize, }); @@ -44,26 +44,51 @@ export class WeightLimitRulesService { return entity; } + /** + * Reject a second rule for the same container + direction. One VGM limit per + * (container, direction) — otherwise the booking engine can't tell which + * applies. + */ + private async assertNoDuplicate( + containerTypeId: string, + tradeDirection: string, + ignoreId?: string, + ): Promise { + const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId); + if (existing) { + throw new ConflictException( + 'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.', + ); + } + } + /** Create a new weight limit rule. */ async create(dto: CreateWeightLimitRuleDto): Promise { + await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); return this.repository.create({ containerTypeId: dto.containerTypeId, tradeDirection: dto.tradeDirection, maxVgmTons: dto.maxVgmTons, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null, }); } /** Update an existing weight limit rule. */ async update(id: string, dto: UpdateWeightLimitRuleDto): Promise { - await this.findById(id); + const existing = await this.findById(id); const patch: Partial = {}; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; - if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom); - if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo); + + // Re-check uniqueness when the identity (container/direction) changes. + if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { + await this.assertNoDuplicate( + patch.containerTypeId ?? existing.containerTypeId, + patch.tradeDirection ?? existing.tradeDirection, + id, + ); + } + const updated = await this.repository.update(id, patch); if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`); return updated; diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts index 4ffecea26..352704eea 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule-booking.entity.ts @@ -1,9 +1,15 @@ import { BaseEntity } from '@edr/api-common'; +import { LoadingStatus } from '@edr/types'; import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm'; import { Booking } from '../../bookings/entities/booking.entity'; import { TrainSchedule } from './train-schedule.entity'; +export const TRAIN_SCHEDULE_BOOKING_LOADING_STATUSES = [ + LoadingStatus.Unloaded, + LoadingStatus.Loaded, +] as const; + @Entity({ schema: 'freight', name: 'train_schedule_bookings' }) @Index(['trainScheduleId', 'bookingId'], { unique: true }) @Index(['bookingId'], { unique: true }) @@ -23,4 +29,7 @@ export class TrainScheduleBooking extends BaseEntity { @ManyToOne(() => Booking) @JoinColumn({ name: 'booking_id' }) booking?: Booking; + + @Column({ name: 'loading_status', type: 'varchar', length: 20, default: 'UNLOADED' }) + loadingStatus!: string; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index d1ed23ef7..d67d58811 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' }) bookingWindowStatus!: string; + /** + * Booking-window lifecycle for the one-booking-day cycle + * (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE). + * NULL on legacy and DOMESTIC schedules — the window engine ignores those. + */ + @Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true }) + windowPhase?: string | null; + + @Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true }) + windowOpensAt?: Date | null; + + @Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true }) + windowClosesAt?: Date | null; + + @Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true }) + docReviewEndsAt?: Date | null; + + /** Staff finished document review early — starts the batch/payment phase immediately. */ + @Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true }) + docReviewCompletedAt?: Date | null; + + @Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true }) + paymentPhaseEndsAt?: Date | null; + + /** 1-based count of open→settle cycles run on the booking day. */ + @Column({ name: 'booking_cycle_no', type: 'int', default: 0 }) + bookingCycleNo!: number; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts index 4ccfcd469..64607ecb8 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedule-bookings.repository.ts @@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository { + return this.repo(manager).find({ + where: { trainScheduleId }, + select: { id: true, bookingId: true, trainScheduleId: true, loadingStatus: true }, + }); + } + + async updateLoadingStatusMany( + trainScheduleId: string, + bookingIds: string[], + loadingStatus: string, + manager?: EntityManager, + ): Promise { + if (!bookingIds.length) return; + await this.repo(manager).update( + { trainScheduleId, bookingId: In(bookingIds) }, + { loadingStatus }, + ); + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index e765e5694..764589b73 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -3,9 +3,9 @@ import { listBatchWindowsForDate, listBatchWindowsForBookings, BATCH_WINDOW_START_HOURS, - boardWindowForTimestamp, - listBoardWindowsForRange, + listConfigBookingWindows, groupBookingsIntoBoardWindows, + type BoardWindowConfig, } from './batch-window.util'; describe('batch-window.util', () => { @@ -54,83 +54,87 @@ describe('batch-window.util', () => { }); }); -describe('batch-window board windows (midnight-based 3h slots)', () => { - it('maps 04:00 EAT to the 03:00–06:00 slot', () => { - // 01:00 UTC = 04:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z')); - expect(w.label).toContain('03:00'); - expect(w.label).toContain('06:00'); - expect(w.date).toBe('2026-06-11'); - expect(w.dateLabel).toContain('11 Jun'); - }); +describe('batch-window board windows (config-driven booking cycles)', () => { + // Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later. + const cfg: BoardWindowConfig = { + importWindowLeadDays: 3, + windowOpenHour: 8, + windowDurationHours: 3, + reopenDelayMinutes: 90, + exportBookingLeadHours: 24, + }; - it('maps 00:30 EAT to the 00:00–03:00 slot of that EAT day', () => { - // 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z')); - expect(w.label).toContain('00:00'); - expect(w.label).toContain('03:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('maps 23:00 EAT to the final 21:00–24:00 slot', () => { - // 20:00 UTC = 23:00 EAT on 11 Jun - const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z')); - expect(w.label).toContain('21:00'); - expect(w.label).toContain('24:00'); - expect(w.date).toBe('2026-06-11'); - }); - - it('lists a continuous range open→departure clamped at both ends', () => { - // open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC) - const open = new Date('2026-06-05T05:00:00.000Z'); + it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => { + // departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC) const departure = new Date('2026-06-08T11:00:00.000Z'); - const windows = listBoardWindowsForRange(open, departure); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); - // Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5 - expect(windows).toHaveLength(6 + 8 + 8 + 5); expect(windows[0].date).toBe('2026-06-05'); - expect(windows[0].label).toContain('06:00'); - expect(windows[0].label).toContain('09:00'); - const last = windows[windows.length - 1]; - expect(last.date).toBe('2026-06-08'); - expect(last.label).toContain('12:00'); - expect(last.label).toContain('15:00'); - // chronological + unique keys - const keys = windows.map((w) => w.key); - expect(new Set(keys).size).toBe(keys.length); + expect(windows[0].label).toContain('08:00'); + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + // end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC) + expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('handles a same-day open→departure range', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (06–09 slot) - const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (12–15 slot) - const windows = listBoardWindowsForRange(open, departure); - // 06,09,12 = 3 slots - expect(windows).toHaveLength(3); + it('import: reopens reopenDelayMinutes after close, same booking day', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, cfg); + // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT + // all cycles stay on the same EAT booking day expect(windows.every((w) => w.date === '2026-06-05')).toBe(true); }); - it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => { - const open = new Date('2026-06-05T05:00:00.000Z'); - const departure = new Date('2026-06-06T11:00:00.000Z'); + it('export: single FCFS window exportBookingLeadHours before departure', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('EXPORT', departure, cfg); + expect(windows).toHaveLength(1); + // 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun + expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe(departure.toISOString()); + }); + + it('buckets bookings into config cycles and keeps empty + pending windows', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); const items = [ - { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 06–09 on 5th + { id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1 { id: 'b', ts: null }, // pending ]; const map = groupBookingsIntoBoardWindows( items, (i) => i.ts, - open, + 'IMPORT', departure, + cfg, 'pending-contract', ); const pending = map.get('pending-contract'); expect(pending?.items.map((i) => i.id)).toEqual(['b']); const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a')); expect(withA?.window?.date).toBe('2026-06-05'); - // empty slots are retained for the UI + // empty cycles are retained for the UI const emptyCount = [...map.values()].filter( (b) => b.window && b.items.length === 0, ).length; expect(emptyCount).toBeGreaterThan(0); }); + + it('attaches a booking made before the window opened to the first cycle', () => { + const departure = new Date('2026-06-08T11:00:00.000Z'); + const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }]; + const map = groupBookingsIntoBoardWindows( + items, + (i) => i.ts, + 'IMPORT', + departure, + cfg, + 'pending-contract', + ); + const withEarly = [...map.values()].find((b) => + b.items.some((i) => i.id === 'early'), + ); + expect(withEarly?.window?.date).toBe('2026-06-05'); + expect(withEarly?.window?.label).toContain('08:00'); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 7316e1610..6d295c8fd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -121,6 +121,66 @@ function windowFromEatStart( }; } +/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */ +export function eatDayToUtc(day: string, hour: number, minute = 0): Date { + const [year, month, dayOfMonth] = day.split('-').map(Number); + return eatToUtc(year, month, dayOfMonth, hour, minute); +} + +/** Shift a `yyyy-MM-dd` EAT day key by whole days. */ +export function shiftEatDay(day: string, deltaDays: number): string { + // Noon UTC keeps the +3h EAT offset from crossing a day boundary. + const [year, month, dayOfMonth] = day.split('-').map(Number); + const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String( + shifted.getUTCDate(), + ).padStart(2, '0')}`; +} + +export interface InitialWindowTimes { + windowOpensAt: Date; + windowClosesAt: Date; +} + +/** + * Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus + * `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its + * computed window has fully passed gets a same-day window starting now instead, + * capped at departure. + */ +export function computeImportWindowTimes( + departure: Date, + cfg: { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + }, + now: Date, +): InitialWindowTimes { + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (closesAt.getTime() <= now.getTime()) { + opensAt = now; + closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000); + } + if (closesAt.getTime() > departure.getTime()) { + closesAt = departure; + } + return { windowOpensAt: opensAt, windowClosesAt: closesAt }; +} + +/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ +export function computeExportWindowTimes( + departure: Date, + cfg: { exportBookingLeadHours: number }, +): InitialWindowTimes { + return { + windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), + windowClosesAt: departure, + }; +} + /** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */ export function getBatchWindowForTimestamp(date: Date): BatchWindow { const { year, month, day, hour } = eatParts(date); @@ -170,14 +230,13 @@ export function listBatchWindowsForBookings( } // --------------------------------------------------------------------------- -// Board-display windows: full-day, midnight-based 3h slots over a date range. -// These are used ONLY for the batch-board UI grouping (not persisted, and -// independent of the cron intake hours above). +// Board-display windows: the REAL booking-window cycles derived from the +// train_scheduling_global_rules config (window open hour, lead days, duration, +// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle +// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after +// reopenDelayMinutes until departure). Export shows the single FCFS lead window. // --------------------------------------------------------------------------- -/** Midnight-based 3-hour slot starts (00–03, 03–06, … 21–24). */ -export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const; - /** A board window carries an EAT calendar date in addition to the slot times. */ export interface BoardWindow extends BatchWindow { /** EAT calendar day as ISO `YYYY-MM-DD`. */ @@ -186,6 +245,15 @@ export interface BoardWindow extends BatchWindow { dateLabel: string; } +/** Config fields the board needs to reconstruct booking-window cycles. */ +export interface BoardWindowConfig { + importWindowLeadDays: number; + windowOpenHour: number; + windowDurationHours: number; + reopenDelayMinutes: number; + exportBookingLeadHours: number; +} + const dayLabelFmt = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: '2-digit', @@ -197,119 +265,124 @@ function pad2(n: number): string { return String(n).padStart(2, '0'); } -/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */ -function boardWindowFromEatStart( - year: number, - month: number, - day: number, - startHour: number, -): BoardWindow { - const start = eatToUtc(year, month, day, startHour); - const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over) - const end = eatToUtc(year, month, day, endHour); - const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`; +/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */ +function boardWindowFromInterval(start: Date, end: Date): BoardWindow { + const { year, month, day } = eatParts(start); return { key: start.toISOString(), start, end, - label: formatWindowLabel(start, end, endLabel), + label: formatWindowLabel(start, end), date: `${year}-${pad2(month)}-${pad2(day)}`, dateLabel: dayLabelFmt.format(start), }; } -/** Which midnight-based 3h EAT slot a timestamp falls in. */ -export function boardWindowForTimestamp(date: Date): BoardWindow { - const { year, month, day, hour } = eatParts(date); - let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0; - for (const h of BOARD_WINDOW_HOURS) { - if (hour >= h) startHour = h; - } - return boardWindowFromEatStart(year, month, day, startHour); -} - /** - * Continuous list of board windows from `openDate` to `departureDate` (inclusive), - * clamped to the slot containing `openDate` on the first day and the slot - * containing `departureDate` on the last day. Returned in chronological order. + * The real booking-window cycles for a schedule, straight from config. + * + * IMPORT: first window opens at `windowOpenHour` EAT on `departure − importWindowLeadDays` + * for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes` + * after each close, on the same booking day, until departure. This mirrors + * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the + * exact windows the engine runs. + * EXPORT: a single FCFS window from `departure − exportBookingLeadHours` to departure. */ -export function listBoardWindowsForRange( - openDate: Date, - departureDate: Date, +export function listConfigBookingWindows( + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, ): BoardWindow[] { - const startWin = boardWindowForTimestamp(openDate); - const endWin = boardWindowForTimestamp(departureDate); - // Guard against an inverted range (departure before open). - if (endWin.start.getTime() < startWin.start.getTime()) { - return [startWin]; + if (direction === 'EXPORT') { + const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000); + return [boardWindowFromInterval(start, departure)]; } const windows: BoardWindow[] = []; - const seen = new Set(); - // Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to - // avoid any boundary ambiguity, then filter to [startWin.start, endWin.start]. - let cursor = new Date(eatToUtc( - Number(startWin.date.slice(0, 4)), - Number(startWin.date.slice(5, 7)), - Number(startWin.date.slice(8, 10)), - 12, - )); - const lastDayMs = eatToUtc( - Number(endWin.date.slice(0, 4)), - Number(endWin.date.slice(5, 7)), - Number(endWin.date.slice(8, 10)), - 12, - ).getTime(); + const durationMs = cfg.windowDurationHours * 3_600_000; + const reopenMs = cfg.reopenDelayMinutes * 60_000; + const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays); - while (cursor.getTime() <= lastDayMs) { - const { year, month, day } = eatParts(cursor); - for (const h of BOARD_WINDOW_HOURS) { - const w = boardWindowFromEatStart(year, month, day, h); - if ( - w.start.getTime() >= startWin.start.getTime() && - w.start.getTime() <= endWin.start.getTime() && - !seen.has(w.key) - ) { - seen.add(w.key); - windows.push(w); - } + let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour); + // Reopen stays on the same EAT booking day and before departure; cap at 12 cycles. + for (let cycle = 0; cycle < 12; cycle += 1) { + if (opensAt.getTime() >= departure.getTime()) break; + let closesAt = new Date(opensAt.getTime() + durationMs); + if (closesAt.getTime() > departure.getTime()) closesAt = departure; + windows.push(boardWindowFromInterval(opensAt, closesAt)); + + const nextOpensAt = new Date(closesAt.getTime() + reopenMs); + if ( + nextOpensAt.getTime() >= departure.getTime() || + eatDay(nextOpensAt) !== eatDay(opensAt) + ) { + break; } - cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000); + opensAt = nextOpensAt; } - windows.sort(compareBatchWindows); + // Degenerate config (no window before departure) — surface a single window + // clamped to departure so the board still renders something meaningful. + if (windows.length === 0) { + windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure)); + } return windows; } +/** Which config booking-window a timestamp falls in; null if before/after all of them. */ +function configWindowForTimestamp( + windows: BoardWindow[], + date: Date, +): BoardWindow | null { + const ms = date.getTime(); + for (const w of windows) { + if (ms >= w.start.getTime() && ms < w.end.getTime()) return w; + } + return null; +} + /** - * Group items into board windows spanning [openDate, departureDate]. Empty - * windows are kept so the UI shows every slot. Items whose timestamp falls - * outside the range still get their own window (nothing hidden). Items without - * a timestamp go to `pendingKey`. + * Group items into the real config booking-window cycles for a schedule. Empty + * windows are kept so the UI shows every cycle. Items whose timestamp falls + * outside every window (e.g. a booking created before the window opened) are + * attached to the nearest window by start time so nothing is hidden. Items + * without a timestamp go to `pendingKey`. */ export function groupBookingsIntoBoardWindows( items: T[], getTimestamp: (item: T) => Date | null | undefined, - openDate: Date, - departureDate: Date, + direction: string | null | undefined, + departure: Date, + cfg: BoardWindowConfig, pendingKey = 'pending-contract', ): Map { + const windows = listConfigBookingWindows(direction, departure, cfg); const map = new Map(); - - for (const w of listBoardWindowsForRange(openDate, departureDate)) { + for (const w of windows) { map.set(w.key, { window: w, items: [] }); } map.set(pendingKey, { window: null, items: [] }); + const firstWindow = windows[0] ?? null; + const lastWindow = windows[windows.length - 1] ?? null; + for (const item of items) { const ts = getTimestamp(item); if (!ts) { map.get(pendingKey)!.items.push(item); continue; } - const w = boardWindowForTimestamp(ts); - if (!map.has(w.key)) { - map.set(w.key, { window: w, items: [] }); + let w = configWindowForTimestamp(windows, ts); + if (!w) { + // Booked before the window opened → first cycle; after it closed → last cycle. + w = + firstWindow && ts.getTime() < firstWindow.start.getTime() + ? firstWindow + : lastWindow; + } + if (!w) { + map.get(pendingKey)!.items.push(item); + continue; } map.get(w.key)!.items.push(item); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index 5c9cb6119..d8dc6b116 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -1,20 +1,14 @@ /** * Tunables for the demand-batching booking → allocation flow. - * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. + * Times run in EAT so window boundaries match the local operating clock. + * + * Cadence and pay-window durations moved to the train_scheduling_global_rules + * table (TrainSchedulingService.getWindowConfig) — the window engine + * (BookingWindowService) drives all timing off that config. */ -/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ -// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; -// export const BATCH_CRON = '*/3 * * * *'; -export const BATCH_CRON = '*/5 * * * *'; -// export const BATCH_CRON = '0 */3 * * *';// - export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; -/** How long a selected commercial customer has to pay before their slot expires. */ -// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour -export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode) - /** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */ export const DEFAULT_WAGONS_PER_BOOKING = 1; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9e974b31e..f112d16d7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => { let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; getBookableSchedules: jest.Mock; + getWindowConfig: jest.Mock; }; let dataSource: { getRepository: jest.Mock; @@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => { violations: [], }), getBookableSchedules: jest.fn().mockResolvedValue([]), + getWindowConfig: jest.fn().mockResolvedValue({ + importWindowLeadDays: 3, + exportBookingLeadHours: 24, + windowOpenHour: 8, + windowDurationHours: 3, + docReviewMinutes: 30, + paymentWindowMinutes: 60, + reopenDelayMinutes: 90, + }), }; const bookingRepo = { @@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => { }) as unknown as Booking; beforeEach(() => { - // Two OPEN trains on the same route + day, train A earlier than train B. - trainSchedulingService.getBookableSchedules.mockResolvedValue([ + // Two OPEN legacy trains on the same route + day, train A earlier than train B. + // fillRouteDay now selects fillable schedules straight from the repository. + trainSchedulesRepository.findAll.mockResolvedValue([ { id: trainA, - scheduleDate: '2026-06-20T06:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, { id: trainB, - scheduleDate: '2026-06-20T09:00:00.000Z', + originStationId: originYardId, + destinationStationId: destinationYardId, + scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'), bookingWindowStatus: 'OPEN', + windowPhase: null, }, ]); trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) => @@ -252,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => { }), ); }); + + it('reserves both partners of a consolidated pair together on one train', async () => { + // Two 20ft bookings, 1 container each — a shared wagon. Both in the pool. + const consol = (id: string, partnerId: string, priority: number): Booking => + ({ + id, + reference: id, + isGovernment: false, + priorityScore: priority, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: partnerId, + bookingContainers: [{ quantity: 1 }], + }) as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([ + consol('a', 'b', 30), + consol('b', 'a', 20), + ]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Both reserved on the same (first) train; neither reported unplaced. + const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id); + expect(reservedIds.sort()).toEqual(['a', 'b']); + expect(notifier.unplaced).not.toHaveBeenCalled(); + }); + + it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => { + const lonely = { + id: 'a', + reference: 'a', + isGovernment: false, + priorityScore: 30, + status: 'FULLY_EXECUTED', + wagonsRequired: 1, + cargoTotalWeightVgm: 10, + freightType: 'CONTAINER', + consolidationPartnerId: 'missing-partner', + bookingContainers: [{ quantity: 1 }], + } as unknown as Booking; + + bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]); + + await service.fillRouteDay(originYardId, destinationYardId, day); + + // Never reserved — waits for its partner in a later cycle. + expect(notifier.payNow).not.toHaveBeenCalled(); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 218a20436..efa6b3259 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, Injectable, Logger, NotFoundException, @@ -7,8 +8,8 @@ import { Optional, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; -import { Cron, SchedulerRegistry } from '@nestjs/schedule'; -import { DataSource } from 'typeorm'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { DataSource, In } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; @@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService } from './train-scheduling.service'; import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util'; -import { Freight } from "@edr/types"; +import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types"; import { BillingService } from "../billing/billing.service"; import { - BATCH_CRON, - BATCH_TIMEZONE, DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_WAGONS_PER_BOOKING, - PAYMENT_WINDOW_MS, } from "./booking-batch.constants"; import { bookingTrainLengthMeters, @@ -41,6 +39,8 @@ import { } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; +import { BookingSplitService } from './booking-split.service'; +import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; /** A train's remaining capacity along the three physical limits the batch enforces. */ interface Capacity { @@ -90,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking { selectedForBatchAt: string | null; allocationStatus: BookingAllocationStatus; allocationIssue: string | null; + /** Set when this booking shares a wagon with a consolidation partner. */ + consolidationPartnerId: string | null; + consolidationPartnerRef: string | null; } export interface BatchWindowGroup { @@ -121,6 +124,13 @@ export interface BatchBoardScheduleDetail { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -138,6 +148,13 @@ export interface BatchBoardSchedule { scheduleDate: string | null; status: string; bookingWindowStatus: string; + direction: string | null; + windowPhase: string | null; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + bookingCycleNo: number; locomotive: { code: string; name: string | null; @@ -189,6 +206,7 @@ export class BookingBatchService implements OnModuleInit { private readonly billing: BillingService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, + @Optional() private readonly splitService?: BookingSplitService, ) {} @@ -284,11 +302,17 @@ export class BookingBatchService implements OnModuleInit { await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId); } - /** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */ + /** + * Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules — + * schedules with a `windowPhase` are driven exclusively by the window engine + * (BookingWindowService), never by the periodic legacy fill. + */ private async openRouteDayGroups(): Promise { - const open = await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, - }); + const open = ( + await this.trainSchedulesRepository.findAll({ + where: { bookingWindowStatus: "OPEN" }, + }) + ).filter((s) => s.windowPhase == null); const groups = new Map(); for (const s of open) { if (!s.scheduledDepartureDate) continue; @@ -340,6 +364,12 @@ export class BookingBatchService implements OnModuleInit { .update(bookingId, { paymentStatus: "PAID" }); } + // Paying inside the window accepts an open partial offer — reduce the booking + // to the offered part before it boards (remainder returns to the contract cap). + if (this.splitService) { + await this.splitService.applySplit(bookingId); + } + const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { @@ -381,6 +411,128 @@ export class BookingBatchService implements OnModuleInit { await this.ensurePaidBookingAllocated(bookingId); } + /** Open partial-capacity offer summary for booking detail payloads (null when none). */ + async getOpenOfferSummary(bookingId: string): Promise<{ + offeredWagons: number; + totalWagons: number; + offeredAmount: number; + paymentDeadline: Date; + } | null> { + if (!this.splitService) return null; + const offer = await this.splitService.findOpenOffer(bookingId); + if (!offer) return null; + return { + offeredWagons: offer.offeredWagons, + totalWagons: offer.totalWagons, + offeredAmount: Number(offer.offeredAmount), + paymentDeadline: offer.paymentDeadline, + }; + } + + // ---- export FCFS ----------------------------------------------------------- + + /** + * Export is first-come-first-serve: no window cycle, no priority, no batch. + * Pick the earliest open export train on the booking's corridor/day that still + * fits the booking. Throws ConflictException when every train is full — the + * staff accept fails and no more export bookings are taken. + */ + async pickExportSchedule(booking: Booking, need?: Capacity): Promise { + if (!booking.scheduledDate) { + throw new BadRequestException('Booking has no scheduled date'); + } + const day = eatDay(new Date(booking.scheduledDate)); + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: booking.originYardId, + destinationStationId: booking.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const candidates = corridor + .filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), + ) + .sort( + (a, b) => + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), + ); + if (!candidates.length) { + throw new ConflictException( + 'No export train is accepting bookings for this day', + ); + } + + const rules = await this.loadGlobalRules(); + const wagonLengths = await this.loadWagonLengths(); + const required = need ?? this.needFor(booking, wagonLengths); + for (const candidate of candidates) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( + candidate.id, + ); + const locomotive = schedule?.trainSet?.locomotive; + if (!schedule || !locomotive) continue; + const limits = await this.capacityLimits(locomotive, rules); + const budget = await this.remainingCapacity(schedule, limits, wagonLengths); + if (this.fits(required, budget)) return schedule.id; + } + throw new ConflictException('Train is full — no export capacity left for this day'); + } + + /** + * Accept an export booking into the FCFS flow. Solo bookings reserve immediately. + * A consolidated booking reserves as a pair only once BOTH partners are ready + * (FULLY_EXECUTED): the second partner's accept triggers the pair reservation + * against the combined shared-wagon need; the first partner's accept just waits. + * Throws ConflictException (before this booking is persisted-ready) when there is + * no export capacity for the day, so staff accept fails. + */ + async acceptExportBooking(booking: Booking): Promise { + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + const scheduleId = await this.pickExportSchedule(booking); + await this.reserveOnExport([booking], scheduleId); + return; + } + + const partner = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } }); + // Partner not yet accepted → this booking is now FULLY_EXECUTED and simply + // waits; the partner's later accept will reserve the pair. + if (!partner || partner.status !== 'FULLY_EXECUTED') { + return; + } + const wagonLengths = await this.loadWagonLengths(); + const need = this.combinedNeed(booking, partner, wagonLengths); + const scheduleId = await this.pickExportSchedule(booking, need); + await this.reserveOnExport([booking, partner], scheduleId); + } + + /** Reserve one or two (consolidated) export bookings on a train and open pay windows. */ + private async reserveOnExport( + bookings: Booking[], + scheduleId: string, + ): Promise { + for (const b of bookings) await this.reserve(b, scheduleId); + this.armSettle(scheduleId); + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule && (await this.remainingWagons(schedule)) <= 0) { + await this.setWindow(scheduleId, 'FULL'); + } + } + /** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */ async reconcilePaidUnlinked(scheduleId: string): Promise { const unlinked = @@ -393,9 +545,13 @@ export class BookingBatchService implements OnModuleInit { } } - // ---- cron entry point ----------------------------------------------------- + // ---- legacy fill entry point ---------------------------------------------- - @Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE }) + /** + * Legacy periodic fill for schedules without a window phase (DOMESTIC and + * pre-migration trains). Invoked by BookingWindowService's tick — the old + * standalone cron was replaced by the window engine. + */ async runBatchFill(): Promise { const groups = await this.openRouteDayGroups(); this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`); @@ -434,6 +590,9 @@ export class BookingBatchService implements OnModuleInit { const board: BatchBoardSchedule[] = []; for (const s of schedules) { if (s.status === "ARRIVED" || s.status === "CANCELLED") continue; + // Batch board is IMPORT-only: export is FCFS with no batch/priority calc, + // and domestic/legacy schedules run the legacy fill, not the window batch. + if (s.direction !== "IMPORT") continue; const links = await linkRepo.find({ where: { trainScheduleId: s.id } }); const linkedIds = new Set(links.map((l) => l.bookingId)); @@ -474,6 +633,12 @@ export class BookingBatchService implements OnModuleInit { if (s.status === "ARRIVED" || s.status === "CANCELLED") { throw new BadRequestException("Schedule is no longer active"); } + // Batch board is IMPORT-only (export is FCFS, no batch/priority calc). + if (s.direction !== "IMPORT") { + throw new BadRequestException( + "The batch board only covers import schedules", + ); + } const wagonLengths = await this.loadWagonLengths(); const linkRepo = this.dataSource.getRepository(TrainScheduleBooking); @@ -499,6 +664,27 @@ export class BookingBatchService implements OnModuleInit { allocationPreview.issues.map((i) => [i.bookingId, i]), ); + // Resolve consolidation-partner references for the shared-wagon badge. Most + // partners are on this same schedule; look up any that aren't in one query. + const refById = new Map( + bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]), + ); + const missingPartnerIds = [ + ...new Set( + bookings + .map((b) => b.consolidationPartnerId) + .filter((id): id is string => Boolean(id) && !refById.has(id!)), + ), + ]; + if (missingPartnerIds.length) { + const partners = await this.dataSource + .getRepository(Booking) + .find({ where: { id: In(missingPartnerIds) } }); + for (const p of partners) { + refById.set(p.id, p.reference ?? p.id.slice(0, 8)); + } + } + const items: BatchBoardBookingDetail[] = bookings.map((b) => { const need = this.needFor(b, wagonLengths); const alloc = allocationByBooking.get(b.id); @@ -524,20 +710,27 @@ export class BookingBatchService implements OnModuleInit { : null, allocationStatus: alloc?.status ?? "NOT_ATTEMPTED", allocationIssue: alloc?.issue ?? null, + consolidationPartnerId: b.consolidationPartnerId ?? null, + consolidationPartnerRef: b.consolidationPartnerId + ? (refById.get(b.consolidationPartnerId) ?? null) + : null, }; }); const loco = s.trainSet?.locomotive ?? null; - // Display windows span the whole booking window: from when it opened - // (schedule creation) through the scheduled departure, in 3-hour EAT slots. - const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date(); + // Display windows are the REAL booking-window cycles from the global-rules + // config (import: opens at windowOpenHour EAT importWindowLeadDays before + // departure, lasts windowDurationHours, reopens per reopenDelayMinutes; + // export: single FCFS lead window) — not a fixed clock grid. + const windowCfg = await this.trainSchedulingService.getWindowConfig(); const departureDate = s.scheduledDepartureDate ?? new Date(); const windowBuckets = groupBookingsIntoBoardWindows( items, (item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null), - openDate, + s.direction ?? null, departureDate, + windowCfg, ); const emptyCounts = () => ({ @@ -595,6 +788,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -679,6 +881,15 @@ export class BookingBatchService implements OnModuleInit { : null, status: s.status, bookingWindowStatus: s.bookingWindowStatus, + direction: s.direction ?? null, + windowPhase: s.windowPhase ?? null, + windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null, + windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null, + docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null, + paymentPhaseEndsAt: s.paymentPhaseEndsAt + ? s.paymentPhaseEndsAt.toISOString() + : null, + bookingCycleNo: s.bookingCycleNo ?? 0, locomotive: loco ? { code: loco.code, @@ -722,11 +933,27 @@ export class BookingBatchService implements OnModuleInit { // ---- core fill ------------------------------------------------------------ + /** + * Whether the batch engine may reserve/allocate onto this schedule right now. + * Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate. + * Import window cycle: the engine fills while the customer window is CLOSED — + * during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups). + * Export: FCFS while the booking window is open. + */ + isFillable(schedule: TrainSchedule): boolean { + if (schedule.bookingWindowStatus === "FULL") return false; + if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN"; + if (schedule.direction === "EXPORT") { + return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN"; + } + return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT"; + } + /** Fill one schedule from its priority-ordered pool until full. */ async fillSchedule(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - if (!schedule || schedule.bookingWindowStatus !== "OPEN") return; + if (!schedule || !this.isFillable(schedule)) return; const locomotive = schedule.trainSet?.locomotive; if (!schedule.trainSetId || !locomotive) { this.logger.warn( @@ -746,13 +973,19 @@ export class BookingBatchService implements OnModuleInit { } const pool = await this.bookingsRepository.findBatchPool(scheduleId); + const units = this.groupConsolidatedPool(pool); let armed = false; - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); if (!this.fits(need, budget)) { - if (booking.isGovernment) { + if (isGov) { budget = await this.preemptForGovernment( scheduleId, need, @@ -761,14 +994,16 @@ export class BookingBatchService implements OnModuleInit { ); if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt } else { - continue; // skip a booking that exceeds weight/length/wagons, try the next + continue; // skip a unit that exceeds weight/length/wagons, try the next } } - if (booking.isGovernment) { + if (isGov) { await this.allocate(scheduleId, booking, "gov"); + if (partner) await this.allocate(scheduleId, partner, "gov"); } else { await this.reserve(booking, scheduleId); + if (partner) await this.reserve(partner, scheduleId); armed = true; } budget = this.subtract(budget, need); @@ -793,22 +1028,33 @@ export class BookingBatchService implements OnModuleInit { destinationYardId: string, day: string, ): Promise { - // The day's OPEN bookable schedules on this exact corridor, earliest first. - const bookable = await this.trainSchedulingService.getBookableSchedules( - originYardId, - destinationYardId, - ); - const scheduleIds = bookable + // The day's fillable schedules on this exact corridor, earliest first. Fillable + // covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT — + // the batch must run while the customer window is closed. + const corridor = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: originYardId, + destinationStationId: destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const scheduleIds = corridor .filter( (s) => - s.bookingWindowStatus === "OPEN" && - s.scheduleDate != null && - eatDay(new Date(s.scheduleDate)) === day, + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + this.isFillable(s), ) .sort( (a, b) => - new Date(a.scheduleDate).getTime() - - new Date(b.scheduleDate).getTime(), + a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(), ) .map((s) => s.id); @@ -845,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit { destinationYardId, day, ); + // Consolidated partners collapse into one atomic unit (both-or-neither); a + // consolidated booking whose partner isn't ready this cycle is skipped. + const units = this.groupConsolidatedPool(pool); - for (const booking of pool) { - const need = this.needFor(booking, wagonLengths); + for (const unit of units) { + const { primary: booking, partner } = unit; + const isPair = partner != null; + const need = isPair + ? this.combinedNeed(booking, partner, wagonLengths) + : this.needFor(booking, wagonLengths); + const isGov = booking.isGovernment || (partner?.isGovernment ?? false); - // First train (earliest departure) that fits this booking as-is. + // First train (earliest departure) that fits this unit as-is. let target = trains.find((t) => this.fits(need, t.budget)); - if (!target && booking.isGovernment) { - // Government booking fits nowhere on its own — try to preempt commercial + if (!target && isGov) { + // Government fits nowhere on its own — try to preempt commercial // on each train (earliest first) until one frees enough room. for (const t of trains) { t.budget = await this.preemptForGovernment( @@ -870,15 +1124,45 @@ export class BookingBatchService implements OnModuleInit { } if (!target) { - // Fits no train this day — stays in the pool, retried next batch. + // A consolidated pair is placed whole or not at all — never split. + if (!isPair) { + // Fits no train whole. Import GENERAL-contract commercial bookings get a + // partial-capacity offer on the train with the most free wagons. + const partialTarget = [...trains] + .filter((t) => t.budget.wagons >= 1) + .sort((a, b) => b.budget.wagons - a.budget.wagons)[0]; + if ( + partialTarget && + !booking.isGovernment && + booking.tradeDirection === "IMPORT" && + booking.contractKind === "GENERAL" && + this.splitService + ) { + const offered = await this.tryPartialOffer( + booking, + partialTarget.id, + partialTarget.budget, + need, + ); + if (offered) { + partialTarget.budget = this.subtract(partialTarget.budget, offered); + partialTarget.armed = true; + continue; + } + } + } + // Stays in the pool, retried next batch/window cycle. this.notifier.unplaced(booking, day); + if (partner) this.notifier.unplaced(partner, day); continue; } - if (booking.isGovernment) { + if (isGov) { await this.allocate(target.id, booking, "gov"); + if (partner) await this.allocate(target.id, partner, "gov"); } else { await this.reserve(booking, target.id); + if (partner) await this.reserve(partner, target.id); target.armed = true; } target.budget = this.subtract(target.budget, need); @@ -893,29 +1177,127 @@ export class BookingBatchService implements OnModuleInit { return trains.map((t) => t.id); } - /** Durable settle: allocate paid / expire overdue reservations, then top up. */ - async settleDueReservations(scheduleId: string): Promise { + /** + * Offer the largest fitting part of an over-capacity booking as a partial + * (split-on-payment). Returns the capacity the offer consumes, or null when no + * meaningful partial fits / an offer is already open. + */ + private async tryPartialOffer( + booking: Booking, + scheduleId: string, + budget: Capacity, + need: Capacity, + ): Promise { + if (!this.splitService) return null; + // A consolidated booking is already half of a shared wagon — never split it. + if (booking.consolidationPartnerId) return null; + if (await this.splitService.findOpenOffer(booking.id)) return null; + + const wagonLengths = await this.loadWagonLengths(); + const bulkCapacityTons = await this.loadBulkWagonCapacityTons(); + const sized = await this.splitService.sizeOffer( + booking, + budget.wagons, + need.wagons, + bulkCapacityTons, + ); + if (!sized) return null; + + const offeredNeed: Capacity = { + wagons: sized.offeredWagons, + weightTons: sized.offeredWeightTons, + lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + if (!this.fits(offeredNeed, budget)) return null; + + const deadline = new Date(Date.now() + (await this.paymentWindowMs())); + await this.splitService.createOffer(booking, scheduleId, sized, deadline); + // Reserve like a normal batch selection, but the partial invoice + partial + // pay-now notification were already produced by createOffer. + await this.bookingsRepository.update(booking.id, { + trainScheduleId: scheduleId, + status: "SELECTED_FOR_BATCH", + selectedForBatchAt: new Date(), + paymentDeadline: deadline, + } as never); + booking.trainScheduleId = scheduleId; + return offeredNeed; + } + + private async loadBulkWagonCapacityTons(): Promise { + const cw3 = await this.dataSource + .getRepository(WagonType) + .findOne({ where: { code: "CW3" } }); + const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60; + return capacity > 0 ? capacity : 60; + } + + /** + * Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides + * how to treat a reservation with no deadline (durable path: leave it; timeout + * path: expire it). Consolidated pairs settle atomically: both allocate only + * when both paid; if either partner expires, both expire (a half-paid shared + * wagon must not ship). Returns whether anything changed. + */ + private async settleReserved( + scheduleId: string, + expireUnpaidUnknownDeadline: boolean, + ): Promise { const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId); const now = Date.now(); + const byId = new Map(reserved.map((b) => [b.id, b])); + const done = new Set(); let anySettled = false; - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : false; + const isPaid = (b: Booking) => + b.paymentStatus === "PAID" || b.status === "PAID"; + const isExpired = (b: Booking) => + b.paymentDeadline + ? b.paymentDeadline.getTime() <= now + : expireUnpaidUnknownDeadline; - if (paid) { + for (const booking of reserved) { + if (done.has(booking.id)) continue; + const partner = booking.consolidationPartnerId + ? (byId.get(booking.consolidationPartnerId) ?? null) + : null; + + if (partner) { + done.add(booking.id); + done.add(partner.id); + // Both-or-neither: allocate the shared wagon only when both partners paid; + // if either lapsed, expire both so no half-paid wagon rides. + if (isPaid(booking) && isPaid(partner)) { + await this.allocate(scheduleId, booking, "paid"); + await this.allocate(scheduleId, partner, "paid"); + anySettled = true; + } else if (isExpired(booking) || isExpired(partner)) { + await this.expire(booking); + await this.expire(partner); + anySettled = true; + } + continue; + } + + done.add(booking.id); + if (isPaid(booking)) { await this.allocate(scheduleId, booking, "paid"); anySettled = true; - } else if (expired) { + } else if (isExpired(booking)) { await this.expire(booking); anySettled = true; } } + return anySettled; + } + /** Durable settle: allocate paid / expire overdue reservations, then top up. */ + async settleDueReservations(scheduleId: string): Promise { + const anySettled = await this.settleReserved(scheduleId, false); if (anySettled) await this.fillSchedule(scheduleId); } @@ -924,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit { /** Allocate paid reservations, expire the rest, then top up. */ async settleBatch(scheduleId: string): Promise { this.removeTimeout(scheduleId); - const reserved = - await this.bookingsRepository.findReservedForSchedule(scheduleId); - const now = Date.now(); - - for (const booking of reserved) { - const paid = - booking.paymentStatus === "PAID" || booking.status === "PAID"; - const expired = booking.paymentDeadline - ? booking.paymentDeadline.getTime() <= now - : true; - - if (paid) { - await this.allocate(scheduleId, booking, "paid"); - } else if (expired) { - await this.expire(booking); - } - // else: still within window (rare at settle) → leave for the re-armed timeout - } - + await this.settleReserved(scheduleId, true); await this.fillSchedule(scheduleId); void this.triggerWagonAllocation(scheduleId); } @@ -1063,7 +1427,7 @@ export class BookingBatchService implements OnModuleInit { */ private async reserve(booking: Booking, scheduleId: string): Promise { const now = new Date(); - const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS); + const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -1136,6 +1500,10 @@ export class BookingBatchService implements OnModuleInit { selectedForBatchAt: null, } as never); booking.trainScheduleId = null; + // An unpaid partial offer dies with the reservation — the booking stays whole. + if (this.splitService) { + await this.splitService.expireOpenOffer(booking.id); + } // Pay window closed before settlement → expire the booking's open invoice too // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. @@ -1198,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit { // ---- capacity helpers ----------------------------------------------------- + /** + * Collapse consolidated partners into single pool entries so the fill treats a + * shared-wagon pair as one atomic unit (both-or-neither). For each pool entry: + * - no `consolidationPartnerId` → passes through as a lone booking. + * - consolidated + partner also in this pool → emitted ONCE (at the position of + * whichever partner ranks first) as a pair; the partner is not emitted again. + * - consolidated + partner NOT in this pool → dropped (can't ship half a wagon; + * it waits for the partner to become ready in a later cycle). + * The pool is already priority-ordered, so emitting the pair at the first-seen + * partner's slot ranks it by the stronger (max-priority) partner automatically. + */ + private groupConsolidatedPool( + pool: Booking[], + ): Array<{ primary: Booking; partner: Booking | null }> { + const byId = new Map(pool.map((b) => [b.id, b])); + const emitted = new Set(); + const units: Array<{ primary: Booking; partner: Booking | null }> = []; + for (const booking of pool) { + if (emitted.has(booking.id)) continue; + const partnerId = booking.consolidationPartnerId ?? null; + if (!partnerId) { + emitted.add(booking.id); + units.push({ primary: booking, partner: null }); + continue; + } + const partner = byId.get(partnerId) ?? null; + if (!partner) { + // Both-or-neither: partner not ready in this pool → skip the pair entirely. + emitted.add(booking.id); + continue; + } + emitted.add(booking.id); + emitted.add(partner.id); + units.push({ primary: booking, partner }); + } + return units; + } + + /** + * Combined capacity need of a consolidated pair sharing wagons. The whole point of + * consolidation is that the two partial 20ft counts pack onto the SAME wagons, so + * the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two + * independently-rounded-up needs (that is the capacity consolidation saves). + */ + private combinedNeed( + primary: Booking, + partner: Booking, + wagonLengths: WagonLengths, + ): Capacity { + const containers = (b: Booking): number => + (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); + const totalContainers = containers(primary) + containers(partner); + const sharedWagons = + totalContainers > 0 + ? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON) + : this.wagonsFor(primary) + this.wagonsFor(partner); + const weightTons = + Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + return { + wagons: sharedWagons, + weightTons, + lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, { + container: wagonLengths.container, + bulk: wagonLengths.bulk, + }), + }; + } + private wagonsFor(booking: Booking): number { if (booking.wagonsRequired && booking.wagonsRequired > 0) { return Math.ceil(booking.wagonsRequired); @@ -1359,7 +1795,7 @@ export class BookingBatchService implements OnModuleInit { return (schedule.maxWagons ?? 0) - used; } - private async setWindow( + async setWindow( scheduleId: string, status: "OPEN" | "FULL" | "CLOSED", ): Promise { @@ -1368,22 +1804,48 @@ export class BookingBatchService implements OnModuleInit { .update(scheduleId, { bookingWindowStatus: status }); } + /** No wagon slots left for allocated + reserved bookings. */ + async isScheduleFull(scheduleId: string): Promise { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) return false; + return (await this.remainingWagons(schedule)) <= 0; + } + // ---- timer plumbing ------------------------------------------------------- + /** Configured customer pay window in ms (global rules, with defaults). */ + private async paymentWindowMs(): Promise { + const cfg = await this.trainSchedulingService.getWindowConfig(); + return cfg.paymentWindowMinutes * 60_000; + } + private timeoutName(scheduleId: string): string { return `settle:${scheduleId}`; } + /** + * In-process accelerator only — the durable settle enforcement is the window + * engine's minute tick calling settleDueReservations off `paymentDeadline`. + */ private armSettle(scheduleId: string): void { - this.removeTimeout(scheduleId); - const handle = setTimeout(() => { - void this.settleBatch(scheduleId).catch((err) => - this.logger.error( - `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + void this.paymentWindowMs() + .then((delayMs) => { + this.removeTimeout(scheduleId); + const handle = setTimeout(() => { + void this.settleBatch(scheduleId).catch((err) => + this.logger.error( + `settleBatch ${scheduleId} failed: ${(err as Error).message}`, + ), + ); + }, delayMs); + this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); + }) + .catch((err) => + this.logger.warn( + `armSettle ${scheduleId} skipped: ${(err as Error).message}`, ), ); - }, PAYMENT_WINDOW_MS); - this.scheduler.addTimeout(this.timeoutName(scheduleId), handle); } private removeTimeout(scheduleId: string): void { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index e8f272123..49df19758 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; -import { PAYMENT_WINDOW_MS } from './booking-batch.constants'; @Injectable() export class BookingNotifierService { @@ -43,12 +42,32 @@ export class BookingNotifierService { } async payNow(b: Booking, deadline: Date): Promise { - const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000); + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`; await this.notifyContact(b, msg, 'PAY NOW'); } + /** + * Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit + * this train. Paying accepts the split; letting the deadline pass keeps the + * booking whole and expires it for this train. + */ + async payNowPartial( + b: Booking, + deadline: Date, + offeredWagons: number, + totalWagons: number, + ): Promise { + const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000)); + const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' }); + const msg = + `Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` + + `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` + + `(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`; + await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)'); + } + secured(b: Booking, reason: 'paid' | 'gov'): void { const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ reason === 'gov' ? ' (government)' : '' diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts new file mode 100644 index 000000000..2cd2df6d9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts @@ -0,0 +1,270 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { Freight } from '@edr/types'; + +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { BookingInvoiceService } from '../bookings/booking-invoice.service'; +import { BillingService } from '../billing/billing.service'; +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 { + BookingBatchOffer, + OfferedLine, +} from './entities/booking-batch-offer.entity'; +import { BookingNotifierService } from './booking-notifier.service'; + +export interface SizedOffer { + offeredWagons: number; + totalWagons: number; + offeredLines: OfferedLine[] | null; + offeredWeightTons: number; + offeredAmount: number; + offeredPricingBreakdown: Record; +} + +/** + * Partial-capacity booking splits (import batch). The offer is sized and priced + * against an in-memory clone — the booking row is untouched until the customer + * pays, which is the act of accepting the split (applySplit). No payment → + * offer expires and the booking stays whole. + * + * Only GENERAL-contract commercial bookings are offered partials: the remainder + * returns to the contract's quantity cap (derived live from booking_container + * rows, so reducing the lines releases it automatically) and can be rebooked in + * any later window within contract validity. + */ +@Injectable() +export class BookingSplitService { + private readonly logger = new Logger(BookingSplitService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + @Inject(forwardRef(() => BookingPricingService)) + private readonly pricing: BookingPricingService, + @Inject(forwardRef(() => BookingInvoiceService)) + private readonly invoiceService: BookingInvoiceService, + private readonly billing: BillingService, + private readonly notifier: BookingNotifierService, + ) {} + + /** + * Size the largest part of the booking that fits `freeWagons`, priced via an + * in-memory clone. Returns null when nothing meaningful fits (no whole + * container unit / no bulk tonnage, or pricing failed). + */ + async sizeOffer( + booking: Booking, + freeWagons: number, + totalWagons: number, + bulkWagonCapacityTons: number, + ): Promise { + if (freeWagons < 1 || freeWagons >= totalWagons) return null; + + const containers = booking.bookingContainers ?? []; + let offeredLines: OfferedLine[] | null = null; + let offeredWeightTons = 0; + let offeredWagons = 0; + const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking); + clone.adjustedTotalAmount = null; + + if (containers.length) { + offeredLines = []; + let remaining = freeWagons; + const clonedContainers: BookingContainer[] = []; + for (const line of containers) { + const quantity = Number(line.quantity ?? 0); + const lineWagons = Number(line.wagonsRequired ?? 0); + if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue; + const perUnit = lineWagons / quantity; + // Largest unit count whose wagon need still fits the remaining budget. + let take = Math.min(quantity, Math.floor(remaining / perUnit)); + while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1; + if (take <= 0) continue; + const takeWagons = Math.ceil(take * perUnit); + const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0); + offeredLines.push({ + bookingContainerId: line.id, + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000, + }); + offeredWeightTons += take * vgmPerUnit; + offeredWagons += takeWagons; + remaining -= takeWagons; + + const clonedLine: BookingContainer = Object.assign( + Object.create(Object.getPrototypeOf(line)), + line, + { + quantity: take, + wagonsRequired: takeWagons, + totalVgmTons: take * vgmPerUnit, + }, + ); + clonedContainers.push(clonedLine); + } + if (!offeredLines.length || offeredWagons <= 0) return null; + clone.bookingContainers = clonedContainers; + } else { + // Bulk: split by weight — the offered part is what freeWagons can carry. + const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0); + if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null; + offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons); + if (offeredWeightTons <= 0) return null; + offeredWagons = Math.min( + freeWagons, + Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)), + ); + } + + offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000; + clone.cargoTotalWeightVgm = offeredWeightTons; + clone.wagonsRequired = offeredWagons; + + try { + const priced = await this.pricing.computePriceForBooking(clone); + return { + offeredWagons, + totalWagons, + offeredLines, + offeredWeightTons, + offeredAmount: priced.totalAmount, + offeredPricingBreakdown: { + lineItems: priced.lineItems, + totalAmount: priced.totalAmount, + currency: priced.currency, + generatedAt: new Date().toISOString(), + partialOfWagons: totalWagons, + }, + }; + } catch (err) { + this.logger.warn( + `Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`, + ); + return null; + } + } + + /** + * Persist the offer and swap the booking's payable to a partial invoice for the + * offered amount. Any previous open offer for the booking is superseded. + */ + async createOffer( + booking: Booking, + scheduleId: string, + sized: SizedOffer, + deadline: Date, + ): Promise { + const repo = this.dataSource.getRepository(BookingBatchOffer); + await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' }); + + // The full-amount invoice must not stay payable next to the partial one. + await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID'); + const invoice = await this.invoiceService.ensureInvoiceForBooking( + { ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking, + { dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending }, + ); + + const offer = await repo.save( + repo.create({ + bookingId: booking.id, + trainScheduleId: scheduleId, + offeredWagons: sized.offeredWagons, + totalWagons: sized.totalWagons, + offeredLines: sized.offeredLines, + offeredWeightTons: sized.offeredWeightTons, + offeredAmount: sized.offeredAmount, + offeredPricingBreakdown: sized.offeredPricingBreakdown, + invoiceId: invoice.id, + paymentDeadline: deadline, + status: 'OFFERED', + }), + ); + await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons); + return offer; + } + + /** + * Payment received inside the window — the customer accepted the split. + * Reduce the booking to the offered lines/weight; the remainder returns to the + * contract cap automatically (bookedQuantities derives from live lines). + * Idempotent: no OFFERED offer → no-op. + */ + async applySplit(bookingId: string): Promise { + const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + if (!offer) return; + + await this.dataSource.transaction(async (manager) => { + if (offer.offeredLines?.length) { + const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l])); + const lines = await manager.getRepository(BookingContainer).find({ + where: { bookingId }, + }); + for (const line of lines) { + const kept = keptByLine.get(line.id); + if (!kept) { + await manager.getRepository(BookingContainer).softDelete(line.id); + await manager + .getRepository(BookingContainerUnit) + .softDelete({ bookingContainerId: line.id }); + continue; + } + const dropCount = Number(line.quantity) - kept.quantity; + await manager.getRepository(BookingContainer).update(line.id, { + quantity: kept.quantity, + wagonsRequired: kept.wagonsRequired, + totalVgmTons: kept.totalVgmTons, + hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity), + reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity), + }); + if (dropCount > 0) { + // Trim surplus physical units, last-entered first. + const units = await manager.getRepository(BookingContainerUnit).find({ + where: { bookingContainerId: line.id }, + order: { sortOrder: 'DESC', createdAt: 'DESC' }, + take: dropCount, + }); + if (units.length) { + await manager + .getRepository(BookingContainerUnit) + .softDelete(units.map((u) => u.id)); + } + } + } + } + + await manager.getRepository(Booking).update(bookingId, { + wagonsRequired: offer.offeredWagons, + cargoTotalWeightVgm: offer.offeredWeightTons, + totalAmount: offer.offeredAmount, + pricingBreakdown: offer.offeredPricingBreakdown, + } as never); + + await manager + .getRepository(BookingBatchOffer) + .update(offer.id, { status: 'APPLIED' }); + }); + this.logger.log( + `Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`, + ); + } + + /** Pay window closed without payment — offer dies, booking stays whole. */ + async expireOpenOffer(bookingId: string): Promise { + await this.dataSource + .getRepository(BookingBatchOffer) + .update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' }); + } + + async findOpenOffer(bookingId: string): Promise { + return this.dataSource.getRepository(BookingBatchOffer).findOne({ + where: { bookingId, status: 'OFFERED' }, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts new file mode 100644 index 000000000..c694eed11 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -0,0 +1,30 @@ +/** + * Booking-window timings sourced from the train_scheduling_global_rules singleton, + * with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig). + */ +export interface BookingWindowConfig { + /** Days before departure the single import booking-window day falls on. */ + importWindowLeadDays: number; + /** Hours before departure an export booking becomes acceptable (FCFS). */ + exportBookingLeadHours: number; + /** Local (Africa/Addis_Ababa) hour at which the import window opens. */ + windowOpenHour: number; + windowDurationHours: number; + /** Max staff document-review time after the window closes. */ + docReviewMinutes: number; + paymentWindowMinutes: number; + /** Delay after window close before reopening when the train is not full. */ + reopenDelayMinutes: number; +} + +/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */ +export const WINDOW_PHASES = [ + 'PRE_WINDOW', + 'OPEN', + 'DOC_REVIEW', + 'PAYMENT', + 'CLOSED_FOR_DAY', + 'DONE', +] as const; + +export type WindowPhase = (typeof WINDOW_PHASES)[number]; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts new file mode 100644 index 000000000..f2fe5db07 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -0,0 +1,346 @@ +import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types'; + +import { Booking } from '../bookings/entities/booking.entity'; +import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; +import { BookingBatchService } from './booking-batch.service'; +import { TrainSchedulingService } from './train-scheduling.service'; +import { BATCH_TIMEZONE } from './booking-batch.constants'; +import { eatDay } from './batch-window.util'; +import { type BookingWindowConfig } from './booking-window.config'; + +/** + * Drives the one-booking-day window cycle for IMPORT schedules and the FCFS + * booking window for EXPORT schedules. All state lives in DB timestamps on the + * schedule row, so every transition is derived purely from the clock — a restart + * resumes mid-phase with no loss (onModuleInit runs one tick immediately). + * + * Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept + * documents) → PAYMENT (batch reserves in priority order, customers pay) → + * reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized). + * Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority). + * Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy + * fill (runBatchFill), which this tick invokes every 5th minute. + */ +@Injectable() +export class BookingWindowService implements OnModuleInit { + private readonly logger = new Logger(BookingWindowService.name); + private ticking = false; + private tickCount = 0; + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly trainSchedulesRepository: TrainSchedulesRepository, + private readonly bookingBatchService: BookingBatchService, + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + async onModuleInit(): Promise { + await this.tick().catch((err) => + this.logger.warn(`Boot window tick failed: ${(err as Error).message}`), + ); + } + + @Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) + async tick(): Promise { + if (this.ticking) return; + this.ticking = true; + try { + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + + const active = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { status: TrainScheduleStatusEnum.Draft }, + { status: TrainScheduleStatusEnum.Scheduled }, + ], + }) + ).filter( + (s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY', + ); + + for (const schedule of active) { + try { + await this.advanceSchedule(schedule, cfg, now); + } catch (err) { + this.logger.error( + `Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`, + ); + } + } + + await this.settleOverdueReservations(); + + // Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. + this.tickCount += 1; + if (this.tickCount % 5 === 0) { + await this.bookingBatchService.runBatchFill(); + } + } finally { + this.ticking = false; + } + } + + /** Staff finished document review early — start the batch/payment phase now. */ + async completeDocReview(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (schedule.windowPhase !== 'DOC_REVIEW') { + // Idempotent for the whole route-day group: only DOC_REVIEW schedules move. + return schedule; + } + const now = new Date(); + const cfg = await this.trainSchedulingService.getWindowConfig(); + // Stamp the whole route-day group so one staff action releases every train + // sharing this booking day's pool. + const group = ( + await this.trainSchedulesRepository.findAll({ + where: { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + }, + }) + ).filter( + (s) => + s.windowPhase === 'DOC_REVIEW' && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate), + ); + for (const s of group) { + await this.dataSource + .getRepository(TrainSchedule) + .update(s.id, { docReviewCompletedAt: now }); + s.docReviewCompletedAt = now; + await this.advanceSchedule(s, cfg, now); + } + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } + + // ---- transitions ------------------------------------------------------------ + + private async advanceSchedule( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + // Apply every transition that is due, in order (fast-forwards after downtime). + for (let guard = 0; guard < 6; guard += 1) { + const advanced = + schedule.direction === 'EXPORT' + ? await this.advanceExport(schedule, now) + : await this.advanceImport(schedule, cfg, now); + if (!advanced) return; + } + } + + /** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */ + private async advanceExport(schedule: TrainSchedule, now: Date): Promise { + if ( + schedule.windowPhase === 'PRE_WINDOW' && + schedule.windowOpensAt && + now >= schedule.windowOpensAt + ) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log(`Export booking window opened for schedule ${schedule.id}`); + return true; + } + if ( + schedule.windowPhase === 'OPEN' && + schedule.windowClosesAt && + now >= schedule.windowClosesAt + ) { + await this.setPhase(schedule, { windowPhase: 'DONE' }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + return true; + } + return false; + } + + private async advanceImport( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const { windowPhase, windowOpensAt, windowClosesAt } = schedule; + + if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) { + await this.setPhase(schedule, { + windowPhase: 'OPEN', + bookingCycleNo: schedule.bookingCycleNo + 1, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + if (schedule.bookingWindowStatus !== 'FULL') { + await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); + schedule.bookingWindowStatus = 'OPEN'; + } + this.logger.log( + `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, + ); + return true; + } + + if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) { + const docReviewEndsAt = new Date( + windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000, + ); + await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt }); + if (schedule.bookingWindowStatus === 'OPEN') { + await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); + schedule.bookingWindowStatus = 'CLOSED'; + } + this.logger.log( + `Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'DOC_REVIEW' && + (schedule.docReviewCompletedAt != null || + (schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt)) + ) { + const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000); + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + // Run the batch: priority fill over the route-day pool, reserving pay windows + // (or allocating government) — skipped automatically for everyone who fits + // is handled inside the fill (all fit → all reserved → all notified). + await this.bookingBatchService.processRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day: eatDay(schedule.scheduledDepartureDate), + }); + this.logger.log( + `Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`, + ); + return true; + } + + if ( + windowPhase === 'PAYMENT' && + schedule.paymentPhaseEndsAt != null && + now >= schedule.paymentPhaseEndsAt + ) { + await this.bookingBatchService.settleDueReservations(schedule.id); + await this.concludeCycle(schedule, cfg, now); + return true; + } + + return false; + } + + /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + private async concludeCycle( + schedule: TrainSchedule, + cfg: BookingWindowConfig, + now: Date, + ): Promise { + const full = await this.bookingBatchService.isScheduleFull(schedule.id); + if (full) { + await this.bookingBatchService.setWindow(schedule.id, 'FULL'); + await this.setPhase(schedule, { windowPhase: 'DONE' }); + await this.tryAutoFinalize(schedule.id); + return; + } + + const closesAt = schedule.windowClosesAt ?? now; + const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000); + const nextOpensAt = reopenAt > now ? reopenAt : now; + let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000); + if (nextClosesAt > schedule.scheduledDepartureDate) { + nextClosesAt = schedule.scheduledDepartureDate; + } + + const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt); + const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate; + if (sameBookingDay && beforeDeparture) { + await this.setPhase(schedule, { + windowPhase: 'PRE_WINDOW', + windowOpensAt: nextOpensAt, + windowClosesAt: nextClosesAt, + docReviewCompletedAt: null, + docReviewEndsAt: null, + paymentPhaseEndsAt: null, + }); + this.logger.log( + `Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`, + ); + } else { + await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' }); + this.logger.log( + `Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`, + ); + } + } + + private async tryAutoFinalize(scheduleId: string): Promise { + try { + await this.trainSchedulingService.finalizeSchedule(scheduleId); + this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`); + } catch (err) { + // Not DRAFT / no linked bookings yet — staff finalize manually. + this.logger.warn( + `Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + + /** Durable settle backstop: expire/allocate reservations whose deadline passed. */ + private async settleOverdueReservations(): Promise { + const overdue = await this.dataSource + .getRepository(Booking) + .createQueryBuilder('b') + .select('DISTINCT b.train_schedule_id', 'scheduleId') + .where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`) + .andWhere('b.payment_deadline <= now()') + .andWhere('b.train_schedule_id IS NOT NULL') + .getRawMany<{ scheduleId: string }>(); + for (const { scheduleId } of overdue) { + try { + await this.bookingBatchService.settleDueReservations(scheduleId); + } catch (err) { + this.logger.warn( + `Overdue settle failed for ${scheduleId}: ${(err as Error).message}`, + ); + } + } + } + + private async setPhase( + schedule: TrainSchedule, + patch: Partial< + Pick< + TrainSchedule, + | 'windowPhase' + | 'windowOpensAt' + | 'windowClosesAt' + | 'docReviewEndsAt' + | 'docReviewCompletedAt' + | 'paymentPhaseEndsAt' + | 'bookingCycleNo' + > + >, + ): Promise { + await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch); + Object.assign(schedule, patch); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts new file mode 100644 index 000000000..25943050c --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-import-loading-status.dto.ts @@ -0,0 +1,15 @@ +import { LoadingStatus } from '@edr/types'; +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayMinSize, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class UpdateImportLoadingStatusDto { + @ApiProperty({ type: [String] }) + @IsArray() + @ArrayMinSize(1) + @IsUUID('4', { each: true }) + bookingIds!: string[]; + + @ApiProperty({ enum: LoadingStatus }) + @IsEnum(LoadingStatus) + loadingStatus!: LoadingStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index d47195976..1171b0c90 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -1,6 +1,6 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNumber, IsOptional, Min } from 'class-validator'; +import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator'; export class UpdateTrainSchedulingGlobalRulesDto { @ApiPropertyOptional({ example: 760 }) @@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsNumber() @Min(0) max20ftPairWeightDiffTons?: number; + + @ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + importWindowLeadDays?: number; + + @ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + exportBookingLeadHours?: number; + + @ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + @Max(23) + windowOpenHour?: number; + + @ApiPropertyOptional({ example: 3 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0.25) + @Max(12) + windowDurationHours?: number; + + @ApiPropertyOptional({ example: 30 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + docReviewMinutes?: number; + + @ApiPropertyOptional({ example: 60 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + paymentWindowMinutes?: number; + + @ApiPropertyOptional({ example: 90 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts new file mode 100644 index 000000000..4b6f7c685 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/booking-batch-offer.entity.ts @@ -0,0 +1,75 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; + +export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const; +export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number]; + +/** One reduced container line of a partial offer (per original booking_container row). */ +export interface OfferedLine { + bookingContainerId: string; + /** Units of this line that ride the offered train (≤ original quantity). */ + quantity: number; + wagonsRequired: number; + totalVgmTons: number; +} + +/** + * A partial-capacity payment offer made by the batch when a booking needs more + * wagons than the train has left (e.g. needs 20, 3 free). The booking itself is + * NOT mutated at offer time — paying inside the window accepts the split + * (BookingSplitService.applySplit reduces the booking to the offered lines and + * the remainder returns to the contract's quantity cap); letting the deadline + * pass expires the offer and the booking stays whole. + */ +@Entity({ schema: 'freight', name: 'booking_batch_offers' }) +@Index(['bookingId']) +@Index(['trainScheduleId']) +@Index(['status']) +export class BookingBatchOffer extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'train_schedule_id' }) + trainSchedule?: TrainSchedule; + + @Column({ name: 'offered_wagons', type: 'int' }) + offeredWagons!: number; + + /** Booking's full wagon need at offer time (for messaging / audit). */ + @Column({ name: 'total_wagons', type: 'int' }) + totalWagons!: number; + + /** Reduced container lines (null for bulk offers — bulk splits by weight). */ + @Column({ name: 'offered_lines', type: 'jsonb', nullable: true }) + offeredLines?: OfferedLine[] | null; + + @Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 }) + offeredWeightTons!: number; + + @Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 }) + offeredAmount!: number; + + @Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true }) + offeredPricingBreakdown?: Record | null; + + /** The partial PREPAID invoice generated for the offered part. */ + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'payment_deadline', type: 'timestamptz' }) + paymentDeadline!: Date; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' }) + status!: BookingBatchOfferStatus; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index 326915933..7b8d9b26a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity { default: 10, }) max20ftPairWeightDiffTons!: number; + + /** Days before departure the single import booking-window day falls on. */ + @Column({ name: 'import_window_lead_days', type: 'int', default: 3 }) + importWindowLeadDays!: number; + + /** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */ + @Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 }) + exportBookingLeadHours!: number; + + /** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */ + @Column({ name: 'window_open_hour', type: 'int', default: 8 }) + windowOpenHour!: number; + + @Column({ + name: 'window_duration_hours', + type: 'numeric', + precision: 4, + scale: 2, + default: 3, + }) + windowDurationHours!: number; + + /** Max time staff have to accept booking documents after the window closes. */ + @Column({ name: 'doc_review_minutes', type: 'int', default: 30 }) + docReviewMinutes!: number; + + @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) + paymentWindowMinutes!: number; + + /** Delay after window close before the window reopens when the train is not yet full. */ + @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) + reopenDelayMinutes!: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 1c15ab801..d0595c0da 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; @@ -43,6 +44,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; import { TrainSchedulingService } from "./train-scheduling.service"; import { BookingBatchService } from "./booking-batch.service"; +import { BookingWindowService } from "./booking-window.service"; +import { BillingService } from "../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() @@ -51,8 +54,23 @@ export class TrainSchedulingController { constructor( private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, + private readonly bookingWindowService: BookingWindowService, + private readonly billingService: BillingService, ) { } + @Get("my-booking-windows") + @ApiOperation({ + summary: + "Upcoming/open booking windows on the signed-in customer's active contract lanes", + }) + async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) { + const companyId = await this.billingService.resolveCompanyId( + resolveAuthUserId(user), + ); + if (!companyId) return []; + return this.trainSchedulingService.getBookingWindowsForCompany(companyId); + } + @Get("global-rules") @TrainSchedulingView() @ApiOperation({ summary: "Get global train scheduling rules (singleton)" }) @@ -308,6 +326,28 @@ export class TrainSchedulingController { return this.trainSchedulingService.getCompositionRemovals(id); } + @Get("schedules/:id/import-loading-bookings") + @TrainSchedulingView() + @ApiOperation({ + summary: "List import bookings eligible for loading confirmation on this schedule", + }) + getImportLoadingBookings(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getImportLoadingBookings(id); + } + + @Patch("schedules/:id/import-loading-status") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", + }) + updateImportLoadingStatus( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateImportLoadingStatusDto, + ) { + return this.trainSchedulingService.updateImportLoadingStatus(id, dto); + } + @Post("schedules/:id/pin-wagons") @TrainSchedulingManage() @ApiOperation({ summary: "Pin physical wagons to train set slots" }) @@ -457,6 +497,17 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Post("schedules/:id/doc-review-complete") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", + }) + async completeDocReview(@Param("id", ParseUUIDPipe) id: string) { + await this.bookingWindowService.completeDocReview(id); + return this.bookingBatchService.getBatchBoardDetail(id); + } + @Post("bookings/:bookingId/mark-paid") @TrainSchedulingManage() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 0f2e22076..7e281a72f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller'; import { TrainSchedulingService } from './train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; +import { BookingWindowService } from './booking-window.service'; +import { BookingSplitService } from './booking-split.service'; +import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { ContractsModule } from '../contracts/contracts.module'; @@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainSchedulingGlobalRules, TrainCheckpointEvent, ImportDjiboutiOperation, + BookingBatchOffer, ]), forwardRef(() => BookingsModule), BillingModule, @@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module'; TrainCheckpointEventsRepository, BookingBatchService, BookingNotifierService, + BookingWindowService, + BookingSplitService, ], - exports: [TrainSchedulingService, BookingBatchService], + exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], }) export class TrainSchedulingModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index e87dbdd88..2c678ea44 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1,5 +1,6 @@ import { AllocationLoadType, + LoadingStatus, SchedulingStatus, TrainCheckpointKind, TrainScheduleStatus as TrainScheduleStatusEnum, @@ -13,7 +14,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, Not } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { Booking } from '../bookings/entities/booking.entity'; @@ -45,6 +46,7 @@ import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; @@ -58,6 +60,7 @@ import { UploadImportDjiboutiDocumentDto, } from './dto/import-djibouti-operation.dto'; import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; +import { type BookingWindowConfig } from './booking-window.config'; import { buildCappedWagonPlan, computeFleetAvailability, @@ -98,7 +101,11 @@ import { DEFAULT_BULK_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_LENGTH_METERS, } from './booking-batch.constants'; -import { eatDay } from './batch-window.util'; +import { + computeExportWindowTimes, + computeImportWindowTimes, + eatDay, +} from './batch-window.util'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; @@ -236,9 +243,37 @@ export class TrainSchedulingService { if (dto.max20ftPairWeightDiffTons != null) { row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons; } + if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays; + if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours; + if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour; + if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours; + if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes; + if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes; + if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes; return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row); } + /** + * Booking-window timings with hardcoded fallbacks for a missing/legacy config row. + * Numeric columns come back from pg as strings — normalize every field. + */ + async getWindowConfig(): Promise { + const row = await this.loadGlobalRulesRow(); + const num = (v: unknown, fallback: number) => { + const n = v == null ? NaN : Number(v); + return Number.isFinite(n) ? n : fallback; + }; + return { + importWindowLeadDays: num(row?.importWindowLeadDays, 3), + exportBookingLeadHours: num(row?.exportBookingLeadHours, 24), + windowOpenHour: num(row?.windowOpenHour, 8), + windowDurationHours: num(row?.windowDurationHours, 3), + docReviewMinutes: num(row?.docReviewMinutes, 30), + paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), + reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), + }; + } + async previewTrainSchedule(dto: PreviewTrainScheduleDto) { const limits = await this.resolveTrainLimitConfig(dto); return this.buildPreviewResponse( @@ -310,8 +345,12 @@ export class TrainSchedulingService { throw new BadRequestException('A train must be pulled by at least two locomotives'); } + const scheduleWarnings: string[] = []; const createdScheduleId = await this.dataSource.transaction(async (manager) => { - // Lock and validate every locomotive: all must be AVAILABLE and at the origin yard. + // Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on + // multiple future schedules and does not need to be at the origin yard yet — staff + // plan around its arrival. Only decommissioned locomotives are hard-blocked; + // everything else surfaces as a warning. const lockedLocomotives: Locomotive[] = []; for (const locomotiveId of locomotiveIds) { const locked = await manager.getRepository(Locomotive).findOne({ @@ -321,12 +360,17 @@ export class TrainSchedulingService { if (!locked) { throw new NotFoundException(`Locomotive ${locomotiveId} not found`); } + if (locked.status === 'OUT_OF_SERVICE') { + throw new ConflictException(`Locomotive ${locked.code} is out of service`); + } if (locked.status !== 'AVAILABLE') { - throw new ConflictException(`Locomotive ${locked.code} is not available`); + scheduleWarnings.push( + `Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`, + ); } if (locked.currentYardId !== route.originYardId) { - throw new ConflictException( - `Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`, + scheduleWarnings.push( + `Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`, ); } lockedLocomotives.push(locked); @@ -340,27 +384,46 @@ export class TrainSchedulingService { const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); // Effective capacity is capped by the weakest locomotive in the set. const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined; + const departure = new Date(dto.scheduleDate); + // IMPORT/EXPORT trains start with a CLOSED customer window; the window engine + // opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead). + // DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL). + const windowCfg = await this.getWindowConfig(); + const windowFields = + direction === 'IMPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeImportWindowTimes(departure, windowCfg, new Date()), + } + : direction === 'EXPORT' + ? { + bookingWindowStatus: 'CLOSED', + windowPhase: 'PRE_WINDOW', + ...computeExportWindowTimes(departure, windowCfg), + } + : {}; const schedule = manager.getRepository(TrainSchedule).create({ trainSetId: trainSet.id, routeId: route.id, originStationId: route.originYardId, destinationStationId: route.destinationYardId, - scheduledDepartureDate: new Date(dto.scheduleDate), + scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, maxWagons: ( await this.resolveTrainLimitConfig(dto, limitLoco) ).maxWagonsPerTrain, + ...windowFields, }); const saved = await manager.getRepository(TrainSchedule).save(schedule); - await manager.getRepository(Locomotive).update( - { id: In(lockedLocomotives.map((l) => l.id)) }, - { status: 'ASSIGNED' }, - ); + // Locomotives stay in their current status until dispatch — advance scheduling + // must not block the locomotive from serving earlier trains. return saved.id; }); - return this.getTrainScheduleById(createdScheduleId); + const created = await this.getTrainScheduleById(createdScheduleId); + return { ...created, warnings: scheduleWarnings }; } async assignBookingsToSchedule( @@ -683,6 +746,87 @@ export class TrainSchedulingService { ); } + async getImportLoadingBookings(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + ]); + if (!scheduleBookings.length) { + return { count: 0, items: [] }; + } + + const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId)); + const statusByBookingId = new Map( + scheduleBookings.map((sb) => [sb.bookingId, sb.loadingStatus]), + ); + const candidateIds = scheduleBookings + .map((sb) => sb.bookingId) + .filter((id) => allocatedBookingIds.has(id)); + if (!candidateIds.length) { + return { count: 0, items: [] }; + } + + const bookings = await this.bookingsRepository.findByIdsForScheduling(candidateIds); + const items = bookings + .filter((b) => b.tradeDirection === 'IMPORT' && b.paymentStatus === 'PAID') + .map((b) => ({ + id: b.id, + reference: b.reference ?? null, + customer: b.company?.name ?? null, + weightTons: b.cargoTotalWeightVgm, + loadingStatus: statusByBookingId.get(b.id) ?? LoadingStatus.Unloaded, + })); + return { count: items.length, items }; + } + + async updateImportLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + + const [scheduleBookings, allocations, bookings] = await Promise.all([ + this.trainScheduleBookingsRepository.findByScheduleId(scheduleId), + this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId), + this.bookingsRepository.findByIdsForScheduling(dto.bookingIds), + ]); + + const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId)); + const allocatedIds = new Set(allocations.map((a) => a.bookingId)); + const bookingById = new Map(bookings.map((b) => [b.id, b])); + + const invalid: string[] = []; + for (const id of dto.bookingIds) { + const booking = bookingById.get(id); + if ( + !scheduledIds.has(id) || + !allocatedIds.has(id) || + !booking || + booking.tradeDirection !== 'IMPORT' || + booking.paymentStatus !== 'PAID' + ) { + invalid.push(id); + } + } + if (invalid.length) { + throw new BadRequestException( + `Not eligible for import loading confirmation on this schedule: ${invalid.join(', ')}`, + ); + } + + await this.trainScheduleBookingsRepository.updateLoadingStatusMany( + scheduleId, + dto.bookingIds, + dto.loadingStatus, + ); + return this.getImportLoadingBookings(scheduleId); + } + async pinWagons(scheduleId: string, dto: PinWagonsDto) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -778,10 +922,19 @@ export class TrainSchedulingService { throw new BadRequestException('Only SCHEDULED trains can be dispatched'); } await this.assertImportDjiboutiMayDepart(schedule); + // A locomotive may sit on many future schedules, but it can only pull one train + // at a time — block dispatch while any set locomotive is out on a dispatched train. + const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId); const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); + if (setLocomotiveIds.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' }); + } await this.trainSchedulesRepository.updateStatus( scheduleId, @@ -1461,6 +1614,12 @@ export class TrainSchedulingService { /** Open or close a schedule's booking window (staff override). */ async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise { + if (status === 'OPEN') { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (schedule?.bookingWindowStatus === 'FULL') { + throw new ConflictException('Train is full — the booking window cannot be reopened'); + } + } await this.dataSource .getRepository(TrainSchedule) .update(scheduleId, { bookingWindowStatus: status }); @@ -1685,16 +1844,14 @@ export class TrainSchedulingService { [scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched], ); - if (schedule.trainSet?.locomotiveId) { - const loco = await manager - .getRepository(Locomotive) - .findOne({ where: { id: schedule.trainSet.locomotiveId } }); - if (loco) { - await manager.getRepository(Locomotive).update(loco.id, { - status: 'AVAILABLE', - currentYardId: schedule.destinationStationId, - }); - } + // Release every locomotive of the set (not just the legacy primary) and move it + // to the destination yard where it physically arrived. + const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); + if (arrivedLocoIds.length) { + await manager.getRepository(Locomotive).update( + { id: In(arrivedLocoIds) }, + { status: 'AVAILABLE', currentYardId: schedule.destinationStationId }, + ); } for (const slot of schedule.trainSet?.wagons ?? []) { @@ -1769,11 +1926,21 @@ export class TrainSchedulingService { if (schedule.trainSetId) { await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' }); } + // Locomotives are only ASSIGNED while out on a dispatched train. Release ours, + // but never stomp a locomotive that is currently pulling another dispatched train. const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id); if (cancelledLocoIds.length) { - await manager - .getRepository(Locomotive) - .update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' }); + const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere( + cancelledLocoIds, + id, + manager, + ); + const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId)); + if (releasable.length) { + await manager + .getRepository(Locomotive) + .update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' }); + } } for (const wagon of schedule.trainSet?.wagons ?? []) { if (wagon.physicalWagonId) { @@ -2016,15 +2183,17 @@ export class TrainSchedulingService { } if (assignedLocomotives.length) { - // Every locomotive of the set must sit at the origin yard, and the weakest - // one must still be able to pull the train (min limits across the set). + // Advance scheduling: a locomotive that hasn't reached the origin yard yet is a + // warning (it must arrive before dispatch), but a set too weak to pull the train + // is a hard violation. const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId); const setLimits = minLocomotiveLimits(assignedLocomotives); if (offYard) { - violations.push( - `Locomotive ${offYard.code} is not at the schedule origin yard`, + warnings.push( + `Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`, ); - } else if ( + } + if ( setLimits && (setLimits.maxPullWeightTons < totalWeightTons || setLimits.maxTrainLengthMeters < totalLengthMeters) @@ -2034,21 +2203,22 @@ export class TrainSchedulingService { ); } } else { - const availableLocomotives = ( - await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE' }, - }) - ).filter((l) => l.currentYardId === originYardId); - if (!availableLocomotives.length) { - violations.push('No available locomotive at the schedule origin yard'); - } else if ( - !availableLocomotives.some( + const inServiceLocomotives = await this.locomotivesRepository.findAll({ + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, + }); + if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) { + warnings.push( + 'No locomotive is at the schedule origin yard yet; one must arrive before dispatch', + ); + } + if ( + !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) >= totalWeightTons && Number(l.maxTrainLengthMeters) >= totalLengthMeters, ) ) { - violations.push('No available locomotive can support the total train weight and length'); + violations.push('No locomotive can support the total train weight and length'); } } @@ -2596,6 +2766,58 @@ export class TrainSchedulingService { return trainSet.locomotive ? [trainSet.locomotive] : []; } + /** + * Locomotive ids (among the given ones) that are attached to a DISPATCHED train + * other than `excludeScheduleId`. Covers both the multi-loco link rows and the + * legacy single-locomotive column on the train set. + */ + private async findLocomotiveIdsDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + manager?: EntityManager, + ): Promise> { + if (!locomotiveIds.length) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { locomotive_id: string }[] = await runner.query( + `SELECT DISTINCT loco.locomotive_id + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status = 'DISPATCHED' + AND ts.deleted_at IS NULL + AND ts.id <> $1 + AND loco.locomotive_id = ANY($2)`, + [excludeScheduleId, locomotiveIds], + ); + return new Set(rows.map((r) => r.locomotive_id)); + } + + private async assertLocomotivesNotDispatchedElsewhere( + locomotiveIds: string[], + excludeScheduleId: string, + ): Promise { + const busy = await this.findLocomotiveIdsDispatchedElsewhere( + locomotiveIds, + excludeScheduleId, + ); + if (!busy.size) return; + const locos = await this.dataSource + .getRepository(Locomotive) + .find({ where: { id: In([...busy]) } }); + const codes = locos.map((l) => l.code).join(', '); + throw new ConflictException( + `Locomotive(s) ${codes} are currently out on another dispatched train`, + ); + } + async selectOrValidateLocomotive( locomotiveId: string, totalWeightTons: number, @@ -2732,15 +2954,113 @@ export class TrainSchedulingService { } /** AVAILABLE locomotives at the route's origin yard. */ - async getAvailableLocomotivesForRoute(routeId: string): Promise { + /** + * All in-service locomotives, annotated for the schedule-creation picker. + * Advance scheduling means nothing is filtered out — staff see status, whether the + * locomotive is at the origin yard yet, and how many future schedules it already has. + */ + async getAvailableLocomotivesForRoute(routeId: string) { const route = await this.getSchedulableRoute(routeId); const locomotives = await this.locomotivesRepository.findAll({ - where: { status: 'AVAILABLE', currentYardId: route.originYardId }, + where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) }, order: { code: 'ASC' }, }); - return locomotives; + const counts: { locomotive_id: string; future_count: string }[] = locomotives.length + ? await this.dataSource.query( + `SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + JOIN ( + SELECT tsl.train_set_id, tsl.locomotive_id + FROM freight.train_set_locomotives tsl + WHERE tsl.deleted_at IS NULL + UNION + SELECT t.id AS train_set_id, t.locomotive_id + FROM freight.train_sets t + WHERE t.locomotive_id IS NOT NULL + ) loco ON loco.train_set_id = tset.id + WHERE ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.deleted_at IS NULL + AND loco.locomotive_id = ANY($1) + GROUP BY loco.locomotive_id`, + [locomotives.map((l) => l.id)], + ) + : []; + const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)])); + + return locomotives.map((loco) => ({ + ...loco, + atOriginYard: loco.currentYardId === route.originYardId, + futureScheduleCount: futureCounts.get(loco.id) ?? 0, + })); + } + + /** + * Upcoming/open booking windows for a customer's active-contract lanes — + * powers the portal home "booking windows" section. Only window-engine + * schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are + * always open and need no announcement. + */ + async getBookingWindowsForCompany(companyId: string) { + const rows: Array<{ + schedule_id: string; + direction: string | null; + window_phase: string | null; + window_opens_at: Date | null; + window_closes_at: Date | null; + booking_window_status: string; + booking_cycle_no: number; + scheduled_departure_date: Date; + origin_label: string | null; + origin_code: string | null; + destination_label: string | null; + destination_code: string | null; + }> = await this.dataSource.query( + `SELECT DISTINCT ts.id AS schedule_id, + ts.direction, + ts.window_phase, + ts.window_opens_at, + ts.window_closes_at, + ts.booking_window_status, + ts.booking_cycle_no, + ts.scheduled_departure_date, + oy.label AS origin_label, oy.code AS origin_code, + dy.label AS destination_label, dy.code AS destination_code + FROM freight.train_schedules ts + JOIN freight.contract_routes cr + ON cr.origin_yard_id = ts.origin_station_id + AND cr.destination_yard_id = ts.destination_station_id + AND cr.deleted_at IS NULL + JOIN freight.contracts c + ON c.id = cr.contract_id + AND c.company_id = $1 + AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED') + AND c.deleted_at IS NULL + LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id + LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id + WHERE ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED') + AND ts.window_phase IS NOT NULL + AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY') + AND ts.scheduled_departure_date >= now() + ORDER BY ts.window_opens_at ASC NULLS LAST`, + [companyId], + ); + return rows.map((r) => ({ + scheduleId: r.schedule_id, + direction: r.direction, + windowPhase: r.window_phase, + isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN', + windowOpensAt: r.window_opens_at, + windowClosesAt: r.window_closes_at, + bookingWindowStatus: r.booking_window_status, + bookingCycleNo: r.booking_cycle_no, + departureDate: r.scheduled_departure_date, + origin: r.origin_label ?? r.origin_code ?? null, + destination: r.destination_label ?? r.destination_code ?? null, + })); } /** OPEN schedules a new booking may target (with rough remaining capacity). @@ -2937,6 +3257,53 @@ export class TrainSchedulingService { return days.includes(day); } + /** + * Enforce the config-driven booking window at booking-create time. + * + * A booking is only allowed when the route has an OPEN departure the customer + * can join for the requested day — which, because the window engine keeps + * `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means: + * - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT, + * `importWindowLeadDays` before departure, for `windowDurationHours`). + * - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS). + * + * `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so + * both gates are satisfied by checking that route for open departures. When a + * specific day is requested, require an open departure on that EAT day; when no + * day is given, require at least one open departure on the route at all. + * Throws `BadRequestException` when the window is closed. No-ops when the route + * yards are unknown (nothing to gate against). + */ + async assertBookingWindowOpen(input: { + originYardId?: string | null; + destinationYardId?: string | null; + scheduledDate?: Date | string | null; + direction?: string | null; + }): Promise { + const { originYardId, destinationYardId } = input; + if (!originYardId || !destinationYardId) return; + + const { days } = await this.getAvailableDays(originYardId, destinationYardId); + if (days.length === 0) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'The export booking window for this route is not open yet' + : 'The import booking window for this route is closed right now', + ); + } + + if (input.scheduledDate) { + const day = eatDay(new Date(input.scheduledDate)); + if (!days.includes(day)) { + throw new BadRequestException( + input.direction === 'EXPORT' + ? 'No departure is within the export booking window on the selected day' + : 'The import booking window is not open for the selected day', + ); + } + } + } + private async mapScheduleDetail( schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index 9f89e7c2c..49fb22fde 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -52,6 +52,11 @@ export class FilterWarehouseInventoryDto { @IsEnum(WAREHOUSE_INVENTORY_STATUSES) status?: WarehouseInventoryStatus; + @ApiPropertyOptional({ enum: ['IMPORT', 'EXPORT'] }) + @IsOptional() + @IsEnum(['IMPORT', 'EXPORT']) + direction?: 'IMPORT' | 'EXPORT'; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 11f34c892..ac6b71c89 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -430,6 +430,7 @@ export class WarehouseInventoryService { ...(filter.status ? { status: filter.status } : {}), ...(createdAt ? { createdAt } : {}), ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), + ...(filter.direction ? { booking: { tradeDirection: filter.direction } } : {}), }; const search = filter.search?.trim(); @@ -2032,6 +2033,22 @@ export class WarehouseInventoryService { const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); if (isTruckLeaving) { await this.invoices.assertClearanceAllowed(id); + + if (item.bookingId) { + const [truckInfo]: Array<{ customerTruckAssignedAt: string | null }> = + await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "customerTruckAssignedAt" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); + if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + throw new BadRequestException( + 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + ); + } + } } const releaseDate = isTruckLeaving ? dto.releaseDate ? new Date(dto.releaseDate) : new Date() 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 3c295c9d0..e6ea89fbe 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -1,6 +1,7 @@ import { BOOKING_RULE_ENGINE_PERMISSIONS, BOOKING_RULE_ENGINE_PERMISSION_KEYS, + POSITION_PERMISSION_PRESETS, ROLE_PERMISSION_PRESETS, } from './freight-permissions.registry'; @@ -10,6 +11,13 @@ export type FreightSeedRole = { permissionKeys: string[]; }; +export type FreightSeedPosition = { + key: string; + name: { en: string }; + rank: number; + permissionKeys: string[]; +}; + const IAM_PERMISSION_KEYS = { activateEmployee: "can:activateEmployee", activateUser: "can:activateUser", @@ -282,3 +290,18 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ permissionKeys: [], }, ]; + +/** + * Operational positions (positions-as-roles). Seeded as Position + + * PositionPermission rows (NOT Role/RolePermission). Users get their access by + * being assigned to a Position via EmployeePosition. + */ +export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ + { key: "chief", name: { en: "Chief" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.chief] }, + { key: "director", name: { en: "Director" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.director] }, + { key: "ceo", name: { en: "CEO" }, rank: 1, permissionKeys: [...POSITION_PERMISSION_PRESETS.ceo] }, + { key: "ethiopian_gl", name: { en: "Ethiopian GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.ethiopianGl] }, + { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, + { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, + { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, +]; diff --git a/apps/edr-freight-api/src/seed/edr-org.seeder.ts b/apps/edr-freight-api/src/seed/edr-org.seeder.ts index 8243ef267..cb379890a 100644 --- a/apps/edr-freight-api/src/seed/edr-org.seeder.ts +++ b/apps/edr-freight-api/src/seed/edr-org.seeder.ts @@ -3,14 +3,28 @@ import { Organization, OrganizationConfiguration, Permission, + Position, + PositionPermission, + PositionType, Role, RolePermission, + Unit, } from "@tria-plc/iamapi-common"; import { DataSource, EntityManager, In } from "typeorm"; import { ERoleKey } from "@tria-plc/api-common/utils/enums/seed.enum"; import { BOOKING_RULE_ENGINE_PERMISSION_KEYS } from "./freight-permissions.registry"; -import { EDR_FREIGHT_ROLES, type FreightSeedRole } from "./edr-freight.seed"; +import { + EDR_FREIGHT_POSITIONS, + EDR_FREIGHT_ROLES, + type FreightSeedPosition, + type FreightSeedRole, +} from "./edr-freight.seed"; + +const EDR_UNIT_KEY = "edr_freight_hq"; +const EDR_UNIT_NAME = { en: "EDR Freight HQ" }; +const EDR_POSITION_TYPE_KEY = "edr_freight_role"; +const EDR_POSITION_TYPE_NAME = { en: "EDR Freight Role" }; const EDR_ORG_KEY = "edr_freight"; const EDR_ORG_NAME = { en: "EDR Freight" }; @@ -40,6 +54,19 @@ export class EdrOrgSeeder { await this.ensureRoles(manager, EDR_FREIGHT_ROLES); await this.ensureRolePermissions(manager, EDR_FREIGHT_ROLES); await this.ensureSuperAdminPermissions(manager); + + // Positions-as-roles: seed operational positions and grant their + // permissions via PositionPermission (not Role/RolePermission). + const unit = await this.ensureDefaultUnit(manager, organization.id); + const positionType = await this.ensureDefaultPositionType(manager, unit.id); + await this.ensurePositions( + manager, + organization.id, + unit.id, + positionType.id, + EDR_FREIGHT_POSITIONS, + ); + await this.ensurePositionPermissions(manager, unit.id, EDR_FREIGHT_POSITIONS); }); this.logger.log(`Ensured EDR organization seed for '${EDR_ORG_KEY}'`); @@ -205,4 +232,146 @@ export class EdrOrgSeeder { `Ensured ${permissions.length} booking+rule-engine permissions on super_admin`, ); } + + private async ensureDefaultUnit( + manager: EntityManager, + organizationId: string, + ): Promise<{ id: string }> { + const unitRepository = manager.getRepository(Unit); + + let unit = await unitRepository.findOne({ + where: { key: EDR_UNIT_KEY, organizationId }, + select: { id: true }, + }); + + if (!unit) { + const insertResult = await unitRepository.insert({ + key: EDR_UNIT_KEY, + name: EDR_UNIT_NAME, + organizationId, + }); + this.logger.log(`Seeded EDR unit '${EDR_UNIT_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR unit '${EDR_UNIT_KEY}'`); + return { id: unit.id }; + } + + private async ensureDefaultPositionType( + manager: EntityManager, + unitId: string, + ): Promise<{ id: string }> { + const positionTypeRepository = manager.getRepository(PositionType); + + // PositionType has no unique constraint on (key, unitId); find-then-insert. + let positionType = await positionTypeRepository.findOne({ + where: { key: EDR_POSITION_TYPE_KEY, unitId }, + select: { id: true }, + }); + + if (!positionType) { + const insertResult = await positionTypeRepository.insert({ + key: EDR_POSITION_TYPE_KEY, + name: EDR_POSITION_TYPE_NAME, + isSystem: true, + unitId, + }); + this.logger.log(`Seeded EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: insertResult.identifiers[0]?.id as string }; + } + + this.logger.log(`Ensured EDR position type '${EDR_POSITION_TYPE_KEY}'`); + return { id: positionType.id }; + } + + private async ensurePositions( + manager: EntityManager, + organizationId: string, + unitId: string, + positionTypeId: string, + seedPositions: FreightSeedPosition[], + ) { + await manager.getRepository(Position).upsert( + seedPositions.map(({ key, name, rank }) => ({ + key, + name, + rank, + organizationId, + unitId, + positionTypeId, + })), + { + conflictPaths: { key: true, unitId: true }, + }, + ); + + this.logger.log( + `Ensured ${seedPositions.length} EDR positions '${seedPositions + .map((position) => position.key) + .join("', '")}'`, + ); + } + + private async ensurePositionPermissions( + manager: EntityManager, + unitId: string, + seedPositions: FreightSeedPosition[], + ) { + const permissionKeys = [ + ...new Set(seedPositions.flatMap((position) => position.permissionKeys)), + ]; + + if (!permissionKeys.length) { + this.logger.log( + "No EDR position permissions configured; skipping position-permission links", + ); + return; + } + + const positions = await manager.getRepository(Position).find({ + where: { key: In(seedPositions.map((position) => position.key)), unitId }, + select: { id: true, key: true }, + }); + const seededPermissions = await manager.getRepository(Permission).find({ + where: { key: In(permissionKeys) }, + select: { id: true, key: true }, + }); + + const positionByKey = new Map( + positions.map((position) => [position.key, position]), + ); + const permissionByKey = new Map( + seededPermissions.map((permission) => [permission.key, permission]), + ); + + const positionPermissions = seedPositions.flatMap((position) => { + const seededPosition = positionByKey.get(position.key); + + if (!seededPosition) { + throw new Error(`missing_position:${position.key}`); + } + + return position.permissionKeys.map((permissionKey) => { + const seededPermission = permissionByKey.get(permissionKey); + + if (!seededPermission) { + throw new Error(`missing_permission:${permissionKey}`); + } + + return { + positionId: seededPosition.id as string, + permissionId: seededPermission.id, + }; + }); + }); + + await manager.getRepository(PositionPermission).upsert(positionPermissions, { + conflictPaths: { positionId: true, permissionId: true }, + }); + + this.logger.log( + `Ensured ${positionPermissions.length} EDR position-permission links`, + ); + } } 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 56ca98626..d70cb5fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -109,10 +109,19 @@ export const RULE_ENGINE_PERMISSIONS: FreightPermissionSeed[] = RULE_ENGINE_RESO }, ); +/** + * Container-allocation permission for the previously-unguarded + * booking allocate-containers endpoint. + */ +export const GAP_CONTROLLER_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c1000001-0001-4000-8000-000000000001', 'edr_freight_app:allocation:manage', 'Allocate containers to vehicles'), +]; + export const BOOKING_RULE_ENGINE_PERMISSIONS = [ ...BOOKING_PERMISSIONS, ...CONTRACT_PERMISSIONS, ...RULE_ENGINE_PERMISSIONS, + ...GAP_CONTROLLER_PERMISSIONS, ]; export const BOOKING_RULE_ENGINE_PERMISSION_KEYS = BOOKING_RULE_ENGINE_PERMISSIONS.map( @@ -171,6 +180,9 @@ export const FREIGHT_PERMS = { manage: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`, }, + allocation: { + manage: 'edr_freight_app:allocation:manage', + }, } as const; const allRuleEngineViewKeys = () => @@ -286,6 +298,34 @@ export const ROLE_PERMISSION_PRESETS = { orgManager: [...BOOKING_RULE_ENGINE_PERMISSION_KEYS], } as const; +/** + * Position permission presets (positions-as-roles). Grants flow to users via + * Position → PositionPermission (NOT Role/RolePermission). Each reuses the + * matching ROLE_PERMISSION_PRESETS key-array as a building block and adds the + * gap-controller keys the position needs. Deduped via Set. + */ +const dedupe = (keys: string[]): string[] => [...new Set(keys)]; + +export const POSITION_PERMISSION_PRESETS = { + // Chief: senior operational role — intake/line-staff approval + director + // approval + scheduling/ops, plus container allocation. + chief: dedupe([ + ...ROLE_PERMISSION_PRESETS.lineStaff, + ...ROLE_PERMISSION_PRESETS.director, + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), + director: dedupe([...ROLE_PERMISSION_PRESETS.director]), + ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), + ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), + djiboutiGl: dedupe([...ROLE_PERMISSION_PRESETS.glDjibouti]), + marketer: dedupe([...ROLE_PERMISSION_PRESETS.marketing]), + operation: dedupe([ + ...ROLE_PERMISSION_PRESETS.operationsOfficer, + FREIGHT_PERMS.allocation.manage, + ]), +} as const; + export const PERMISSIONS_CATALOG = BOOKING_RULE_ENGINE_PERMISSIONS.map((p) => ({ key: p.key, label: p.name.en, 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 b6d83c138..a8c16ef05 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 @@ -3,8 +3,11 @@ import { hashPassword } from '@tria-plc/api-common/utils/argon'; import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; import { Employee, + EmployeePosition, Organization, + Position, Role, + Unit, User, UserCredential, UserRole, @@ -13,13 +16,19 @@ import { DataSource } from 'typeorm'; const SEED_FLAG = 'SEED_FREIGHT_STAFF'; const EDR_ORG_KEY = 'edr_freight'; +const EDR_UNIT_KEY = 'edr_freight_hq'; +// roleKey is kept only for backwards compatibility with existing UserRole rows; +// access is granted via the assigned position (positionKey) + PositionPermission. 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-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia' }, - { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti' }, + { email: 'linestaff@edr.local', username: 'linestaff', roleKey: 'edr_line_staff', positionKey: 'operation' }, + { email: 'chief@edr.local', username: 'chief', roleKey: 'edr_org_manager', positionKey: 'chief' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director', positionKey: 'director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo', positionKey: 'ceo' }, + { email: 'marketer@edr.local', username: 'marketer', roleKey: 'edr_marketing', positionKey: 'marketer' }, + { email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' }, + { email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' }, + { email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' }, ] as const; @Injectable() @@ -47,11 +56,22 @@ export class FreightStaffUsersSeeder { throw new Error(`missing_organization:${EDR_ORG_KEY}`); } + const unit = await manager.getRepository(Unit).findOne({ + where: { key: EDR_UNIT_KEY, organizationId: organization.id }, + select: { id: true }, + }); + + if (!unit) { + throw new Error(`missing_unit:${EDR_UNIT_KEY}`); + } + const roleRepository = manager.getRepository(Role); const userRepository = manager.getRepository(User); const userCredentialRepository = manager.getRepository(UserCredential); const userRoleRepository = manager.getRepository(UserRole); const employeeRepository = manager.getRepository(Employee); + const positionRepository = manager.getRepository(Position); + const employeePositionRepository = manager.getRepository(EmployeePosition); const hashedPassword = await hashPassword(password); @@ -105,20 +125,50 @@ export class FreightStaffUsersSeeder { { conflictPaths: { userId: true, roleId: true } }, ); - const employeeExists = await employeeRepository.exists({ + let employee = await employeeRepository.findOne({ where: { userId: user.id, organizationId: organization.id, isCurrent: true, }, + select: { id: true }, }); - if (!employeeExists) { - await employeeRepository.insert({ - userId: user.id, - organizationId: organization.id, + if (!employee) { + employee = await employeeRepository.save( + employeeRepository.create({ + userId: user.id, + organizationId: organization.id, + unitId: unit.id, + isCurrent: true, + name: { en: staff.username }, + }), + ); + } + + // Grant access via the assigned position (positions-as-roles). + const position = await positionRepository.findOne({ + where: { key: staff.positionKey, unitId: unit.id }, + select: { id: true, key: true }, + }); + + if (!position) { + throw new Error(`missing_position:${staff.positionKey}`); + } + + const employeePositionExists = await employeePositionRepository.exists({ + where: { + employeeId: employee.id as string, + positionId: position.id as string, + }, + }); + + if (!employeePositionExists) { + await employeePositionRepository.insert({ + employeeId: employee.id as string, + positionId: position.id as string, + unitId: unit.id, isCurrent: true, - name: { en: staff.username }, }); } } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 02a05602e..14ccb3efb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { const twenty = await ctRepo.findOneByOrFail({ code: "20FT" }); const forty = await ctRepo.findOneByOrFail({ code: "40FT" }); - const base = new Date("2026-01-01"); - const rules = [ - { - containerTypeId: twenty.id, - tradeDirection: "IMPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: twenty.id, - tradeDirection: "EXPORT", - maxVgmTons: 26, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "IMPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, - { - containerTypeId: forty.id, - tradeDirection: "EXPORT", - maxVgmTons: 28, - effectiveFrom: base, - isActive: true, - }, + { containerTypeId: twenty.id, tradeDirection: "IMPORT", maxVgmTons: 26 }, + { containerTypeId: twenty.id, tradeDirection: "EXPORT", maxVgmTons: 26 }, + { containerTypeId: forty.id, tradeDirection: "IMPORT", maxVgmTons: 28 }, + { containerTypeId: forty.id, tradeDirection: "EXPORT", maxVgmTons: 28 }, ]; for (const rule of rules) { @@ -361,10 +335,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { }); if (existing) { - await wlRepo.update(existing.id, { - maxVgmTons: rule.maxVgmTons, - effectiveFrom: rule.effectiveFrom, - }); + await wlRepo.update(existing.id, { maxVgmTons: rule.maxVgmTons }); } else { await wlRepo.insert(rule); } @@ -409,7 +380,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { ctByCode: Map, cargoByCode: Map, ): Promise { - const effectiveFrom = new Date("2026-01-01"); const now = new Date(); // Each rate is self-describing: `appliesTo` + `trigger` decide how the @@ -479,7 +449,6 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { proposedByStaffId: STAFF_USER_ID, approvedByCeoId: CEO_USER_ID, approvedAt: now, - effectiveFrom, })) .filter((d) => !existingBySignature.has(signature(d))); diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 48b9bc0a9..72c782e11 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -17,6 +17,7 @@ "@edr/ui-common": "workspace:*", "@hello-pangea/dnd": "^18.0.1", "@mantine/core": "^9.3.0", + "@mantine/dates": "^9.3.0", "@mantine/hooks": "^9.3.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.100.11", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 608cdbaad..4c74f6993 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -12,6 +12,7 @@ import { PackageCheck, PackageOpen, Paperclip, + Receipt, Send, Settings, ShieldCheck, @@ -32,7 +33,11 @@ import { useParams, } from "react-router-dom"; -import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout"; +import { + FreightDashboardLayout, + type SidebarItem, + type SidebarSection, +} from "@/components/layout"; import { useAuth } from "./auth/useAuth"; import LoadingScreen from "./components/LoadingScreen"; import LoginPage from "./pages/auth/LoginPage"; @@ -53,6 +58,8 @@ import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm"; import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage"; import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; +import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; +import InvoicesPage from "./pages/invoices/InvoicesPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -61,7 +68,13 @@ import UserManagementHostPage from "./pages/dashboard/user-management/UserManage import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; -import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { + FREIGHT_PERMS, + hasPermission as hasFreightPermission, + isDjiboutiGl, + isEthiopianGl, + isSuperAdmin, +} from "./lib/permissions"; import PermissionsPage from "./pages/dashboard/user-management/PermissionsPage"; import PositionTypesPage from "./pages/dashboard/user-management/PositionTypesPage"; import RolesPage from "./pages/dashboard/user-management/RolesPage"; @@ -145,6 +158,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.bookings.view, }, + { + label: "Invoices", + href: "/dashboard/invoices", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -160,12 +179,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ FREIGHT_PERMS.contracts.clearanceEtActions, ], }, - // { - // label: "Shipment Requests", - // href: "/dashboard/shipment-requests", - // icon: , - // permission: FREIGHT_PERMS.contracts.createBooking, - // }, + { + label: "Shipment Requests", + href: "/dashboard/shipment-requests", + icon: , + permission: FREIGHT_PERMS.contracts.createBooking, + }, { label: "GL Djibouti Clearance", href: "/dashboard/gl-djibouti/clearance", @@ -310,7 +329,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , }, { @@ -357,7 +376,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, { label: "Terminal Inventory", - href: "/dashboard/warehouse-inventory", + href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , }, ], @@ -420,10 +439,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Contract validity", href: "/dashboard/configuration/contract-validity-periods", }, - // { - // label: "Train scheduling rules", - // href: "/dashboard/configuration/train-scheduling-rules", - // }, + { + label: "Train scheduling rules", + href: "/dashboard/configuration/train-scheduling-rules", + }, ], }, { @@ -436,12 +455,35 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -/** Keep only items the user is permitted to see; drop now-empty sections. */ +/** Hrefs of the two document-clearance menu items (stable identifiers). */ +const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; +const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; + +const isEtClearanceItem = (item: SidebarItem): boolean => + item.href === ET_CLEARANCE_HREF; +const isDjClearanceItem = (item: SidebarItem): boolean => + item.href === DJ_CLEARANCE_HREF; +const isClearanceItem = (item: SidebarItem): boolean => + isEtClearanceItem(item) || isDjClearanceItem(item); + +/** + * Keep only items the user is permitted to see; drop now-empty sections. + * + * Position-scoped visibility (super_admin bypasses all of this): + * - Ethiopian GL → sees ONLY the ET document-clearance page. + * - Djibouti GL → sees ONLY the DJ clearance page. + * - Everyone else → sees everything they have permission for, EXCEPT the two + * clearance pages (those are GL-only). + */ const filterSidebarByPermission = ( sections: SidebarSection[], user: ReturnType["user"], ): SidebarSection[] => { - const itemAllowed = (item: SidebarItem): boolean => { + const superAdmin = isSuperAdmin(user); + const etGl = !superAdmin && isEthiopianGl(user); + const djGl = !superAdmin && isDjiboutiGl(user); + + const permissionAllowed = (item: SidebarItem): boolean => { if (!item.permission) return true; const keys = Array.isArray(item.permission) ? item.permission @@ -449,6 +491,19 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; + const itemAllowed = (item: SidebarItem): boolean => { + if (superAdmin) return true; + + // GL positions are locked to their single clearance page. + if (etGl) return isEtClearanceItem(item); + if (djGl) return isDjClearanceItem(item); + + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + + return permissionAllowed(item); + }; + return sections .map((section) => ({ ...section, @@ -470,6 +525,22 @@ const DashboardShell = () => { ); const displayName = user?.name?.en || user?.username || user?.email || "User"; + // GL positions are locked to their single clearance page: if they navigate + // (or deep-link) anywhere else, send them back to their clearance hub. + // Super admin is exempt. Allow the clearance path + its detail sub-routes. + const superAdmin = isSuperAdmin(user); + const glClearanceHome = !superAdmin + ? isEthiopianGl(user) + ? ET_CLEARANCE_HREF + : isDjiboutiGl(user) + ? DJ_CLEARANCE_HREF + : null + : null; + + if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + return ; + } + return ( { } /> } /> } /> - } /> + } + /> }> } /> } /> @@ -525,8 +599,27 @@ const App = () => { /> } /> } /> + + + + } + /> + + + + } + /> } /> - } /> + } + /> } @@ -539,7 +632,9 @@ const App = () => { + } @@ -573,7 +668,9 @@ const App = () => { + } @@ -581,7 +678,9 @@ const App = () => { + } @@ -589,7 +688,9 @@ const App = () => { + } @@ -621,12 +722,20 @@ const App = () => { } /> - } /> - } /> + } + /> + } + /> + } @@ -634,7 +743,9 @@ const App = () => { + } @@ -647,7 +758,9 @@ const App = () => { + } @@ -658,155 +771,174 @@ const App = () => { /> } /> } /> - } /> + } + /> } /> } /> } /> } /> } /> } /> - } /> - } /> + } + /> + } + /> } /> } /> - } /> - } /> + } + /> + } + /> - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> } + element={ + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + } /> { {/* Legacy embedded user management routes */} } /> } /> - } /> + } + /> {/* } /> */} - } /> + } + /> } /> { } + element={ + + } /> { } /> } /> - } /> - } /> + } + /> + } + /> { } + element={ + + } + /> + } /> - } /> } /> } /> @@ -1029,9 +1180,7 @@ const App = () => { /** Redirect removed milestones page to document clearance. */ function BookingMilestonesRedirect() { const { id } = useParams(); - return ( - - ); + return ; } /** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */ diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx new file mode 100644 index 000000000..d7cd9207b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -0,0 +1,1130 @@ +import { useMemo, useState } from "react"; +import { + Alert, + Badge, + Button, + FileInput, + Group, + Modal, + NumberInput, + Paper, + Select, + Stack, + Stepper, + Text, + Textarea, +} from "@mantine/core"; +import { DateInput, DateTimePicker } from "@mantine/dates"; +import { + AlertTriangle, + CheckCircle2, + FileText, + PackageCheck, + Receipt, + Ship, + Train, + Truck, + Upload, +} from "lucide-react"; +import type { Freight } from "@edr/types"; +import toast from "react-hot-toast"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { + TransitPermitMultiUpload, + type TransitPermitUploadedRow, +} from "@/components/contracts/TransitPermitMultiUpload"; +import { + findWorkflowFile, + PhasedUploadedFileRow, +} from "@/components/contracts/PhasedUploadedFileRow"; +import { + DeclarationStep, + StepStatus, + isBookingMilestoneDone, + isMilestoneDone, + type ClearanceViewLike, + type MilestoneRow, +} from "@/components/contracts/PhasedClearanceActionPanel"; +import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; + +/** + * Export customs flow, ordered per the stakeholder process: + * customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET) + * → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ) + * → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm. + */ +export function computeExportActiveStep( + clearance: ClearanceViewLike, + bookingMilestones: MilestoneRow[], + bookingCreated: boolean, +): number { + const released = Boolean(clearance.bookingReady || clearance.operationReady); + if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0; + if (!isMilestoneDone(clearance.milestones, "RELEASE_ORDER_SECURED")) return 1; + if (!isMilestoneDone(clearance.milestones, "DECLARED") || !released) return 2; + if (!bookingCreated) return 3; + if ( + !isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED") || + !( + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + clearance.train?.wagonAllocated + ) + ) { + return 4; + } + if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5; + if (!clearance.train?.arrivedAt) return 6; + if (!clearance.gatepassGranted) return 7; + if (!clearance.t1Closed) return 8; + if (clearance.finalInvoice?.status !== "PAID") return 9; + return 10; +} + +export function exportTransitFilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): TransitPermitUploadedRow[] { + return workflowFiles + .filter( + (f) => + f.category === "transit" && + f.code.toLowerCase().startsWith("export_transport_document") && + f.file, + ) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); +} + +export function ExportClearanceStepper({ + contractId, + bookingId, + clearance, + workflowFiles = [], + showEt, + canEt, + showDj, + canDj, + onChanged, + bookingCreateHref, + onViewFile, + onDownloadFile, + useUploadModals = false, + onUploadRoRequest, + bookingCreated = false, + bookingMilestones = [], +}: { + contractId?: string; + bookingId?: string; + clearance: ClearanceViewLike; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + showEt: boolean; + canEt: boolean; + showDj: boolean; + canDj: boolean; + onChanged?: () => void; + bookingCreateHref?: string; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; + useUploadModals?: boolean; + onUploadRoRequest?: () => void; + bookingCreated?: boolean; + bookingMilestones?: MilestoneRow[]; +}) { + // Pre-booking actions (RO, declaration, release) target the contract when one + // is present; GENERAL customs bookings run the same flow keyed on the booking. + const isBooking = Boolean(bookingId) && !contractId; + const entityId = contractId ?? bookingId ?? ""; + // The booking that carries the post-booking steps (gate pass, T1, invoice). + const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null; + const effectiveBookingCreated = + bookingCreated || Boolean(clearance.linkedBookingId) || isBooking; + + const activeStep = useMemo( + () => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated), + [clearance, bookingMilestones, effectiveBookingCreated], + ); + + const released = Boolean(clearance.bookingReady || clearance.operationReady); + const declared = isMilestoneDone(clearance.milestones, "DECLARED"); + const paymentSettled = isBookingMilestoneDone(bookingMilestones, "FREIGHT_PAYMENT_SETTLED"); + const wagonAllocated = + isBookingMilestoneDone(bookingMilestones, "WAGON_ALLOCATED") || + Boolean(clearance.train?.wagonAllocated); + const transportIssued = isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED"); + + return ( + + {clearance.roHold && clearance.roHoldReason ? ( + } title="Release Order on hold"> + {clearance.roHoldReason} + + ) : null} + + {clearance.nextAction ? ( + + + {clearance.nextAction.actor.replace("_", " ")} —{" "} + {clearance.nextAction.action} + + + ) : null} + + + + Export customs clearance + + + + ) : undefined + } + > + + + + } + > + {showDj && canDj ? ( + useUploadModals ? ( + + ) : ( + + ) + ) : ( + + {findWorkflowFile(workflowFiles, "release_order") ? ( + + ) : null} + {clearance.vesselDepartureDate ? ( + + Vessel departure:{" "} + {new Date(clearance.vesselDepartureDate).toLocaleDateString()} + + ) : null} + + + )} + + + : } + > + {showEt && canEt && !effectiveBookingCreated && (activeStep >= 2 || declared) ? ( + + + {declared && !released ? ( + + ) : null} + + ) : ( + + )} + + + } + > + {released && bookingCreateHref && !effectiveBookingCreated && showEt && canEt ? ( + + + Export is released. Create the shipment booking for the customer. + + + + ) : ( + + )} + + + } + > + + + + + + + : } + > + {showEt && canEt && actionBookingId && wagonAllocated ? ( + + ) : ( + + {exportTransitFilesFromWorkflow(workflowFiles).map((row) => ( + + ))} + + + )} + + + } + > + + + + + + + : } + > + + + + : } + > + + + + + ) : ( + + ) + } + > + + + + + + ); +} + +/** Legacy in-flight contracts: declaration done before auto-release existed. */ +function ConfirmExportReleaseFallback({ + entityId, + isBooking, + onChanged, +}: { + entityId: string; + isBooking: boolean; + onChanged?: () => void; +}) { + const [loading, setLoading] = useState(false); + return ( + + ); +} + +function GatepassStep({ + bookingId, + clearance, + canAct, + onChanged, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canAct: boolean; + onChanged?: () => void; +}) { + const [opened, setOpened] = useState(false); + const [at, setAt] = useState(new Date()); + const [loading, setLoading] = useState(false); + + if (clearance.gatepassGranted) { + return ( + + ); + } + + const arrived = Boolean(clearance.train?.arrivedAt); + + return ( + + + {canAct && bookingId ? ( + <> + + setOpened(false)} + title={Grant gate pass} + radius="md" + size="sm" + > + + setAt(v ? new Date(v) : null)} + required + /> + + + + + + + + ) : null} + + ); +} + +function AcceptT1Step({ + bookingId, + clearance, + transportIssued, + workflowFiles = [], + canAct, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + transportIssued: boolean; + workflowFiles?: Freight.ClearanceWorkflowFile[]; + canAct: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [loading, setLoading] = useState(false); + const files = exportTransitFilesFromWorkflow(workflowFiles); + + return ( + + {files.map((row) => ( + + ))} + {clearance.t1Closed ? ( + + ) : ( + <> + + {canAct && bookingId ? ( + + ) : null} + + )} + + ); +} + +function FinalInvoiceStep({ + bookingId, + clearance, + canDjAct, + canConfirm, + onChanged, + onViewFile, + onDownloadFile, +}: { + bookingId: string | null; + clearance: ClearanceViewLike; + canDjAct: boolean; + canConfirm: boolean; + onChanged?: () => void; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +}) { + const [opened, setOpened] = useState(false); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [description, setDescription] = useState(""); + const [file, setFile] = useState(null); + const [sending, setSending] = useState(false); + const [confirming, setConfirming] = useState(false); + + const invoice = clearance.finalInvoice ?? null; + const paid = invoice?.status === "PAID"; + + if (!clearance.offloaded && !invoice) { + return ( + + ); + } + + return ( + + {invoice ? ( + + +
+ + {invoice.invoiceNumber} + + + {invoice.totalAmount.toLocaleString()} {invoice.currency} + {invoice.description ? ` — ${invoice.description}` : ""} + +
+ + {invoice.status} + +
+
+ ) : null} + + {invoice?.invoiceFile ? ( + + ) : null} + {invoice?.slipFile ? ( + + ) : null} + + {paid ? ( + + ) : invoice ? ( + <> + + {canConfirm && bookingId && invoice.slipFile ? ( + + ) : null} + + ) : canDjAct && bookingId ? ( + <> + + Cargo offloaded — send the final invoice to the customer. + + + setOpened(false)} + title={Send final invoice} + radius="md" + size="md" + > + + + +