This commit is contained in:
natib21
2026-07-03 15:22:51 +00:00
209 changed files with 13780 additions and 2892 deletions

View File

@@ -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

View File

@@ -121,13 +121,59 @@ This ensures `docker ps` shows `0.0.0.0:<port>-><port>/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}-<service>:<sha8>`).
### 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:<previous-sha> 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=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs:
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
- 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 <service> --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

View File

@@ -55,8 +55,9 @@ 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
@@ -71,10 +72,14 @@ FAYDA_USERINFO_ENDPOINT=
# Base64-encoded RSA private JWK used for the private_key_jwt client assertion
FAYDA_PRIVATE_KEY_BASE64=
# OAuth redirect_uri for MOBILE clients (must be registered with eSignet)
FAYDA_REDIRECT_URI=
FAYDA_REDIRECT_URI=http://localhost:3001/api/fayda/verification/complete
# OAuth redirect_uri for WEB clients. Defaults to FAYDA_REDIRECT_URI when unset.
FAYDA_WEB_REDIRECT_URI=
FAYDA_WEB_REDIRECT_URI=http://localhost:3000/callback
CLIENT_ASSERTION_TYPE=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
FAYDA_SCOPE=openid profile email phone address
FAYDA_ACR_VALUES=mosip:idp:acr:generated-code
FAYDA_CLAIMS_LOCALES=en am
FAYDA_SESSION_TTL_MINUTES=10
EXPIRATION_TIME=15
ALGORITHM=RS256
EMAIL_QUEUE=email_queue

View File

@@ -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,22 +66,23 @@ 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 { FleetHistoryModule } from './modules/fleet-history/fleet-history.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: [
@@ -216,4 +221,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("*");
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddBookingWindowGlobalRules1861000000000 implements MigrationInterface {
name = "AddBookingWindowGlobalRules1861000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -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<void> {
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<void> {
// Data fix — not reversible.
}
}

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddScheduleWindowPhases1862000000000 implements MigrationInterface {
name = "AddScheduleWindowPhases1862000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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;
`);
}
}

View File

@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateBookingBatchOffers1863000000000 implements MigrationInterface {
name = "CreateBookingBatchOffers1863000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_batch_offers;`);
}
}

View File

@@ -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<void> {
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<void> {
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
`);
}
}

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedule_bookings
DROP COLUMN IF EXISTS loading_status
`);
}
}

View File

@@ -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<void> {
// ── 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<void> {
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);`,
);
}
}

View File

@@ -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")

View File

@@ -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<Invoice & { lines: InvoiceLine[] }> {
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);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
ratesService as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
});

View File

@@ -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<GeneratePriceResponseDto> {
@@ -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,
};
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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<void> {
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<SubmitBookingResponseDto> {
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<Booking> {
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,
};
}
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
/**
* 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<Booking> {
): Promise<Booking | null> {
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<Booking> {
.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. */

View File

@@ -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([]);
});
});

View File

@@ -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;
}

View File

@@ -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<number> {
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<Container20ftUnit[]> {
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<PairingViolation[]> {
// 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);
}
}

View File

@@ -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[];
}

View File

@@ -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<typeof buildWorkflowFiles>;
/** 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<Booking> {
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);

View File

@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
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<string, Omit<MilestoneDef, 'code'>> = {
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 },
};

View File

@@ -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<ClearanceMilestone> {
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,

View File

@@ -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<number> {
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,

View File

@@ -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<typeof buildWorkflowFiles>;
/** 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<ReturnType<FilesService['findByResource']>> = [];
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<Contract> {
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);

View File

@@ -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',

View File

@@ -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())

View File

@@ -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 =>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
/**

View File

@@ -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<Freight.ClearanceT1State> {
/** Wagon-allocation + train-schedule actuals for a booking (both directions). */
async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
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<Freight.ClearanceT1State> {
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<Freight.ClearanceT1State> {
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<Freight.DjClearanceSchedule[]> {
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<Freight.ClearanceFinalInvoiceSummary> {
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<Freight.ClearanceFinalInvoiceSummary> {
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<Freight.ClearanceFinalInvoiceSummary | null> {
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).
*/

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -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 {}

View File

@@ -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
);
}

View File

@@ -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;

View File

@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
exports: [
OtpRepository,
OtpService,
],
})
export class OtpModule {}

View File

@@ -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
);
}
}

View File

@@ -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",
};
}
}
// ---------------------------------------------------------------------------
// 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 };
}
}

View File

@@ -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<MarkPaidResponseDto> {
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<MarkPaidResponseDto> {
this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
}

View File

@@ -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],

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
create(data: Partial<Rate>): Promise<Rate>;

View File

@@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;

View File

@@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository {
}
findLiveRates(): Promise<Rate[]> {
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<Rate | null> {
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<Rate>): Promise<Rate[]> {
return this.repo.find(options);
}

View File

@@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
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<WeightLimitRule | null> {
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<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}

View File

@@ -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<void> {
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<Rate> {
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;

View File

@@ -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<void> {
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<WeightLimitRule> {
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<WeightLimitRule> {
await this.findById(id);
const existing = await this.findById(id);
const patch: Partial<WeightLimitRule> = {};
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;

View File

@@ -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;
}

View File

@@ -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[];
}

View File

@@ -47,4 +47,27 @@ export class TrainScheduleBookingsRepository extends BaseRepository<TrainSchedul
select: { id: true, bookingId: true, trainScheduleId: true },
});
}
findByScheduleId(
trainScheduleId: string,
manager?: EntityManager,
): Promise<TrainScheduleBooking[]> {
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<void> {
if (!bookingIds.length) return;
await this.repo(manager).update(
{ trainScheduleId, bookingId: In(bookingIds) },
{ loadingStatus },
);
}
}

View File

@@ -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:0006: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:0003: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:0024: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 (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 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:0011: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 → 0609 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');
});
});

View File

@@ -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 (0003, 0306, … 2124). */
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<string>();
// 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<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
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);
}

View File

@@ -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 (0003, 0306, … 2124), 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;

View File

@@ -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();
});
});
});

View File

@@ -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<RouteDayGroup[]> {
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<string, RouteDayGroup>();
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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string[]> {
// 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<void> {
/**
* 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<Capacity | null> {
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<number> {
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<boolean> {
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<string>();
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<void> {
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<void> {
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<void> {
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<string>();
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<void> {
@@ -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<boolean> {
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<number> {
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 {

View File

@@ -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<void> {
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<void> {
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)' : ''

View File

@@ -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<string, unknown>;
}
/**
* 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<SizedOffer | null> {
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<BookingBatchOffer> {
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<void> {
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<void> {
await this.dataSource
.getRepository(BookingBatchOffer)
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
}
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
return this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -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];

View File

@@ -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<void> {
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<void> {
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<TrainSchedule> {
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<void> {
// 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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
Object.assign(schedule, patch);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<string, unknown> | 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;
}

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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 {}

View File

@@ -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<BookingWindowConfig> {
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<void> {
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<Set<string>> {
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<void> {
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<Locomotive[]> {
/**
* 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<void> {
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,
) {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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] },
];

View File

@@ -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`,
);
}
}

View File

@@ -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,

View File

@@ -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 },
});
}
}

View File

@@ -319,37 +319,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
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<void> {
});
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<void> {
ctByCode: Map<string, any>,
cargoByCode: Map<string, any>,
): Promise<Rate[]> {
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<void> {
proposedByStaffId: STAFF_USER_ID,
approvedByCeoId: CEO_USER_ID,
approvedAt: now,
effectiveFrom,
}))
.filter((d) => !existingBySignature.has(signature(d)));

View File

@@ -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",

View File

@@ -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: <Wallet />,
permission: FREIGHT_PERMS.bookings.view,
},
{
label: "Invoices",
href: "/dashboard/invoices",
icon: <Receipt />,
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: <Send />,
// permission: FREIGHT_PERMS.contracts.createBooking,
// },
{
label: "Shipment Requests",
href: "/dashboard/shipment-requests",
icon: <Send />,
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: <Package />,
},
{
@@ -357,7 +376,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
},
{
label: "Terminal Inventory",
href: "/dashboard/warehouse-inventory",
href: "/dashboard/warehouse-inventory?direction=EXPORT",
icon: <Package />,
},
],
@@ -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<typeof useAuth>["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 <Navigate to={glClearanceHome} replace />;
}
return (
<FreightDashboardLayout
sidebarSections={sidebarSections}
@@ -509,7 +580,10 @@ const App = () => {
<Route path="/health" element={<HealthCheck />} />
<Route path="/callback" element={<FaydaCallbackPage />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route
path="/dashboard"
element={<Navigate to="/dashboard/overview" replace />}
/>
<Route path="/dashboard" element={<DashboardShell />}>
<Route path="overview" element={<OverviewPage />} />
<Route path="profile" element={<MyProfilePage />} />
@@ -525,8 +599,27 @@ const App = () => {
/>
<Route path="customers" element={<CustomersPage />} />
<Route path="customers/:id" element={<CustomerDetailPage />} />
<Route
path="invoices"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<InvoicesPage />
</RequirePermission>
}
/>
<Route
path="invoices/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
<InvoiceDetailPage />
</RequirePermission>
}
/>
<Route path="booking-requests/new" element={<NewBookingPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route
path="booking-requests/:id"
element={<BookingRequestDetailPage />}
/>
<Route
path="booking-requests/:id/contract"
element={<BookingContractPage />}
@@ -539,7 +632,9 @@ const App = () => {
<Route
path="clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -573,7 +668,9 @@ const App = () => {
<Route
path="bookings/:bookingId/clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
<RequirePermission
permission={FREIGHT_PERMS.bookings.clearanceView}
>
<DocumentClearanceDetailPage />
</RequirePermission>
}
@@ -581,7 +678,9 @@ const App = () => {
<Route
path="shipment-requests"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestsPage />
</RequirePermission>
}
@@ -589,7 +688,9 @@ const App = () => {
<Route
path="shipment-requests/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<ShipmentRequestDetailPage />
</RequirePermission>
}
@@ -621,12 +722,20 @@ const App = () => {
</RequirePermission>
}
/>
<Route path="gl-ethiopia/clearance" element={<LegacyGlEthiopiaClearanceRedirect />} />
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlEthiopiaClearanceRedirect />} />
<Route
path="gl-ethiopia/clearance"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route
path="gl-ethiopia/clearance/:id"
element={<LegacyGlEthiopiaClearanceRedirect />}
/>
<Route
path="gl-djibouti/clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlDjiboutiClearanceListPage />
</RequirePermission>
}
@@ -634,7 +743,9 @@ const App = () => {
<Route
path="gl-djibouti/clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.clearanceDjActions}
>
<GlClearanceDetailPage />
</RequirePermission>
}
@@ -647,7 +758,9 @@ const App = () => {
<Route
path="contracts/:id/create-booking"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<RequirePermission
permission={FREIGHT_PERMS.contracts.createBooking}
>
<GlCreateBookingForm />
</RequirePermission>
}
@@ -658,155 +771,174 @@ const App = () => {
/>
<Route path="warehouses" element={<WarehouseListPage />} />
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
<Route
path="warehouse-inventory"
element={<WarehouseInventoryPage />}
/>
<Route path="import-warehouse" element={<ImportWarehouseFlowPage />} />
<Route path="export-warehouse" element={<ExportWarehouseFlowPage />} />
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route path="export-djibouti-unloading" element={<ExportDjiboutiUnloadingQueuePage />} />
<Route path="interchange-documents" element={<InterchangeDocumentsPage />} />
<Route
path="export-djibouti-unloading"
element={<ExportDjiboutiUnloadingQueuePage />}
/>
<Route
path="interchange-documents"
element={<InterchangeDocumentsPage />}
/>
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
<Route
path="warehouse-fee-invoices"
element={<WarehouseInvoicesPage />}
/>
<Route
path="warehouse-dashboard"
element={<WarehouseDashboardPage />}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<FirstMilePage />
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchBoardPage />
</RequirePermission>
}
/>
<Route
path="operations/first-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<FirstMilePage />
</RequirePermission>
}
/>
<Route
path="operations/last-mile"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<LastMilePage />
</RequirePermission>
}
/>
<Route
path="operations/batch-board/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<BatchScheduleDetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2ListPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleV2DetailPage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling-v2/:scheduleId/track"
element={
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
<TrainScheduleTrackPage />
</RequirePermission>
}
/>
<Route
path="routes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<RoutesPage />
</RequirePermission>
}
/>
<Route
path="locomotives"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="trains/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<TrainDetailPage />
</RequirePermission>
}
/>
<Route
path="wagons"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="containers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="cargoes"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="vehicles"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="drivers"
element={
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
<FleetResourcePage />
</RequirePermission>
}
/>
<Route
path="operations/train-scheduling"
element={
<Navigate to="/dashboard/operations/train-scheduling-v2" replace />
}
/>
<Route
path="operations/batch-board"
@@ -956,9 +1088,15 @@ const App = () => {
{/* Legacy embedded user management routes */}
<Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} />
<Route path="user-management/position-types" element={<PositionTypesPage />} />
<Route
path="user-management/position-types"
element={<PositionTypesPage />}
/>
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
<Route path="user-management/permissions" element={<PermissionsPage />} />
<Route
path="user-management/permissions"
element={<PermissionsPage />}
/>
<Route path="user-management/roles" element={<RolesPage />} />
<Route
@@ -980,7 +1118,9 @@ const App = () => {
<Route
path="configuration"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
element={
<Navigate to="/dashboard/configuration/cargo-types" replace />
}
/>
<Route
path="configuration/train-scheduling-rules"
@@ -999,8 +1139,14 @@ const App = () => {
}
/>
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
<Route
path="configuration/cargo-types/:id"
element={<CargoTypesPage />}
/>
<Route
path="configuration/:resource"
element={<RuleEngineResourcePage />}
/>
<Route
path="rules"
@@ -1010,9 +1156,14 @@ const App = () => {
<Route
path="rule-engine"
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
element={
<Navigate to="/dashboard/configuration/cargo-types" replace />
}
/>
<Route
path="rule-engine/:resource"
element={<RuleEngineLegacyRedirect />}
/>
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
<Route path="user1" element={<DemoUser1Page />} />
<Route path="user2" element={<DemoUser2Page />} />
@@ -1029,9 +1180,7 @@ const App = () => {
/** Redirect removed milestones page to document clearance. */
function BookingMilestonesRedirect() {
const { id } = useParams();
return (
<Navigate to={`/dashboard/bookings/${id}/clearance`} replace />
);
return <Navigate to={`/dashboard/bookings/${id}/clearance`} replace />;
}
/** Redirect legacy GL Ethiopia clearance URLs to the unified document clearance hub. */

View File

@@ -62,10 +62,11 @@ export function GlClearanceUploadModal({
setLoading(true);
try {
if (isDo) {
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file);
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
} else {
await contractsService.uploadDeliveryOrder(entityId, file);
await contractsService.uploadDeliveryOrder(entityId, file, iso);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
@@ -117,7 +118,15 @@ export function GlClearanceUploadModal({
size="sm"
required
/>
) : null}
) : (
<DateInput
label="Vessel departure date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
size="sm"
clearable
/>
)}
<PhasedFileDropzone
label={isDo ? "Delivery Order file" : "Release Order file"}

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { Badge, Button, Group, Tooltip } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { api } from "@/services/api";
@@ -88,7 +89,13 @@ export function ProfileChips({
}) {
if (!profiles.length) {
return (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
<Badge
color="gray"
variant="light"
size="sm"
radius="md"
style={badgeStyle}
>
No profiles
</Badge>
);
@@ -118,7 +125,13 @@ export function ProfileChips({
</Tooltip>
))}
{extra > 0 ? (
<Badge color="gray" variant="light" size="sm" radius="md" style={badgeStyle}>
<Badge
color="gray"
variant="light"
size="sm"
radius="md"
style={badgeStyle}
>
+{extra}
</Badge>
) : null}
@@ -169,7 +182,11 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
CANCELLED: "red",
};
export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) {
export function BookingStatusBadge({
status,
}: {
status: CustomerBookingStatus;
}) {
return (
<Badge
color={BOOKING_STATUS_COLOR[status] ?? "gray"}
@@ -194,7 +211,11 @@ const PAYMENT_STATUS_COLOR: Record<CustomerPaymentStatus, string> = {
refunded: "grape",
};
export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) {
export function PaymentStatusBadge({
status,
}: {
status: CustomerPaymentStatus;
}) {
return (
<Badge
color={PAYMENT_STATUS_COLOR[status] ?? "gray"}
@@ -210,6 +231,38 @@ export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }
);
}
const INVOICE_STATUS_COLOR: Record<Freight.InvoiceStatus, string> = {
DRAFT: "gray",
ISSUED: "cyan",
PENDING: "yellow",
PARTIALLY_PAID: "orange",
PAID: "edr-green",
OVERDUE: "red",
CANCELLED: "gray",
REFUNDED: "grape",
EXPIRED: "red",
};
export function InvoiceStatusBadge({
status,
}: {
status: Freight.InvoiceStatus;
}) {
return (
<Badge
color={INVOICE_STATUS_COLOR[status] ?? "gray"}
variant="light"
size="sm"
radius="md"
tt="capitalize"
fw={600}
style={badgeStyle}
>
{humanize(status)}
</Badge>
);
}
/**
* Inline approval action buttons for a profile row.
* Transitions: pending → approve/reject | active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate
@@ -225,8 +278,7 @@ export function ProfileApprovalActions({
api.customers.setProfileStatus.mutationOptions(),
);
const act = (next: ProfileStatus) =>
mutate({ profileId, status: next });
const act = (next: ProfileStatus) => mutate({ profileId, status: next });
if (status === "pending") {
return (

View File

@@ -2,6 +2,7 @@ export {
BookingStatusBadge,
CompanyStatusBadge,
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
ProfileApprovalActions,
ProfileChips,

View File

@@ -179,7 +179,16 @@ const RuleEngineFormDialog = ({
const formRows = useMemo(() => buildFormRows(visibleFields), [visibleFields]);
const setField = (name: string, value: unknown) => {
setValues((current) => ({ ...current, [name]: value }));
setValues((current) => {
const next = { ...current, [name]: value };
// Changing what a rate applies to (or its surcharge trigger) can invalidate
// the previously-chosen unit — reset it so the admin re-picks from the new
// allowed set instead of submitting a stale, rejected unit.
if ((name === "appliesTo" || name === "trigger") && "rateUnit" in current) {
next.rateUnit = "";
}
return next;
});
};
const handleSubmit = (event: React.FormEvent) => {
@@ -250,6 +259,9 @@ const RuleEngineFormDialog = ({
const label = <FieldLabel label={field.label} required={field.required} />;
if (field.type === "select") {
// Dynamic options (e.g. rate unit) resolve from the live form values so
// the choices track the other fields the admin has picked.
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
return (
<Select
key={field.name}
@@ -261,7 +273,7 @@ const RuleEngineFormDialog = ({
value={resolveSelectValue(field, values)}
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
disabled={selectOptionsLoading}
data={(field.options ?? [])
data={options
.filter((opt) => opt.value !== "")
.map((opt) => ({
label: opt.label,

View File

@@ -48,6 +48,7 @@ import type {
} from "@/types/trainScheduling";
import { ContainerPlacementGrid } from "./ContainerPlacementGrid";
import { locomotiveOption, showScheduleWarnings } from "./locomotiveOptions";
import {
autoFillPlacements,
mergePlacementsWithSaved,
@@ -274,6 +275,7 @@ export function AllocateBookingWizard({
const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds },
});
showScheduleWarnings(created.warnings);
setSelectedScheduleId(created.id);
return created.id;
};
@@ -536,10 +538,9 @@ export function AllocateBookingWizard({
placeholder={
routeId ? "Select at least two locomotives" : "Select a route first"
}
data={(locomotivesQuery.data ?? []).map((l) => ({
value: l.id,
label: `${l.code}${l.name ? ` · ${l.name}` : ""}`,
}))}
data={(locomotivesQuery.data ?? []).map((l) =>
locomotiveOption(l, " · "),
)}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable

View File

@@ -0,0 +1,172 @@
import { useMemo, useState } from "react";
import { PackageCheck } from "lucide-react";
import { Badge, Button, Checkbox, Group, Loader, Paper, Stack, Text } from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import type {
ImportLoadingBooking,
ImportLoadingBookingsResponse,
LoadingStatus,
} from "@/types/trainScheduling";
function ImportLoadingBookingRow({
booking,
selected,
onToggle,
}: {
booking: ImportLoadingBooking;
selected: boolean;
onToggle: () => void;
}) {
return (
<Group
align="flex-start"
wrap="nowrap"
p="sm"
style={{
border: `1px solid ${
selected ? "var(--mantine-color-edr-green-3)" : "var(--mantine-color-gray-2)"
}`,
borderRadius: 12,
background: selected ? "var(--mantine-color-edr-green-0)" : "white",
transition: "border-color 120ms ease, background 120ms ease",
}}
>
<Checkbox checked={selected} onChange={onToggle} mt={4} color="edr-green" />
<Stack gap={4} style={{ flex: 1 }}>
<Group gap="xs" wrap="wrap">
<PackageCheck size={14} />
<Text fw={600} size="sm">
{booking.reference ?? booking.id}
</Text>
<Badge
variant="light"
size="xs"
color={booking.loadingStatus === "LOADED" ? "edr-green" : "gray"}
>
{booking.loadingStatus}
</Badge>
</Group>
<Text size="xs" c="dimmed">
{booking.customer ?? "Unknown customer"}
</Text>
<Text size="xs" c="dimmed">
{booking.weightTons}T
</Text>
</Stack>
</Group>
);
}
export function ImportLoadingConfirmationPanel({
scheduleId,
items,
isLoading,
}: {
scheduleId: string;
items: ImportLoadingBooking[];
isLoading?: boolean;
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const queryClient = useQueryClient();
const updateStatus = useMutation<
ImportLoadingBookingsResponse,
Error,
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus }
>({
...api.trainScheduling.updateImportLoadingStatus.mutationOptions(),
onSuccess: () => {
setSelectedIds([]);
queryClient.invalidateQueries({
queryKey: api.trainScheduling.importLoadingBookings.queryKey({ id: scheduleId }),
});
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Could not update loading status");
},
});
const toggle = (id: string) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
};
const allIds = useMemo(() => items.map((b) => b.id), [items]);
if (isLoading) {
return (
<Group justify="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Loading import bookings
</Text>
</Group>
);
}
if (!items.length) {
return (
<Paper p="lg" radius="lg" withBorder bg="gray.0">
<Text size="sm" c="dimmed" ta="center">
No paid import bookings with wagons allocated on this schedule
</Text>
</Paper>
);
}
return (
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Text size="sm" fw={500}>
Import bookings ({items.length})
</Text>
<Group gap="sm">
<Button variant="light" size="compact-sm" onClick={() => setSelectedIds(allIds)}>
Select all
</Button>
<Button variant="subtle" size="compact-sm" onClick={() => setSelectedIds([])}>
Clear
</Button>
</Group>
</Group>
<Stack gap="sm">
{items.map((booking) => (
<ImportLoadingBookingRow
key={booking.id}
booking={booking}
selected={selectedIds.includes(booking.id)}
onToggle={() => toggle(booking.id)}
/>
))}
</Stack>
<Group gap="sm">
<Button
color="edr-green"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "LOADED" })
}
>
Mark loaded
</Button>
<Button
variant="outline"
disabled={!selectedIds.length}
loading={updateStatus.isPending}
onClick={() =>
updateStatus.mutate({ id: scheduleId, bookingIds: selectedIds, loadingStatus: "UNLOADED" })
}
>
Mark unloaded
</Button>
</Group>
</Stack>
);
}

Some files were not shown because too many files have changed in this diff Show More