diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f0beccd20..44be07550 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -172,7 +172,7 @@ jobs: 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 }}" + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build "${{ 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 diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index 984e622db..3c05c8a9f 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -1,14 +1,12 @@ # syntax=docker/dockerfile:1 # Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile . +# +# The base image (Node + Alpine Chromium/Puppeteer + pnpm) is built and pushed +# separately — see Dockerfile.base. Override the pinned tag at build time with +# --build-arg BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base: +ARG BASE_IMAGE=registry.license.aafda.gov.et/edr-public/freight-api-base:node24-alpine -FROM node:24.15.0-alpine AS base -RUN apk add --no-cache libc6-compat -# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit -# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -WORKDIR /app +FROM ${BASE_IMAGE} AS base FROM base AS pruner COPY . . @@ -31,8 +29,8 @@ COPY --from=builder /app/ . RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy -FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +FROM base AS runner + ENV NODE_ENV=production WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ diff --git a/apps/edr-freight-api/Dockerfile.base b/apps/edr-freight-api/Dockerfile.base new file mode 100644 index 000000000..9459958a2 --- /dev/null +++ b/apps/edr-freight-api/Dockerfile.base @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# Base image for edr-freight-api — Node + Alpine Chromium/Puppeteer + pnpm. +# Built and pushed separately so app builds pull it from Harbor instead of +# reinstalling the ~system Chromium toolchain on every build. +# +# Build + push (from monorepo root): +# docker build -f apps/edr-freight-api/Dockerfile.base \ +# -t registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine . +# docker push registry.license.aafda.gov.et/edr/freight-api-base:node24-alpine +# +# Bump the tag whenever Node, Chromium, or the apk set below changes, then +# update BASE_IMAGE in Dockerfile to match. + +FROM node:24.15.0-alpine + +# Puppeteer ships a glibc Chrome that cannot run on Alpine; skip the ~150MB +# download at install time. This stage installs Alpine's system Chromium. +ENV PUPPETEER_SKIP_DOWNLOAD=true +# Chromium + fonts for Puppeteer PDF rendering (contract/invoice/receipt docs). +# Without these, Puppeteer fails to launch and the code degrades to an +# unformatted plain-text PDF fallback. Use Alpine's system Chromium (musl-built); +# the glibc Chrome that `puppeteer install` downloads cannot run on Alpine. +RUN apk add --no-cache \ + libc6-compat \ + chromium \ + nss \ + freetype \ + harfbuzz \ + ca-certificates \ + ttf-freefont \ + font-noto-cjk +ENV NODE_ENV=production +# Point Puppeteer at the system Chromium and skip its bundled download. +ENV PUPPETEER_SKIP_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser +# Store pnpm's content-addressable store under PNPM_HOME so the BuildKit +# `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /app diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 559415fd8..a298b8dcb 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -1,7 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; -import { Contract } from '../modules/contracts/entities/contract.entity'; +import { + Contract, + ContractDocumentSnapshot, +} from '../modules/contracts/entities/contract.entity'; import { ContractRoute } from '../modules/contracts/entities/contract-route.entity'; import { ContractSignature, @@ -11,7 +14,10 @@ import { ContractPricingBreakdown } from '../modules/contracts/contract-pricing. import { ContractTemplatesService } from '../modules/contract-templates/contract-templates.service'; import { ContractTemplateResolver } from './contract-template.resolver'; import { getTemplateMeta } from './contract-template.registry'; -import { ContractViewModel } from './contract-view-model.builder'; +import { + ContractDynamicTemplateView, + ContractViewModel, +} from './contract-view-model.builder'; /** * Signature row for the contract PDF. Mirrors the booking builder's @@ -90,22 +96,36 @@ export class ContractDocumentViewModelBuilder { contract.contractTemplateKey ?? this.templateResolver.resolve(this.toResolverInput(contract)); let template = getTemplateMeta(templateKey); - // Prefer the admin-editable DB template matching the contract's - // direction/freight pair; fall back to the code-defined generic layout - // when none is active. - const dynamicSource = await this.contractTemplates.findActiveForContract( - contract.tradeDirection, - contract.freightType, - ); - const dynamicTemplate = dynamicSource - ? { - code: dynamicSource.code, - name: dynamicSource.name, - documentTitle: dynamicSource.documentTitle, - whereasClauses: dynamicSource.whereasClauses ?? [], - articles: dynamicSource.articles ?? [], - } - : undefined; + // The document articles come, in order of preference, from: + // 1. this contract's frozen snapshot (staff accepted / edited it) — the + // shared six templates are never consulted for these contracts; + // 2. the admin-editable DB template matching the direction/freight pair; + // 3. the code-defined generic layout (handled below when none of the above). + const snapshot = contract.documentSnapshot as ContractDocumentSnapshot | null; + let dynamicTemplate: ContractDynamicTemplateView | undefined; + if (snapshot && (snapshot.articles?.length ?? 0) > 0) { + dynamicTemplate = { + code: snapshot.code ?? 'CONTRACT', + name: snapshot.name ?? template.title, + documentTitle: snapshot.documentTitle ?? '', + whereasClauses: snapshot.whereasClauses ?? [], + articles: snapshot.articles, + }; + } else { + const dynamicSource = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + dynamicTemplate = dynamicSource + ? { + code: dynamicSource.code, + name: dynamicSource.name, + documentTitle: dynamicSource.documentTitle, + whereasClauses: dynamicSource.whereasClauses ?? [], + articles: dynamicSource.articles ?? [], + } + : undefined; + } if (dynamicTemplate) { template = { ...template, diff --git a/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts new file mode 100644 index 000000000..5a667e2e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2190000000000-DropReopenDelayMinutes.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Drop the unused reopen-delay knob from the global rules. + * + * The window engine never honoured `reopen_delay_minutes`: a not-yet-full train + * reopens as soon as its payment phase settles, so the real gap between a cycle + * closing and reopening is doc review + payment — nothing else. The per-schedule + * `rule_reopen_delay_minutes` snapshot stays: it freezes that derived gap at + * creation so the batch board keeps projecting the cycles the customer was shown. + */ +export class DropReopenDelayMinutes2190000000000 implements MigrationInterface { + name = "DropReopenDelayMinutes2190000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + DROP COLUMN IF EXISTS reopen_delay_minutes; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_scheduling_global_rules + ADD COLUMN IF NOT EXISTS reopen_delay_minutes integer NOT NULL DEFAULT 90; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts new file mode 100644 index 000000000..65a3d3cda --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2200000000000-TrainNumberPair.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every built train owns a fixed pair of run numbers, typed at build time: + * an EXPORT number (odd, e.g. 8001) and an IMPORT number (even, e.g. 8002). + * Scheduling copies the route-direction-matched number onto the schedule at + * creation; legacy trains with a null pair keep dispatch-time pool assignment. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class TrainNumberPair2200000000000 implements MigrationInterface { + name = 'TrainNumberPair2200000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.trains + ADD COLUMN IF NOT EXISTS import_train_number varchar(20), + ADD COLUMN IF NOT EXISTS export_train_number varchar(20); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_import_train_number" + ON freight.trains (import_train_number) + WHERE import_train_number IS NOT NULL; + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_trains_export_train_number" + ON freight.trains (export_train_number) + WHERE export_train_number IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_export_train_number";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."UQ_trains_import_train_number";`); + await queryRunner.query(` + ALTER TABLE freight.trains + DROP COLUMN IF EXISTS export_train_number, + DROP COLUMN IF EXISTS import_train_number; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts new file mode 100644 index 000000000..42e8f1dc8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2210000000000-ScheduleScopedWagonPins.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Schedule-scoped wagon pins. + * + * Wagon occupancy now lives ONLY on each schedule's own train_set_wagons slots + * (the per-schedule snapshot): pinning/releasing a wagon no longer mutates the + * Wagon entity, so the same physical wagon can serve many schedules (the July 17 + * and July 20 runs of one train both use its 50 wagons). The Wagon columns + * `current_train_schedule_id` / `train_set_wagon_id` keep only their physical + * meaning — "out on this DISPATCHED train right now" (stamped at dispatch, + * cleared at arrive/unload/cancel). + * + * This migration erases the legacy pin-time stamps left by the old flow: any + * wagon pointing at a schedule that is not currently DISPATCHED (or that no + * longer exists) gets its pointers cleared, and — when the old flow had parked + * it in ASSIGNED — its status returns to the pool semantics (ASSIGNED only + * while coupled to a built train, otherwise AVAILABLE). + */ +export class ScheduleScopedWagonPins2210000000000 implements MigrationInterface { + name = "ScheduleScopedWagonPins2210000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons w + SET current_train_schedule_id = NULL, + train_set_wagon_id = NULL, + status = CASE + WHEN w.status = 'ASSIGNED' AND w.train_id IS NULL THEN 'AVAILABLE' + ELSE w.status + END + WHERE w.deleted_at IS NULL + AND w.current_train_schedule_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM freight.train_schedules ts + WHERE ts.id = w.current_train_schedule_id + AND ts.deleted_at IS NULL + AND ts.status = 'DISPATCHED' + ); + `); + } + + public async down(_queryRunner: QueryRunner): Promise { + // Pin-time stamps cannot be reconstructed (the data was the bug); the + // slots on train_set_wagons still hold every live pin, so down is a no-op. + } +} diff --git a/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts new file mode 100644 index 000000000..5a8cdd035 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2220000000000-AddContractDocumentSnapshot.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds freight.contracts.document_snapshot — a per-contract frozen copy of the + * contract-document template (articles + WHEREAS recitals) captured at staff + * accept. Staff can edit these articles for a single contract before generating + * its PDF; the edit never touches the shared six freight.contract_templates + * rows. Null on existing contracts → the PDF keeps rendering from the live + * template, so this is backward compatible. + */ +export class AddContractDocumentSnapshot2220000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + ADD COLUMN IF NOT EXISTS document_snapshot JSONB; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.contracts + DROP COLUMN IF EXISTS document_snapshot; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts new file mode 100644 index 000000000..2fe7c726d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2230000000000-RenameWagonStatusRetiredToDetained.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagon status RETIRED is renamed DETAINED (wagons pulled from circulation). + * The column is a plain varchar, so this is a data-only rename. Vehicles keep + * their own RETIRED status — only freight.wagons rows are touched. + */ +export class RenameWagonStatusRetiredToDetained2230000000000 + implements MigrationInterface +{ + name = 'RenameWagonStatusRetiredToDetained2230000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons SET status = 'DETAINED' WHERE status = 'RETIRED' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.wagons SET status = 'RETIRED' WHERE status = 'DETAINED' + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts new file mode 100644 index 000000000..76a59b0f7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2240000000000-AddTransferRequestReason.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Every new wagon-transfer request must state WHY the wagons are needed; the + * reason is shown on the OCC request queue. Nullable in the DB — legacy rows + * predate the requirement; the DTO enforces it for new requests. + */ +export class AddTransferRequestReason2240000000000 implements MigrationInterface { + name = 'AddTransferRequestReason2240000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS reason text NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS reason + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts new file mode 100644 index 000000000..f93fc7c95 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2250000000000-CreatePriorityRuleChangeRequests.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Approval workflow for priority-rule changes: every create/update/delete of a + * priority config is filed here as a PENDING change request; an approver + * applies or rejects it. `payload` carries the proposed field values (null for + * DELETE), `priority_config_id` the target row (null for CREATE). + */ +export class CreatePriorityRuleChangeRequests2250000000000 + implements MigrationInterface +{ + name = 'CreatePriorityRuleChangeRequests2250000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.priority_rule_change_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + action varchar(10) NOT NULL, + priority_config_id uuid NULL REFERENCES freight.priority_configs (id), + payload jsonb NULL, + status varchar(10) NOT NULL DEFAULT 'PENDING', + requested_by_user_id uuid NULL, + decided_by_user_id uuid NULL, + decided_at timestamptz NULL, + decision_note text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL + ) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_prcr_status + ON freight.priority_rule_change_requests (status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.priority_rule_change_requests`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 7b1522446..698759901 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -1003,18 +1003,26 @@ export class BookingTransitionService { } // The binding shipment day must have at least one OPEN departure on the - // route — only schedule-backed days are selectable. The batch engine - // assigns the specific train within that (route, day) pool later. - const hasDeparture = await this.bookingsService.hasOpenDepartureOnDay( - booking.originYardId, - booking.destinationYardId, - eatDay(date), - ); + // route — only schedule-backed days are selectable — AND some departure + // that day must be able to physically carry this cargo type (wagon-TYPE + // gate; quantity never blocks — oversized bookings get a partial split + // offer). The batch engine assigns the specific train within that + // (route, day) pool later. + const { hasDeparture, hasCompatible } = + await this.bookingsService.checkDayCompatibilityForBooking( + booking, + eatDay(date), + ); if (!hasDeparture) { throw new BadRequestException( "No departures available on the selected day for this route", ); } + if (!hasCompatible) { + throw new BadRequestException( + "No wagon on the selected day can carry this cargo type — please choose another day", + ); + } await this.bookingsRepository.update(bookingId, { status: "OPERATION_REQUEST_PENDING", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f556751fb..53554b2e3 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -348,6 +348,28 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Get(':id/available-days') + @ApiOperation({ + summary: + 'Days bookable for THIS booking (cargo-aware wagon-TYPE gate; days only, no capacity counts)', + }) + async availableDays( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) + ) { + await this.bookingsService.assertCustomerCanAccessBooking( + user?.id, + booking, + ); + } + return this.bookingsService.availableDaysForBooking(id); + } + @Get(':id/mile-summary') @ApiOperation({ summary: 'First/last-mile operational summary for a booking (customer-safe)', diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index dd5011df3..4aade3bf2 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -653,22 +653,37 @@ export class BookingsService { } else if (dto.scheduledDate) { // A real (binding) scheduledDate was supplied (e.g. staff pinning a day // directly). Require that the route has at least one OPEN departure on - // that EAT day. The booking wizard does NOT send scheduledDate at creation - // — it captures a non-binding estimatedShipmentDate instead, and the - // binding day is chosen later at the operation-request step. General - // contracts also skip this (each drawdown order validates its own day). + // that EAT day AND that some departure that day can physically carry the + // cargo (wagon-TYPE gate — quantity never blocks; oversized bookings get + // a partial split offer later). The booking wizard does NOT send + // scheduledDate at creation — it captures a non-binding + // estimatedShipmentDate instead, and the binding day is chosen later at + // the operation-request step. General contracts also skip this (each + // drawdown order validates its own day). const day = eatDay(new Date(dto.scheduledDate)); - const hasDeparture = - await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + const { hasDeparture, hasCompatible } = + await this.trainSchedulingService.checkDayCargoCompatibility( dto.originYardId, dto.destinationYardId, day, + { + freightType: dto.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: dto.cargoTypeId, + containerTypeIds: (dto.containers ?? []) + .map((c) => c.containerTypeId) + .filter((id): id is string => Boolean(id)), + }, ); if (!hasDeparture) { throw new BadRequestException( 'No departures available on the selected day for this route', ); } + if (!hasCompatible) { + throw new BadRequestException( + 'No wagon on the selected day can carry this cargo type — please choose another day', + ); + } } const containers = dto.containers ?? []; @@ -1149,6 +1164,52 @@ export class BookingsService { ); } + /** Cargo identity of a booking for the wagon-TYPE compatibility gate. */ + private cargoIdentityOf(booking: Booking): { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + containerTypeIds?: string[]; + } { + return { + freightType: booking.freightType as 'CONTAINER' | 'BULK', + cargoTypeId: booking.cargoTypeId ?? null, + containerTypeIds: (booking.bookingContainers ?? []) + .map((line) => line.containerTypeId) + .filter((id): id is string => Boolean(id)), + }; + } + + /** + * Day gate for a specific booking: OPEN departure exists AND some departure + * that day can physically carry the booking's cargo/container type. + * Quantity never blocks — oversized bookings get a partial split offer. + */ + async checkDayCompatibilityForBooking( + booking: Booking, + day: string, + ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> { + return this.trainSchedulingService.checkDayCargoCompatibility( + booking.originYardId, + booking.destinationYardId, + day, + this.cargoIdentityOf(booking), + ); + } + + /** + * Days the customer may pick for THIS booking (operation-request step): + * cargo-aware — only days whose departures can carry the booking's cargo + * type. Returns days only, no capacity counts. + */ + async availableDaysForBooking(bookingId: string): Promise<{ days: string[] }> { + const booking = await this.findById(bookingId); + return this.trainSchedulingService.getAvailableDaysForCargo({ + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + ...this.cargoIdentityOf(booking), + }); + } + /** * Batched version of the findById flag: marks each page item whose booking * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 4cc15854e..79d15069b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -86,6 +86,7 @@ export const SCHEDULING_STATUSES = [ SchedulingStatus.Eligible, SchedulingStatus.Scheduled, SchedulingStatus.Dispatched, + SchedulingStatus.WaitingForWagon, ] as const; export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4da2677bd..049db2a94 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -4,6 +4,7 @@ import { Injectable, Logger, } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; import { Readable } from 'stream'; import { insertWithGeneratedReference } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; @@ -21,16 +22,35 @@ import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings. import { FilesService } from '../files/files.service'; import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; +import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; import { ContractsService } from './contracts.service'; import { contractClearanceSettingCode } from './contract-clearance.util'; -import { Contract } from './entities/contract.entity'; +import { + Contract, + ContractDocumentArticle, + ContractDocumentSnapshot, + ContractDocumentSnapshotInput, +} from './entities/contract.entity'; import { ContractSignerRole } from './entities/contract-signature.entity'; import { SignContractDto } from './dto/sign-contract.dto'; +/** The editable contract-document draft returned for the accept/edit dialog. */ +export interface ContractDocumentDraft { + documentTitle: string | null; + whereasClauses: string[]; + articles: ContractDocumentArticle[]; + code: string | null; + name: string | null; + /** True once the document may no longer be edited/regenerated. */ + locked: boolean; + generatedAt: Date | null; + status: string; +} + /** * Dropdown-settings code holding the admin-configured contract validity options * (each option's `value` is a day count). The staff accept dialog reads the same @@ -68,6 +88,7 @@ export class ContractTransitionService { private readonly minioService: MinioService, private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, + private readonly contractTemplates: ContractTemplatesService, ) {} /** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */ @@ -110,6 +131,7 @@ export class ContractTransitionService { contractId: string, actorId: string, validityDays: number, + documentSnapshot?: ContractDocumentSnapshotInput | null, ): Promise { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['SUBMITTED']); @@ -128,6 +150,12 @@ export class ContractTransitionService { await this.instantiateApprovalSteps(contract); + // Freeze the contract document for THIS contract only. Staff may have edited + // the articles in the accept dialog; otherwise the live template is captured + // as-is so later template edits never change an in-flight contract. The + // shared six templates are never written here. + const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot); + await this.contractsRepository.update(contractId, { status: 'PENDING_APPROVAL', approvedByStaffId: actorId, @@ -135,12 +163,148 @@ export class ContractTransitionService { contractValidityDays: validityDays, contractValidFrom: validFrom, contractValidUntil: validUntil, + documentSnapshot: snapshot, } as never); const updated = await this.contractsService.findById(contractId); this.notifier.accepted(updated); return updated; } + // ── Per-contract document snapshot (US: edit articles for one contract) ───── + + /** + * The editable document draft for the accept/edit dialog: the frozen snapshot + * if one exists, else the live active template resolved for this contract's + * direction/freight pair. `locked` flips true once the document may no longer + * be edited (an approver has acted, or the contract has left the pre-approval + * window). + */ + async getContractDocumentDraft( + contractId: string, + ): Promise { + const contract = await this.contractsService.findById(contractId); + const snapshot = + (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? + (await this.resolveDocumentSnapshot(contract)); + return { + documentTitle: snapshot?.documentTitle ?? null, + whereasClauses: snapshot?.whereasClauses ?? [], + articles: snapshot?.articles ?? [], + code: snapshot?.code ?? null, + name: snapshot?.name ?? null, + locked: !this.documentIsEditable(contract), + generatedAt: contract.contractGeneratedAt ?? null, + status: contract.status, + }; + } + + /** + * Replace this contract's document articles from the editor. Per-contract + * only — it writes the contract's own snapshot and never the shared templates. + * Allowed while the document is still editable (PENDING_APPROVAL, no approver + * has acted). + */ + async updateContractDocument( + contractId: string, + input: ContractDocumentSnapshotInput, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertContractStatus(contract, ['PENDING_APPROVAL']); + this.assertDocumentEditable(contract); + + const current = + (contract.documentSnapshot as ContractDocumentSnapshot | null) ?? + (await this.resolveDocumentSnapshot(contract)); + const merged: ContractDocumentSnapshotInput = { + code: current?.code ?? null, + name: input.name ?? current?.name ?? null, + documentTitle: input.documentTitle ?? current?.documentTitle ?? null, + whereasClauses: input.whereasClauses ?? current?.whereasClauses ?? [], + articles: input.articles ?? current?.articles ?? [], + }; + await this.contractsRepository.update(contractId, { + documentSnapshot: this.normalizeSnapshot(merged), + } as never); + return this.contractsService.findById(contractId); + } + + /** + * Build the per-contract document snapshot. Prefer the staff's edited articles + * from the dialog; otherwise freeze the active template matching the + * contract's direction/freight. Returns null when no active template exists + * (the renderer then falls back to the built-in generic layout at render time). + */ + private async resolveDocumentSnapshot( + contract: Contract, + provided?: ContractDocumentSnapshotInput | null, + ): Promise { + if (provided && (provided.articles?.length ?? 0) > 0) { + return this.normalizeSnapshot(provided); + } + const active = await this.contractTemplates.findActiveForContract( + contract.tradeDirection, + contract.freightType, + ); + if (!active) return null; + return { + code: active.code, + name: active.name, + documentTitle: active.documentTitle, + whereasClauses: active.whereasClauses ?? [], + articles: this.normalizeArticles(active.articles ?? []), + }; + } + + private normalizeSnapshot( + input: ContractDocumentSnapshotInput, + ): ContractDocumentSnapshot { + return { + code: input.code ?? null, + name: input.name ?? null, + documentTitle: input.documentTitle ?? null, + whereasClauses: Array.isArray(input.whereasClauses) + ? input.whereasClauses + .map((c) => String(c)) + .filter((c) => c.trim().length > 0) + : [], + articles: this.normalizeArticles(input.articles ?? []), + }; + } + + /** Re-key ids and renumber order sequentially, dropping empty-title rows. */ + private normalizeArticles( + articles: Array<{ id?: string; title?: string; body?: string; order?: number }>, + ): ContractDocumentArticle[] { + return articles + .filter((a) => (a.title ?? '').trim().length > 0 || (a.body ?? '').trim().length > 0) + .map((a, index) => ({ + id: a.id ?? randomUUID(), + title: (a.title ?? '').trim(), + body: a.body ?? '', + order: index + 1, + })); + } + + /** + * The per-contract document may be edited/regenerated while the contract is at + * the accept stage (SUBMITTED) or in approval with NO approver having acted + * yet. The first approval action freezes it. + */ + private documentIsEditable(contract: Contract): boolean { + if (contract.status === 'SUBMITTED') return true; + if (contract.status !== 'PENDING_APPROVAL') return false; + return !(contract.approvalSteps ?? []).some((s) => s.status !== 'PENDING'); + } + + private assertDocumentEditable(contract: Contract): void { + if (!this.documentIsEditable(contract)) { + throw new ConflictException( + 'The contract document is locked — an approver has already acted or the ' + + 'contract has advanced. It can no longer be edited or regenerated.', + ); + } + } + /** * Ensure the chosen validity (days) is one of the admin-configured options in * the `contract_validity_periods` dropdown setting. If the setting is missing @@ -303,6 +467,15 @@ export class ContractTransitionService { const contract = await this.contractsService.findById(contractId); assertContractStatus(contract, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']); + // Approvers review the generated contract document, so it must exist before + // the first approval can be recorded. Staff generate it (from the frozen, + // optionally-edited snapshot) at the accept stage. + if (contract.status === 'PENDING_APPROVAL' && !contract.contractGeneratedAt) { + throw new BadRequestException( + 'Generate the contract document before it can be approved.', + ); + } + const step = await this.contractsRepository.findApprovalStepById(contractId, stepId); if (!step || step.status !== 'PENDING') { throw new BadRequestException('Approval step not found or already actioned'); @@ -350,15 +523,14 @@ export class ContractTransitionService { const updated = await this.contractsService.findById(contractId); if (allDone) { this.notifier.approved(updated); - // Final approval step also generates the contract document from the - // template matching the contract's direction/freight pair. Best-effort: - // a rendering hiccup must not roll back the approval — the document can - // still be generated manually or lazily on view/download. + // Every step approved → CONTRACT_READY. The document was already generated + // (and reviewed) at the accept stage, so we reuse it rather than + // re-rendering. Best-effort: a hiccup must not roll back the approval. try { - return await this.generateContract(contractId); + return await this.finalizeApprovedContract(contractId); } catch (err) { this.logger.warn( - `Auto contract generation after final approval failed for ${updated.reference}: ${err}`, + `Finalizing contract after final approval failed for ${updated.reference}: ${err}`, ); } } @@ -366,30 +538,66 @@ export class ContractTransitionService { } /** - * Render the contract PDF from the Contract aggregate, store it via FilesService, - * stamp the template key, and move to CONTRACT_READY. PDF rendering (Puppeteer/ - * Chromium) is best-effort and must NOT block the contract from becoming ready — - * the document is (re)rendered lazily on view/download once Chromium is available. + * Staff (re)generate the contract PDF. Two stages: + * - PENDING_APPROVAL: render from the frozen (optionally staff-edited) + * snapshot so approvers review the real document. Status is UNCHANGED, and + * it is blocked once an approver has acted (the document is then locked). + * - APPROVED / APPROVED_PENDING_SIGNATURE (fallback): render and advance to + * CONTRACT_READY. + * PDF rendering (Puppeteer/Chromium) is best-effort and never blocks the + * transition — the document re-renders lazily on view/download. */ async generateContract(contractId: string): Promise { const contract = await this.contractsService.findById(contractId); + + if (contract.status === 'PENDING_APPROVAL') { + this.assertDocumentEditable(contract); + await this.renderContractDocument(contract); + return this.contractsService.findById(contractId); + } + assertContractStatus(contract, ['APPROVED', 'APPROVED_PENDING_SIGNATURE']); + await this.renderContractDocument(contract); + await this.contractsRepository.update(contractId, { + status: 'CONTRACT_READY', + } as never); + return this.contractsService.findById(contractId); + } - const { view } = await this.documentViewModelBuilder.build(contractId); - + /** + * Render the contract PDF from the Contract aggregate (snapshot-driven), store + * it via FilesService, and stamp the template key + generated timestamp. Never + * changes status. Rendering is best-effort — a Chromium hiccup defers the file + * (it re-renders on view/download) but the timestamp is still stamped. + */ + private async renderContractDocument(contract: Contract): Promise { + const { view } = await this.documentViewModelBuilder.build(contract.id); try { - await this.upsertContractPdf(contractId, contract.reference, view); + await this.upsertContractPdf(contract.id, contract.reference, view); } catch (err) { this.logger.warn( `Contract PDF deferred for ${contract.reference}: ${err}. It will render on view/download once Chromium is available.`, ); } - - await this.contractsRepository.update(contractId, { - status: 'CONTRACT_READY', + await this.contractsRepository.update(contract.id, { contractTemplateKey: view.templateKey, contractGeneratedAt: new Date(), } as never); + } + + /** + * Every approval step landed → CONTRACT_READY. The document was already + * generated (and reviewed) at the accept stage, so reuse it; render now only + * if it was somehow never generated. Never re-renders over an existing file. + */ + private async finalizeApprovedContract(contractId: string): Promise { + const contract = await this.contractsService.findById(contractId); + if (!contract.contractGeneratedAt) { + await this.renderContractDocument(contract); + } + await this.contractsRepository.update(contractId, { + status: 'CONTRACT_READY', + } as never); return this.contractsService.findById(contractId); } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 2b79d3274..2957124f8 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -8,6 +8,7 @@ import { ParseUUIDPipe, Patch, Post, + Put, Query, Res, UnauthorizedException, @@ -57,6 +58,7 @@ import { UpdateContractDto } from './dto/update-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto'; import { ContractListSummaryDto } from './dto/contract-list-summary.dto'; import { AcceptContractDto } from './dto/accept-contract.dto'; +import { UpdateContractDocumentDto } from './dto/contract-document.dto'; import { ApproveStepDto, RejectContractDto, @@ -340,9 +342,33 @@ export class ContractsController { id, resolveAuthUserId(user), dto.validityDays, + dto.documentSnapshot, ); } + @Get(':id/document/draft') + @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @ApiOperation({ + summary: + 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', + }) + getContractDocumentDraft(@Param('id', ParseUUIDPipe) id: string) { + return this.transitionService.getContractDocumentDraft(id); + } + + @Put(':id/document/articles') + @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @ApiOperation({ + summary: + 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', + }) + updateContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateContractDocumentDto, + ) { + return this.transitionService.updateContractDocument(id, dto); + } + @Post(':id/staff/request-changes') @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) @ApiOperation({ summary: 'Staff return contract for customer updates' }) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts index 86e1260c4..d3eaa73a3 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/accept-contract.dto.ts @@ -1,5 +1,8 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsInt, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min, ValidateNested } from 'class-validator'; + +import { UpdateContractDocumentDto } from './contract-document.dto'; export class AcceptContractDto { @ApiProperty({ @@ -14,4 +17,16 @@ export class AcceptContractDto { @Min(1) @Max(3650) validityDays!: number; + + /** + * Optional per-contract document override edited by staff in the accept + * dialog. When present its articles are frozen onto THIS contract; when + * omitted the live template is snapshotted as-is. Never edits the shared + * six templates. + */ + @ApiPropertyOptional({ type: UpdateContractDocumentDto }) + @IsOptional() + @ValidateNested() + @Type(() => UpdateContractDocumentDto) + documentSnapshot?: UpdateContractDocumentDto; } diff --git a/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts new file mode 100644 index 000000000..7fdb8477e --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/contract-document.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsInt, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +/** One article of a per-contract document override sent from the editor. */ +export class ContractDocumentArticleDto { + @ApiPropertyOptional({ description: 'Stable id; omitted for a new article.' }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty() + @IsString() + title!: string; + + @ApiProperty({ description: 'Plain multiline body; each line becomes a clause.' }) + @IsString() + body!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsInt() + order?: number; +} + +/** + * The per-contract document override sent from the accept/edit editor. It edits + * ONLY this contract's frozen snapshot — it is never written back to the shared + * six {@link ContractTemplate} rows. + */ +export class UpdateContractDocumentDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + code?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + name?: string | null; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + documentTitle?: string | null; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + whereasClauses?: string[]; + + @ApiProperty({ type: [ContractDocumentArticleDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ContractDocumentArticleDto) + articles!: ContractDocumentArticleDto[]; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 0b0fab41b..f29b9093b 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -42,6 +42,43 @@ export const CONTRACT_STATUSES = [ export type ContractStatus = (typeof CONTRACT_STATUSES)[number]; +/** One article on a per-contract document snapshot (mirrors the template shape). */ +export interface ContractDocumentArticle { + id: string; + title: string; + body: string; + order: number; +} + +/** + * A per-contract copy of the resolved contract-document template, frozen when + * staff accept the contract for approval. Staff may edit these articles for a + * single contract in the accept/edit dialog — editing NEVER writes back to the + * shared six {@link ContractTemplate} rows. The PDF is rendered from this + * snapshot when present; a null snapshot renders from the live template. + */ +export interface ContractDocumentSnapshot { + code?: string | null; + name?: string | null; + documentTitle?: string | null; + whereasClauses: string[]; + articles: ContractDocumentArticle[]; +} + +/** Loose inbound shape (article ids/order optional) — normalized before store. */ +export interface ContractDocumentSnapshotInput { + code?: string | null; + name?: string | null; + documentTitle?: string | null; + whereasClauses?: string[]; + articles?: Array<{ + id?: string; + title?: string; + body?: string; + order?: number; + }>; +} + export const CONTRACT_KINDS = ['ONE_TIME', 'GENERAL'] as const; export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; @@ -193,6 +230,14 @@ export class Contract extends BaseEntity { @Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true }) contractGeneratedAt?: Date | null; + /** + * Per-contract frozen copy of the document template (articles + WHEREAS), + * captured at staff accept. Editing it affects only this contract, never the + * shared six templates. Null → the PDF renders from the live template. + */ + @Column({ name: 'document_snapshot', type: 'jsonb', nullable: true }) + documentSnapshot?: ContractDocumentSnapshot | null; + @Column({ name: 'contract_summary', type: 'text', nullable: true }) contractSummary?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts new file mode 100644 index 000000000..73ff94d31 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/priority-rule-change-requests.controller.ts @@ -0,0 +1,72 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { + DecidePriorityRuleChangeDto, + SubmitPriorityRuleChangeDto, +} from '../dto/priority-rule-change-request.dto'; +import { PriorityRuleChangeStatus } from '../entities/priority-rule-change-request.entity'; +import { PriorityRuleChangeRequestsService } from '../services/priority-rule-change-requests.service'; + +/** + * Approval workflow for priority-rule changes. Anyone with the manage + * permission SUBMITS a change; an approver (same permission — the team decides + * who reviews) approves or rejects it. The team is notified at each step. + */ +@ApiTags('priority-rule-change-requests') +@Controller('priority-rule-change-requests') +@ApiBearerAuth() +export class PriorityRuleChangeRequestsController { + constructor(private readonly service: PriorityRuleChangeRequestsService) {} + + @Post() + @RuleEngineManage('priority-configs') + @ApiOperation({ summary: 'Submit a priority-rule change for approval' }) + submit( + @Body() dto: SubmitPriorityRuleChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.submit(dto, user?.id); + } + + @Get() + @RuleEngineView('priority-configs') + @ApiQuery({ name: 'status', required: false, enum: ['PENDING', 'APPROVED', 'REJECTED'] }) + @ApiOperation({ summary: 'List priority-rule change requests' }) + list(@Query('status') status?: PriorityRuleChangeStatus) { + return this.service.list(status); + } + + @Post(':id/approve') + @RuleEngineManage('priority-configs') + @ApiOperation({ summary: 'Approve and apply a pending change' }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecidePriorityRuleChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id, dto.decisionNote); + } + + @Post(':id/reject') + @RuleEngineManage('priority-configs') + @ApiOperation({ summary: 'Reject a pending change' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: DecidePriorityRuleChangeDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id, dto.decisionNote); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts new file mode 100644 index 000000000..31295b050 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/priority-rule-change-request.dto.ts @@ -0,0 +1,49 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsIn, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; + +import { CreatePriorityConfigDto } from './create-priority-config.dto'; +import { UpdatePriorityConfigDto } from './update-priority-config.dto'; + +/** + * File a priority-rule change for approval. CREATE carries a full `create` + * payload; UPDATE carries the target id + an `update` patch; DELETE carries + * only the target id. + */ +export class SubmitPriorityRuleChangeDto { + @ApiProperty({ enum: ['CREATE', 'UPDATE', 'DELETE'] }) + @IsIn(['CREATE', 'UPDATE', 'DELETE']) + action!: 'CREATE' | 'UPDATE' | 'DELETE'; + + @ApiPropertyOptional({ description: 'Target rule id (UPDATE / DELETE)' }) + @IsOptional() + @IsUUID() + priorityConfigId?: string; + + @ApiPropertyOptional({ description: 'Proposed new rule (CREATE)' }) + @IsOptional() + @ValidateNested() + @Type(() => CreatePriorityConfigDto) + create?: CreatePriorityConfigDto; + + @ApiPropertyOptional({ description: 'Proposed field changes (UPDATE)' }) + @IsOptional() + @ValidateNested() + @Type(() => UpdatePriorityConfigDto) + update?: UpdatePriorityConfigDto; +} + +export class DecidePriorityRuleChangeDto { + @ApiPropertyOptional({ description: 'Optional note shown to the requester' }) + @IsOptional() + @IsString() + @MaxLength(1000) + decisionNote?: string; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts new file mode 100644 index 000000000..ec2425745 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/priority-rule-change-request.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { PriorityConfig } from './priority-config.entity'; + +export type PriorityRuleChangeAction = 'CREATE' | 'UPDATE' | 'DELETE'; +export type PriorityRuleChangeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'; + +/** + * One proposed change to a priority rule, awaiting approval. Every + * create/update/delete of a priority config is filed here first; an approver + * applies (which runs the real mutation, including range-collision checks) or + * rejects it. `payload` holds the proposed field values (null for DELETE); + * `priorityConfigId` the target rule (null for CREATE). + */ +@Entity({ schema: 'freight', name: 'priority_rule_change_requests' }) +@Index(['status']) +export class PriorityRuleChangeRequest extends BaseEntity { + @Column({ name: 'action', type: 'varchar', length: 10 }) + action!: PriorityRuleChangeAction; + + @Column({ name: 'priority_config_id', type: 'uuid', nullable: true }) + priorityConfigId?: string | null; + + @ManyToOne(() => PriorityConfig, { nullable: true }) + @JoinColumn({ name: 'priority_config_id' }) + priorityConfig?: PriorityConfig | null; + + @Column({ name: 'payload', type: 'jsonb', nullable: true }) + payload?: Record | null; + + @Column({ name: 'status', type: 'varchar', length: 10, default: 'PENDING' }) + status!: PriorityRuleChangeStatus; + + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) + requestedByUserId?: string | null; + + @Column({ name: 'decided_by_user_id', type: 'uuid', nullable: true }) + decidedByUserId?: string | null; + + @Column({ name: 'decided_at', type: 'timestamptz', nullable: true }) + decidedAt?: Date | null; + + @Column({ name: 'decision_note', type: 'text', nullable: true }) + decisionNote?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts index 34dc9f982..7edcf0bbf 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.module.ts @@ -5,6 +5,7 @@ import { ApprovalRulesController } from './controllers/approval-rules.controller import { CargoTypesController } from './controllers/cargo-types.controller'; import { ContainerTypesController } from './controllers/container-types.controller'; import { PriorityConfigsController } from './controllers/priority-configs.controller'; +import { PriorityRuleChangeRequestsController } from './controllers/priority-rule-change-requests.controller'; import { RatesController } from './controllers/rates.controller'; import { ServiceTypesController } from './controllers/service-types.controller'; import { ShippingLinesController } from './controllers/shipping-lines.controller'; @@ -15,6 +16,7 @@ import { ApprovalRule } from './entities/approval-rule.entity'; import { CargoType } from './entities/cargo-type.entity'; import { ContainerType } from './entities/container-type.entity'; import { PriorityConfig } from './entities/priority-config.entity'; +import { PriorityRuleChangeRequest } from './entities/priority-rule-change-request.entity'; import { Rate } from './entities/rate.entity'; import { ServiceType } from './entities/service-type.entity'; import { ShippingLine } from './entities/shipping-line.entity'; @@ -46,6 +48,7 @@ import { DisplayOrderService } from './services/display-order.service'; import { CargoTypesService } from './services/cargo-types.service'; import { ContainerTypesService } from './services/container-types.service'; import { PriorityConfigsService } from './services/priority-configs.service'; +import { PriorityRuleChangeRequestsService } from './services/priority-rule-change-requests.service'; import { RatesService } from './services/rates.service'; import { ServiceTypesService } from './services/service-types.service'; import { ShippingLinesService } from './services/shipping-lines.service'; @@ -54,6 +57,8 @@ import { YardsService } from './services/yards.service'; import { RuleEngineService } from './rule-engine.service'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; + import { BookingApprovalStep } from '../bookings/entities/booking-approval-step.entity'; import { BookingCargoModifier } from '../bookings/entities/booking-cargo-modifier.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; @@ -66,6 +71,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoType, ContainerType, PriorityConfig, + PriorityRuleChangeRequest, ServiceType, WeightLimitRule, Yard, @@ -77,11 +83,14 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. BookingApprovalStep, BookingRateSnapshot, ]), + // Team notifications for the priority-rule approval workflow. + NotificationInboxModule, ], controllers: [ CargoTypesController, ContainerTypesController, PriorityConfigsController, + PriorityRuleChangeRequestsController, ServiceTypesController, WeightLimitRulesController, YardsController, @@ -111,6 +120,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot. CargoTypesService, ContainerTypesService, PriorityConfigsService, + PriorityRuleChangeRequestsService, ServiceTypesService, WeightLimitRulesService, YardsService, diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts index 560a550b2..9aaf06985 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-configs.service.ts @@ -31,6 +31,12 @@ export class PriorityConfigsService { async create(dto: CreatePriorityConfigDto): Promise { this.validateCurrencyField(dto.type, dto.currency); + await this.assertNoRangeCollision({ + type: dto.type, + currency: dto.currency ?? null, + minWagonCount: dto.minWagonCount, + maxWagonCount: dto.maxWagonCount, + }); const displayOrder = await this.displayOrder.resolveCreateOrder(PriorityConfig, 'displayOrder', {}); @@ -52,6 +58,13 @@ export class PriorityConfigsService { const type = dto.type ?? existing.type; const currency = dto.currency !== undefined ? dto.currency : existing.currency; this.validateCurrencyField(type, currency); + await this.assertNoRangeCollision({ + type, + currency: currency ?? null, + minWagonCount: dto.minWagonCount ?? existing.minWagonCount, + maxWagonCount: dto.maxWagonCount ?? existing.maxWagonCount, + excludeId: id, + }); const { ...patch } = dto; const updated = await this.repository.update(id, patch); @@ -59,6 +72,43 @@ export class PriorityConfigsService { return updated; } + /** + * No two rules of the same type (and, for CURRENCY rules, the same currency) + * may cover overlapping wagon-count ranges — a booking must match at most one + * rule per type. Rejects an exact duplicate (1–5 vs 1–5) and any partial + * overlap (1–5 vs 4–7). Ranges are inclusive on both ends. + */ + async assertNoRangeCollision(input: { + type: 'WAGON' | 'CURRENCY' | 'CUSTOMS'; + currency?: string | null; + minWagonCount: number; + maxWagonCount: number; + excludeId?: string; + }): Promise { + if (input.minWagonCount > input.maxWagonCount) { + throw new BadRequestException( + 'Min wagon count cannot be greater than max wagon count', + ); + } + const siblings = await this.repository.findAll({ + where: { type: input.type }, + }); + const clash = siblings.find( + (s) => + s.id !== input.excludeId && + (input.type !== 'CURRENCY' || (s.currency ?? null) === (input.currency ?? null)) && + input.minWagonCount <= s.maxWagonCount && + input.maxWagonCount >= s.minWagonCount, + ); + if (clash) { + throw new BadRequestException( + `Wagon range ${input.minWagonCount}–${input.maxWagonCount} overlaps existing rule ` + + `"${clash.label}" (${clash.minWagonCount}–${clash.maxWagonCount}). ` + + 'Adjust the range so rules do not collide.', + ); + } + } + async remove(id: string): Promise { await this.findById(id); await this.repository.softDelete(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts new file mode 100644 index 000000000..bdc6a2e8f --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/services/priority-rule-change-requests.service.ts @@ -0,0 +1,226 @@ +import { + NotificationAudience, + NotificationType, +} from '@edr/types'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationInboxService } from '../../notification-inbox/notification-inbox.service'; +import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto'; +import { SubmitPriorityRuleChangeDto } from '../dto/priority-rule-change-request.dto'; +import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto'; +import { + PriorityRuleChangeRequest, + PriorityRuleChangeStatus, +} from '../entities/priority-rule-change-request.entity'; +import { PriorityConfigsService } from './priority-configs.service'; + +/** Backoffice rule-engine page — where both queue and rules live. */ +const RULES_LINK = '/dashboard/rules/priority-configs'; + +/** + * Approval workflow for priority-rule changes. Nobody mutates priority configs + * directly any more: a change is SUBMITTED here (validated up front so the + * requester gets immediate feedback on range collisions), the team is + * notified, and an approver later applies or rejects it. Applying re-runs the + * full validation — the winning state is whatever is true at approval time. + */ +@Injectable() +export class PriorityRuleChangeRequestsService { + private readonly logger = new Logger(PriorityRuleChangeRequestsService.name); + + constructor( + @InjectRepository(PriorityRuleChangeRequest) + private readonly repo: Repository, + private readonly configs: PriorityConfigsService, + private readonly inbox: NotificationInboxService, + ) {} + + async submit( + dto: SubmitPriorityRuleChangeDto, + userId?: string | null, + ): Promise { + const payload = await this.validateSubmission(dto); + + const request = await this.repo.save( + this.repo.create({ + action: dto.action, + priorityConfigId: dto.priorityConfigId ?? null, + payload, + status: 'PENDING', + requestedByUserId: userId ?? null, + }), + ); + + this.notifyTeam( + 'Priority rule change submitted', + `A ${dto.action.toLowerCase()} of a priority rule was submitted and awaits approval.`, + request, + ); + return request; + } + + async list(status?: PriorityRuleChangeStatus): Promise { + return this.repo.find({ + where: status ? { status } : {}, + relations: { priorityConfig: true }, + order: { createdAt: 'DESC' }, + }); + } + + async approve( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + + // Apply the change through the normal service so currency + range-collision + // validation runs against the CURRENT rules; a stale request that now + // collides fails here and stays PENDING for the approver to see the error. + if (request.action === 'CREATE') { + await this.configs.create(request.payload as unknown as CreatePriorityConfigDto); + } else if (request.action === 'UPDATE') { + await this.configs.update( + this.requireTarget(request), + request.payload as unknown as UpdatePriorityConfigDto, + ); + } else { + await this.configs.remove(this.requireTarget(request)); + } + + request.status = 'APPROVED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Priority rule change approved', + `The ${request.action.toLowerCase()} priority-rule change was approved and applied.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + async reject( + id: string, + userId?: string | null, + decisionNote?: string, + ): Promise { + const request = await this.findPending(id); + request.status = 'REJECTED'; + request.decidedByUserId = userId ?? null; + request.decidedAt = new Date(); + request.decisionNote = decisionNote ?? null; + const saved = await this.repo.save(request); + + this.notifyTeam( + 'Priority rule change rejected', + `The ${request.action.toLowerCase()} priority-rule change was rejected.` + + (decisionNote ? ` Note: ${decisionNote}` : ''), + saved, + ); + return saved; + } + + /** + * Validate a submission the way applying it would, so bad requests are + * refused at the door — most importantly the wagon-range collision rule. + * Returns the payload to persist. + */ + private async validateSubmission( + dto: SubmitPriorityRuleChangeDto, + ): Promise | null> { + if (dto.action === 'CREATE') { + if (!dto.create) { + throw new BadRequestException('CREATE requires the proposed rule in `create`'); + } + await this.configs.assertNoRangeCollision({ + type: dto.create.type, + currency: dto.create.currency ?? null, + minWagonCount: dto.create.minWagonCount, + maxWagonCount: dto.create.maxWagonCount, + }); + return { ...dto.create }; + } + + if (!dto.priorityConfigId) { + throw new BadRequestException(`${dto.action} requires priorityConfigId`); + } + const existing = await this.configs.findById(dto.priorityConfigId); + + if (dto.action === 'DELETE') return null; + + if (!dto.update || Object.keys(dto.update).length === 0) { + throw new BadRequestException('UPDATE requires the field changes in `update`'); + } + await this.configs.assertNoRangeCollision({ + type: dto.update.type ?? existing.type, + currency: + dto.update.currency !== undefined ? dto.update.currency : existing.currency, + minWagonCount: dto.update.minWagonCount ?? existing.minWagonCount, + maxWagonCount: dto.update.maxWagonCount ?? existing.maxWagonCount, + excludeId: existing.id, + }); + return { ...dto.update }; + } + + private async findPending(id: string): Promise { + const request = await this.repo.findOne({ + where: { id }, + relations: { priorityConfig: true }, + }); + if (!request) throw new NotFoundException(`Change request ${id} not found`); + if (request.status !== 'PENDING') { + throw new ConflictException( + `Change request is already ${request.status.toLowerCase()}`, + ); + } + return request; + } + + private requireTarget(request: PriorityRuleChangeRequest): string { + if (!request.priorityConfigId) { + throw new BadRequestException( + `${request.action} change request has no target rule`, + ); + } + return request.priorityConfigId; + } + + /** + * In-app notification to the whole backoffice team (submission AND decision + * both notify the team; the requester is staff, so they are included). + * Fire-and-forget — a notification failure never blocks the workflow. + */ + private notifyTeam( + title: string, + body: string, + request: PriorityRuleChangeRequest, + ): void { + void this.inbox + .notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title, + body, + link: RULES_LINK, + data: { priorityRuleChangeRequestId: request.id, action: request.action }, + }) + .catch((err) => + this.logger.warn( + `Priority-rule notification failed: ${(err as Error).message}`, + ), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts index ba0995679..e913af6b7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.spec.ts @@ -109,17 +109,26 @@ describe('computeImportWindowTimes — first-window open respects office hours', }); it('caps the close at departure', () => { - // Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT, - // past the 06 Jul 08:00 departure → clamped to departure. + // Round-the-clock desk (no desk-close cap in play). Opens now (05 Jul 12:00 + // EAT); a 24h duration would close 06 Jul 12:00 EAT, past the 06 Jul 08:00 + // departure → clamped to departure. const now = new Date('2026-07-05T09:00:00.000Z'); const { windowClosesAt } = computeImportWindowTimes( departure, - { ...bounded, windowDurationHours: 24 }, + { ...bounded, windowOpenHour: 8, windowCloseHour: 8, windowDurationHours: 24 }, now, ); expect(windowClosesAt.toISOString()).toBe(departure.toISOString()); }); + it('desk close hour cuts the window short (duration never outlives the desk)', () => { + // Opens now (05 Jul 12:00 EAT); the 15h duration would run to 03:00 next + // day, but the desk shuts 17:00 EAT (14:00 UTC) → the window closes with it. + const now = new Date('2026-07-05T09:00:00.000Z'); + const { windowClosesAt } = computeImportWindowTimes(departure, bounded, now); + expect(windowClosesAt.toISOString()).toBe('2026-07-05T14:00:00.000Z'); + }); + describe('overnight desk (open > close, wraps past midnight)', () => { // Desk open 08:00, closes 05:00 next morning — open across midnight. const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 }; @@ -189,13 +198,13 @@ describe('computeImportWindowTimes — overnight desk (open > close, wraps midni describe('batch-window board windows (config-driven booking cycles)', () => { // Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure, - // 3h long, reopen 90m later. + // 3h long, reopen gap (doc review + payment) 90m. const cfg: BoardWindowConfig = { importWindowLeadDays: 3, windowOpenHour: 8, windowCloseHour: 17, windowDurationHours: 3, - reopenDelayMinutes: 90, + reopenGapMinutes: 90, exportBookingLeadHours: 24, }; @@ -211,7 +220,7 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z'); }); - it('import: reopens reopenDelayMinutes after close while inside office hours', () => { + it('import: reopens after the doc-review + payment gap while inside office hours', () => { const departure = new Date('2026-06-08T11:00:00.000Z'); const windows = listConfigBookingWindows('IMPORT', departure, cfg); // cycle 1: 08:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT, same day @@ -250,6 +259,17 @@ describe('batch-window board windows (config-driven booking cycles)', () => { expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3); }); + it('import: desk close hour cuts a cycle short (duration past 17:00 clamps)', () => { + const longCfg: BoardWindowConfig = { ...cfg, windowDurationHours: 10 }; + const departure = new Date('2026-06-08T11:00:00.000Z'); + const windows = listConfigBookingWindows('IMPORT', departure, longCfg); + // Cycle 1 opens 08:00 EAT; 10h would close 18:00 — desk shuts 17:00 (14:00 UTC). + expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z'); + expect(windows[0].end.toISOString()).toBe('2026-06-05T14:00:00.000Z'); + // Reopen 90m after the clamped close lands past 17:00 → next morning 08:00 EAT. + expect(windows[1].start.toISOString()).toBe('2026-06-06T05: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); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts index 0fdacc572..a1dfe61f4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/batch-window.util.ts @@ -223,6 +223,49 @@ export function nextCycleOpensAt( return opensAt.getTime() < departure.getTime() ? opensAt : null; } +/** + * The desk-close instant of the office window containing `opensAt`; null for a + * round-the-clock desk. Same-day desk (open < close): closeHour on `opensAt`'s + * EAT day. Overnight desk (open > close): closeHour on the NEXT EAT day when + * `opensAt` sits in the evening half, closeHour the same day when it sits in the + * after-midnight half. + */ +export function officeCloseAfter(opensAt: Date, hours: OfficeHours): Date | null { + if (isRoundTheClock(hours)) return null; + const { hour, minute } = eatParts(opensAt); + const openMinutes = hour * 60 + minute; + if ( + hours.windowOpenHour > hours.windowCloseHour && + openMinutes >= hours.windowOpenHour * 60 + ) { + return eatDayToUtc(shiftEatDay(eatDay(opensAt), 1), hours.windowCloseHour); + } + return eatDayToUtc(eatDay(opensAt), hours.windowCloseHour); +} + +/** + * Cap a window close at the desk-close hour that follows its open: the office + * hours end a running window early rather than letting the duration outlive the + * desk (open 16:00, 3h duration, desk 8–17 → closes 17:00, not 19:00). A + * round-the-clock desk never caps; a desk-close at/before the open (degenerate + * config) is ignored so the window is never clamped to zero length here. + */ +export function clampCloseToOfficeHours( + opensAt: Date, + closesAt: Date, + hours: OfficeHours, +): Date { + const deskClose = officeCloseAfter(opensAt, hours); + if ( + deskClose != null && + deskClose.getTime() > opensAt.getTime() && + closesAt.getTime() > deskClose.getTime() + ) { + return deskClose; + } + return closesAt; +} + export interface InitialWindowTimes { windowOpensAt: Date; windowClosesAt: Date; @@ -243,7 +286,8 @@ export interface InitialWindowTimes { * • `now` before openHour that EAT day → opens at openHour that morning * • `now` at/after closeHour → desk shut; opens openHour next morning * - * `windowDurationHours` extends from that open, capped at departure. + * `windowDurationHours` extends from that open, capped at the desk close hour + * and at departure. */ export function computeImportWindowTimes( departure: Date, @@ -276,6 +320,10 @@ export function computeImportWindowTimes( } let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, { + windowOpenHour: cfg.windowOpenHour, + windowCloseHour: cfg.windowCloseHour, + }); if (closesAt.getTime() > departure.getTime()) { closesAt = departure; } @@ -381,10 +429,11 @@ export function listBatchWindowsForBookings( // --------------------------------------------------------------------------- // 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. +// schedule's frozen window rule (open/close hour, lead days, duration, reopen +// gap = doc review + payment) — NOT a fixed clock grid. Import shows each +// booking-window cycle (opens at windowOpenHour EAT, lasts windowDurationHours +// capped at the desk close, reopens after the gap until departure). Export shows +// the single FCFS lead window. // --------------------------------------------------------------------------- /** A board window carries an EAT calendar date in addition to the slot times. */ @@ -402,8 +451,11 @@ export interface BoardWindowConfig { /** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */ windowCloseHour: number; windowDurationHours: number; - /** Gap between a cycle's close and its reopen (doc review + payment minutes). */ - reopenDelayMinutes: number; + /** + * Gap between a cycle's close and its reopen — always doc review + payment + * minutes (the schedule's frozen snapshot, or the live sum for legacy rows). + */ + reopenGapMinutes: number; exportBookingLeadHours: number; } @@ -435,10 +487,11 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow { * 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. + * for `windowDurationHours` (cut short by the desk close hour); if the train isn't + * full it reopens `reopenGapMinutes` (doc review + payment) after each close, + * honouring office hours, 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, * with the open shifted to the next desk opening when it lands outside office hours * (same math as `computeExportWindowTimes`). @@ -464,7 +517,7 @@ export function listConfigBookingWindows( const durationMs = cfg.windowDurationHours * 3_600_000; // Post-close gap before the next cycle opens (doc review + payment), subject // to office hours below. - const reopenMs = cfg.reopenDelayMinutes * 60_000; + const reopenMs = cfg.reopenGapMinutes * 60_000; const officeHours: OfficeHours = { windowOpenHour: cfg.windowOpenHour, windowCloseHour: cfg.windowCloseHour, @@ -484,6 +537,7 @@ export function listConfigBookingWindows( for (let cycle = 0; cycle < maxCycles; cycle += 1) { if (opensAt.getTime() >= departure.getTime()) break; let closesAt = new Date(opensAt.getTime() + durationMs); + closesAt = clampCloseToOfficeHours(opensAt, closesAt, officeHours); if (closesAt.getTime() > departure.getTime()) closesAt = departure; windows.push(boardWindowFromInterval(opensAt, closesAt)); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index c8e254141..c37430121 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -37,6 +37,7 @@ describe('BookingBatchService — PAID reconcile', () => { }; let trainSchedulingService: { tryAutoWagonAllocation: jest.Mock; + previewPaidBookingWagonShortage: jest.Mock; getBookableSchedules: jest.Mock; getWindowConfig: jest.Mock; }; @@ -87,6 +88,8 @@ describe('BookingBatchService — PAID reconcile', () => { issues: [], violations: [], }), + // No shortage by default — paid bookings link as before. + previewPaidBookingWagonShortage: jest.fn().mockResolvedValue(null), getBookableSchedules: jest.fn().mockResolvedValue([]), getWindowConfig: jest.fn().mockResolvedValue({ importWindowLeadDays: 3, @@ -96,7 +99,6 @@ describe('BookingBatchService — PAID reconcile', () => { windowDurationHours: 3, docReviewMinutes: 30, paymentWindowMinutes: 60, - reopenDelayMinutes: 90, }), }; @@ -169,6 +171,38 @@ describe('BookingBatchService — PAID reconcile', () => { expect(trainSchedulingService.tryAutoWagonAllocation).toHaveBeenCalledTimes(2); }); + it('ensurePaidBookingAllocated holds a wagon-short booking out of the train', async () => { + trainSchedulingService.previewPaidBookingWagonShortage.mockResolvedValue({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 1, + wagonsAvailable: 0, + wagonsShort: 1, + }); + + await service.ensurePaidBookingAllocated(bookingId); + + // Not linked, no wagon run — held PAID + unlinked, flagged for manual placement. + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect(trainSchedulingService.tryAutoWagonAllocation).not.toHaveBeenCalled(); + expect(dataSource.getRepository().update).toHaveBeenCalledWith( + bookingId, + expect.objectContaining({ schedulingStatus: 'WAITING_FOR_WAGON' }), + ); + }); + + it('reconcilePaidUnlinked leaves WAITING_FOR_WAGON bookings held', async () => { + bookingsRepository.findPaidUnlinkedForSchedule.mockResolvedValue([ + { ...paidBooking, schedulingStatus: 'WAITING_FOR_WAGON' }, + ]); + + await service.reconcilePaidUnlinked(scheduleId); + + expect(trainScheduleBookingsRepository.createMany).not.toHaveBeenCalled(); + expect( + trainSchedulingService.previewPaidBookingWagonShortage, + ).not.toHaveBeenCalled(); + }); + it('processSchedule reconciles PAID-unlinked before wagon allocation', async () => { const fillSpy = jest.spyOn(service, 'fillSchedule').mockResolvedValue(0); const settleSpy = jest.spyOn(service, 'settleDueReservations').mockResolvedValue(undefined); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index e51d4ad23..82782446a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -497,6 +497,7 @@ export class BookingBatchService implements OnModuleInit { const linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); if (!linked) { + if (await this.holdIfWagonShort(booking.trainScheduleId, booking)) return; await this.allocate(booking.trainScheduleId, booking, "paid"); this.logger.log( `Linked PAID booking ${booking.reference ?? bookingId} to schedule ${booking.trainScheduleId}`, @@ -783,6 +784,9 @@ export class BookingBatchService implements OnModuleInit { const unlinked = await this.bookingsRepository.findPaidUnlinkedForSchedule(scheduleId); for (const booking of unlinked) { + // Held on purpose (paid, no wagon free) — the cron must not undo it. + if (booking.schedulingStatus === "WAITING_FOR_WAGON") continue; + if (await this.holdIfWagonShort(scheduleId, booking)) continue; await this.allocate(scheduleId, booking, "paid"); this.logger.log( `Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`, @@ -1050,7 +1054,11 @@ export class BookingBatchService implements OnModuleInit { s.ruleWindowDurationHours, liveCfg.windowDurationHours, ), - reopenDelayMinutes: num(s.ruleReopenDelayMinutes, liveCfg.reopenDelayMinutes), + // Frozen doc-review + payment sum; legacy rows fall back to the live sum. + reopenGapMinutes: num( + s.ruleReopenDelayMinutes, + liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes, + ), importWindowLeadDays: num( s.ruleImportWindowLeadDays, liveCfg.importWindowLeadDays, @@ -1894,7 +1902,9 @@ export class BookingBatchService implements OnModuleInit { done.add(booking.id); if (isPaid(booking)) { - await this.allocate(scheduleId, booking, "paid"); + if (!(await this.holdIfWagonShort(scheduleId, booking))) { + await this.allocate(scheduleId, booking, "paid"); + } anySettled = true; } else if (isExpired(booking)) { await this.expire(booking); @@ -1920,6 +1930,29 @@ export class BookingBatchService implements OnModuleInit { ); } + /** + * Conclude-time retry: promote whatever still fits from the route-day waiting + * list, opening fresh pay windows. Returns how many commercial units got + * reserved — corridor-wide, since the fill is day-level and may reserve onto a + * sibling train; the caller must check `hasLiveReservations` for its OWN + * schedule before deciding to stay in PAYMENT. + */ + async fillFromWaitingList(scheduleId: string): Promise { + return this.withScheduleLock(scheduleId, async () => { + let promoted = 0; + for (let round = 0; round < 10; round += 1) { + const reservedThisRound = await this.topUpFill(scheduleId); + if (reservedThisRound <= 0) break; + promoted += reservedThisRound; + await this.extendPaymentPhaseForTopUp(scheduleId); + } + if (promoted > 0) { + this.notifyBoardChanged(scheduleId, "conclude_waiting_list_fill"); + } + return promoted; + }); + } + /** * Settle, then keep promoting the waiting list until the train can take no more. * Returns whether anything settled. @@ -2050,7 +2083,9 @@ export class BookingBatchService implements OnModuleInit { await this.dataSource .getRepository(Booking) .update(bookingId, { paymentStatus: "PAID" }); - await this.allocate(booking.trainScheduleId, booking, "paid"); + if (!(await this.holdIfWagonShort(booking.trainScheduleId, booking))) { + await this.allocate(booking.trainScheduleId, booking, "paid"); + } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( booking.trainScheduleId, @@ -2112,7 +2147,12 @@ export class BookingBatchService implements OnModuleInit { await manager.getRepository(Booking).update(bookingId, { trainScheduleId: newScheduleId, status: restoredStatus, - schedulingStatus: "ELIGIBLE", + // A paid booking still hunting for a wagon keeps its flag through the + // move — it only clears when wagons are actually assigned. + schedulingStatus: + booking.schedulingStatus === "WAITING_FOR_WAGON" + ? "WAITING_FOR_WAGON" + : "ELIGIBLE", paymentDeadline: null, selectedForBatchAt: null, } as never); @@ -2254,6 +2294,50 @@ export class BookingBatchService implements OnModuleInit { } /** Allocate a booking to the schedule's train (creates the TrainScheduleBooking link). */ + /** + * Fleet preflight shared by every single-booking paid-allocation path: when + * no wagon of the booking's required type is free, hold it OUT of the train + * instead of linking — it stays PAID + unlinked in the (route, day) pool, + * flagged WAITING_FOR_WAGON, and staff place it on any same-day schedule from + * the workspace "Paid · unassigned" panel once a wagon frees up. Returns true + * when the booking was held. Consolidated pairs are exempt (the shared wagon + * is both-or-neither and settles atomically in settleReserved). + */ + private async holdIfWagonShort( + scheduleId: string, + booking: Booking, + ): Promise { + if (booking.consolidationPartnerId) return false; + const shortage = + await this.trainSchedulingService.previewPaidBookingWagonShortage( + scheduleId, + booking.id, + ); + if (!shortage) return false; + + await this.dataSource.getRepository(Booking).update(booking.id, { + status: "PAID", + paymentStatus: "PAID", + schedulingStatus: "WAITING_FOR_WAGON", + paymentDeadline: null, + selectedForBatchAt: null, + } as never); + // Payment landed — record it even though nothing boards yet. The wagon + // milestone stays pending until staff assign one. + void this.completeTrackingMilestones(booking.id, [ + "FREIGHT_PAYMENT_PENDING", + "FREIGHT_PAYMENT_SETTLED", + ]); + this.logger.warn( + `PAID booking ${booking.reference ?? booking.id} is WAITING FOR WAGON: ` + + `needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort}). ` + + `Held in the day pool for manual placement.`, + ); + this.notifyBoardChanged(scheduleId, "booking_waiting_wagon"); + return true; + } + private async allocate( scheduleId: string, booking: Booking, @@ -2358,7 +2442,9 @@ export class BookingBatchService implements OnModuleInit { `[BATCH] expire skipped for ${booking.reference} — payment already ` + `landed; allocating on schedule ${paidScheduleId} instead`, ); - await this.allocate(paidScheduleId, fresh, "paid"); + if (!(await this.holdIfWagonShort(paidScheduleId, fresh))) { + await this.allocate(paidScheduleId, fresh, "paid"); + } return; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts index 25d569171..7128bd705 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.config.ts @@ -19,8 +19,6 @@ export interface BookingWindowConfig { /** 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. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 72229cfd6..9393c520f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; + fillFromWaitingList: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -33,7 +34,6 @@ describe('BookingWindowService — window state machine', () => { windowDurationHours: 1, docReviewMinutes: 30, paymentWindowMinutes: 60, - reopenDelayMinutes: 0, }; const baseSchedule = (over: Partial): TrainSchedule => @@ -75,6 +75,8 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), + // No waiting booking fits by default, so conclude proceeds to reopen/DONE. + fillFromWaitingList: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -123,6 +125,8 @@ describe('BookingWindowService — window state machine', () => { }); it('DOC_REVIEW → PAYMENT expires un-accepted, then runs the batch', async () => { + // The batch reserved someone (live reservations exist) → real PAYMENT phase. + batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), @@ -140,6 +144,7 @@ describe('BookingWindowService — window state machine', () => { }); it('DOC_REVIEW → PAYMENT also fires when staff finished review early (docReviewCompletedAt)', async () => { + batch.hasLiveReservations.mockResolvedValue(true); const s = baseSchedule({ windowPhase: 'DOC_REVIEW', docReviewEndsAt: new Date('2026-07-01T05:00:00.000Z'), // far future @@ -150,6 +155,21 @@ describe('BookingWindowService — window state machine', () => { expect(s.windowPhase).toBe('PAYMENT'); }); + it('DOC_REVIEW → batch reserves nothing → skips the empty PAYMENT phase and reopens', async () => { + // Default hasLiveReservations=false: the batch reserved nobody. Waiting a + // full payment window with the desk shut would serve no one — the cycle + // concludes immediately (24h desk + far departure → straight to PRE_WINDOW). + const s = baseSchedule({ + windowPhase: 'DOC_REVIEW', + docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'), + }); + const advanced = await advanceImport(s, new Date('2026-07-01T01:30:01.000Z')); + expect(advanced).toBe(true); + expect(batch.processRouteDay).toHaveBeenCalledTimes(1); + expect(s.windowPhase).toBe('PRE_WINDOW'); + expect(s.windowOpensAt).not.toBeNull(); + }); + it('PAYMENT → conclude at paymentPhaseEndsAt settles due reservations', async () => { const s = baseSchedule({ windowPhase: 'PAYMENT', @@ -205,6 +225,22 @@ describe('BookingWindowService — window state machine', () => { expect(trainSchedulingService.finalizeSchedule).not.toHaveBeenCalled(); }); + it('conclude: waiting booking still fits → fresh pay window, back to PAYMENT, no reopen', async () => { + batch.isScheduleFull.mockResolvedValue(false); + batch.fillFromWaitingList.mockResolvedValue(2); + batch.hasLiveReservations.mockResolvedValue(true); + const s = baseSchedule({ + windowPhase: 'PAYMENT', + scheduledDepartureDate: new Date('2026-08-01T06:00:00.000Z'), + }); + const now = new Date('2026-07-01T02:30:05.000Z'); + await concludeCycle(s, now); + expect(batch.fillFromWaitingList).toHaveBeenCalledWith(scheduleId); + expect(s.windowPhase).toBe('PAYMENT'); + // Fresh pay window from `now`, not a reopen. + expect(s.paymentPhaseEndsAt).toEqual(new Date(now.getTime() + 60 * 60_000)); + }); + it('conclude: NOT full but NO cycle fits before departure → DONE', async () => { batch.isScheduleFull.mockResolvedValue(false); const s = baseSchedule({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index d3405c951..f728afe6a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -17,7 +17,12 @@ import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; -import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; +import { + clampCloseToOfficeHours, + eatDay, + nextCycleOpensAt, + type OfficeHours, +} from './batch-window.util'; import { type BookingWindowConfig } from './booking-window.config'; /** @@ -297,6 +302,17 @@ export class BookingWindowService implements OnModuleInit { // (or allocating government) — skipped automatically for everyone who fits // is handled inside the fill (all fit → all reserved → all notified). await this.bookingBatchService.processRouteDay(routeDay); + // Batch reserved nobody (empty pool, or it allocated without pay windows): + // a PAYMENT phase with nobody to pay is a dead hour with the window shut. + // Conclude straight away — full → DONE, otherwise reopen per office hours. + if (!(await this.bookingBatchService.hasLiveReservations(schedule.id))) { + this.logger.log( + `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch reserved nothing; ` + + `skipping the empty payment phase and concluding the cycle`, + ); + await this.concludeCycle(schedule, cfg, now); + return true; + } this.logger.log( `[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` + `until ${paymentPhaseEndsAt.toISOString()}`, @@ -353,7 +369,10 @@ export class BookingWindowService implements OnModuleInit { return false; } - /** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */ + /** + * After settle: full → finalize + DONE; waiting bookings still fit → fresh pay + * window, back to PAYMENT; otherwise reopen (office hours decide when) or DONE. + */ private async concludeCycle( schedule: TrainSchedule, cfg: BookingWindowConfig, @@ -386,6 +405,31 @@ export class BookingWindowService implements OnModuleInit { if (fresh) schedule.bookingWindowStatus = fresh.bookingWindowStatus; } + // The window reopens only once the waiting list is exhausted: a booking can + // still reach the pool mid-payment (late doc accept, consolidation partner), + // so retry the batch before reopening. Anything that fits gets a fresh pay + // window and the cycle stays in PAYMENT; check live reservations on THIS + // schedule because the day-level fill may have reserved onto a sibling. + // Waiting bookings that fit no train stay pooled and the window reopens. + const promoted = await this.bookingBatchService.fillFromWaitingList(schedule.id); + if ( + promoted > 0 && + (await this.bookingBatchService.hasLiveReservations(schedule.id)) + ) { + let paymentPhaseEndsAt = new Date( + now.getTime() + cfg.paymentWindowMinutes * 60_000, + ); + if (paymentPhaseEndsAt > schedule.scheduledDepartureDate) { + paymentPhaseEndsAt = schedule.scheduledDepartureDate; + } + await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt }); + this.logger.log( + `[WINDOW] ${schedule.id} conclude → waiting list still had bookings that ` + + `fit — back in PAYMENT until ${paymentPhaseEndsAt.toISOString()}, no reopen yet`, + ); + return; + } + // Doc review + payment have already run, so the desk is ready to reopen NOW — // office hours decide whether that is this afternoon or tomorrow morning. Past // the last cycle before departure, nextCycleOpensAt returns null and we finish. @@ -413,6 +457,9 @@ export class BookingWindowService implements OnModuleInit { let nextClosesAt = new Date( nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000, ); + // Office hours end a running window early: never let the duration outlive + // the desk close (open 16:00, 3h, desk 8–17 → closes 17:00). + nextClosesAt = clampCloseToOfficeHours(nextOpensAt, nextClosesAt, officeHours); if (nextClosesAt > schedule.scheduledDepartureDate) { nextClosesAt = schedule.scheduledDepartureDate; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts index a3af6f572..165a0db20 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/available-days-for-cargo-query.dto.ts @@ -41,6 +41,28 @@ export class AvailableDaysForCargoQueryDto { @IsString() cargoTypeCode?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Bulk cargo type id (preferred over code).' }) + @IsOptional() + @IsUUID() + cargoTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Container type ids as a JSON string array — enables the exact wagon-type compatibility gate (falls back to containerSize matching when absent).', + }) + @IsOptional() + @Transform(({ value }) => { + if (value == null || value === '') return undefined; + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return undefined; + } + }) + @IsArray() + containerTypeIds?: string[]; + @ApiPropertyOptional({ description: 'Total bulk weight in tons.' }) @IsOptional() @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts index 0e252240b..2948b874d 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-train-scheduling-global-rules.dto.ts @@ -95,11 +95,4 @@ export class UpdateTrainSchedulingGlobalRulesDto { @IsInt() @Min(1) paymentWindowMinutes?: number; - - @ApiPropertyOptional({ example: 90 }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - reopenDelayMinutes?: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts index ffa42fd7e..729063599 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/entities/train-scheduling-global-rules.entity.ts @@ -79,8 +79,4 @@ export class TrainSchedulingGlobalRules extends BaseEntity { @Column({ name: 'payment_window_minutes', type: 'int', default: 60 }) paymentWindowMinutes!: number; - - /** Delay after window close before the window reopens when the train is not yet full. */ - @Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 }) - reopenDelayMinutes!: number; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts index 54bf7a181..ff685d30c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts @@ -114,6 +114,35 @@ describe('fleet-plan.util', () => { expect(warnings.some((w) => w.includes('deferred'))).toBe(true); }); + it('names the booking and its per-type shortfall when the deferral carries a shortage', () => { + const warnings = summarizeFleetWarnings( + [], + [ + { + id: 'b1', + reference: 'BKG-1', + reason: 'No available NW6 wagon at the yard', + shortage: { + wagonTypeCodes: 'NW6', + wagonsNeeded: 2, + wagonsAvailable: 1, + wagonsShort: 1, + }, + }, + ], + ); + + expect( + warnings.some( + (w) => + w.includes('BKG-1') && + w.includes('2 × NW6') && + w.includes('only 1 available') && + w.includes('short 1'), + ), + ).toBe(true); + }); + it('counts wagons required per booking from container lines', () => { const booking = makeBooking('b1', { bookingContainers: [ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 6173da6b1..9cca4d213 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -17,10 +17,21 @@ export type FleetAvailabilityRow = { shortfall: number; }; +/** Per-booking wagon shortage: how many wagons of which type this booking still lacks. */ +export type BookingWagonShortage = { + /** Candidate wagon-type codes usable by the booking, joined ("NW6" or "NW6/CW3"). */ + wagonTypeCodes: string; + wagonsNeeded: number; + wagonsAvailable: number; + wagonsShort: number; +}; + export type DeferredBookingRow = { id: string; reference: string; reason: string; + /** Set when the deferral is a fleet-stock shortage (absent for config issues). */ + shortage?: BookingWagonShortage | null; }; export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { @@ -156,6 +167,16 @@ export function summarizeFleetWarnings( ); } + // Name the bookings the shortage actually hits, with their own per-type counts, + // so staff know WHAT is held out — not just that the pool is short overall. + for (const row of deferred) { + if (!row.shortage) continue; + warnings.push( + `Booking ${row.reference} held out: needs ${row.shortage.wagonsNeeded} × ${row.shortage.wagonTypeCodes}, ` + + `only ${row.shortage.wagonsAvailable} available (short ${row.shortage.wagonsShort})`, + ); + } + if (deferred.length) { warnings.push( `${deferred.length} booking(s) deferred to next train due to insufficient fleet wagons`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index c6e22589f..e4efaab9a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -234,9 +234,11 @@ export class TrainSchedulingController { originYardId: query.originYardId, destinationYardId: query.destinationYardId, freightType: query.freightType, + cargoTypeId: query.cargoTypeId, cargoTypeCode: query.cargoTypeCode, totalWeightTons: query.totalWeightTons, containers: query.containers, + containerTypeIds: query.containerTypeIds, }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 04583c777..8c2742b64 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -99,6 +99,7 @@ import { summarizeFleetWarnings, totalAssignedWeight, wagonsRequiredForBooking, + type BookingWagonShortage, type DeferredBookingRow, type FleetAvailabilityRow, } from './fleet-plan.util'; @@ -214,8 +215,6 @@ export function effectiveWindowConfig( : liveCfg.windowDurationHours, docReviewMinutes: liveCfg.docReviewMinutes, paymentWindowMinutes: liveCfg.paymentWindowMinutes, - reopenDelayMinutes: - schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes, }; } @@ -251,6 +250,8 @@ export interface CompositionUnassignedBookingRow { yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; + /** Structured fleet shortage when the block is missing wagons (null otherwise). */ + shortage: BookingWagonShortage | null; } export interface UnassignedBookingsResponse { @@ -435,7 +436,12 @@ export class TrainSchedulingService { .where('s.originStationId = :originStationId', { originStationId }) .andWhere('s.destinationStationId = :destinationStationId', { destinationStationId }) .andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart }) - .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }); + .andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart }) + // A cancelled train is not a sibling: cancel retires its window as DONE, + // and a newborn anchoring to it would inherit that dead window verbatim. + .andWhere('s.status != :cancelledStatus', { + cancelledStatus: TrainScheduleStatusEnum.Cancelled, + }); if (excludeScheduleId) { qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId }); } @@ -471,7 +477,12 @@ export class TrainSchedulingService { departure, ); if (siblings.length === 0) return null; - const withWindow = siblings.filter((s) => s.windowOpensAt != null); + // A DONE window is retired (the day's last cycle already ran) — anchoring + // to it would hand the newborn a dead window no tick ever advances. With no + // live or pending sibling left, fall back to fresh times (return null). + const withWindow = siblings.filter( + (s) => s.windowOpensAt != null && s.windowPhase !== 'DONE', + ); if (withWindow.length === 0) return null; // A group whose window is live (some sibling has moved past PRE_WINDOW but is @@ -603,7 +614,6 @@ export class TrainSchedulingService { 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; // The booking desk supports three shapes: a same-day range // (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an @@ -692,7 +702,6 @@ export class TrainSchedulingService { // override changes them, so the derived snapshot delay stays consistent. docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes, paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes, - reopenDelayMinutes: liveCfg.reopenDelayMinutes, }; // Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid @@ -919,7 +928,6 @@ export class TrainSchedulingService { windowDurationHours: num(row?.windowDurationHours, 3), docReviewMinutes: num(row?.docReviewMinutes, 30), paymentWindowMinutes: num(row?.paymentWindowMinutes, 60), - reopenDelayMinutes: num(row?.reopenDelayMinutes, 90), }; } @@ -1069,6 +1077,20 @@ export class TrainSchedulingService { // getSchedulableRoute already rejected DOMESTIC (intercity). const direction = this.resolveRouteDirection(route); + // Direction-matched fixed number from the built train's typed pair. + // Legacy locomotive-picked schedules keep dispatch-time pool assignment + // (assignTrainNumber is idempotent, so both paths compose). + const pairTrainNumber = builtTrain + ? (direction === 'IMPORT' + ? builtTrain.importTrainNumber + : builtTrain.exportTrainNumber) ?? null + : null; + if (builtTrain && !pairTrainNumber) { + scheduleWarnings.push( + `Train ${builtTrain.code} has no ${direction === 'IMPORT' ? 'import' : 'export'} train number; a pool number will be assigned at dispatch`, + ); + } + const trainSet = await this.buildEmptyTrainSet( manager, lockedLocomotives, @@ -1164,6 +1186,7 @@ export class TrainSchedulingService { scheduledDepartureDate: departure, status: TrainScheduleStatusEnum.Draft, direction, + trainNumber: pairTrainNumber ?? undefined, maxWagons, ...windowFields, }), @@ -1368,8 +1391,6 @@ export class TrainSchedulingService { await this.dataSource.transaction(async (manager) => { const trainSetId = schedule.trainSetId; - await this.releasePinnedWagonsForTrainSet(manager, trainSetId); - const deletedAllocationIds = await this.wagonBookingAllocationsRepository.deleteByTrainSetId(trainSetId, manager); @@ -1505,7 +1526,6 @@ export class TrainSchedulingService { (sb) => sb.bookingId !== bookingId, ); if (remainingBookings.length === 0) { - await this.releasePinnedWagonsForTrainSet(manager, schedule.trainSetId); await this.wagonBookingAllocationsRepository.deleteByTrainSetId( schedule.trainSetId, manager, @@ -1745,7 +1765,18 @@ export class TrainSchedulingService { throw new BadRequestException('Cannot pin wagons on a dispatched or cancelled schedule'); } - const slotIds = new Set((schedule.trainSet?.wagons ?? []).map((w) => w.id)); + const slots = schedule.trainSet?.wagons ?? []; + const slotIds = new Set(slots.map((w) => w.id)); + const slotById = new Map(slots.map((w) => [w.id, w])); + const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId); + // Occupancy is judged against THIS schedule's own slots only — a wagon + // pinned on another schedule (e.g. the same train's July 17 run) stays + // pinnable here. + const slotIdByPhysicalId = new Map( + slots + .filter((w) => w.physicalWagonId) + .map((w) => [w.physicalWagonId as string, w.id]), + ); await this.dataSource.transaction(async (manager) => { for (const assignment of dto.assignments) { @@ -1761,29 +1792,61 @@ export class TrainSchedulingService { if (!physicalWagon) { throw new NotFoundException(`Wagon ${assignment.physicalWagonId} not found`); } - if ( - physicalWagon.status !== WagonStatus.Available && - physicalWagon.currentTrainScheduleId !== scheduleId - ) { + const occupyingSlotId = slotIdByPhysicalId.get(assignment.physicalWagonId); + if (occupyingSlotId && occupyingSlotId !== assignment.trainSetWagonId) { + const occupyingSlot = slotById.get(occupyingSlotId); throw new ConflictException( - `Wagon ${physicalWagon.wagonNumber} is not available`, + `Wagon ${physicalWagon.wagonNumber} is already pinned to slot #${occupyingSlot?.sequenceNo ?? '?'} of this schedule`, ); } - if (physicalWagon.currentYardId !== schedule.originStationId) { - throw new ConflictException( - `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, - ); + if (builtTrainId) { + // Train-bound schedule: only the built train's own consist may be + // pinned — wherever the wagons currently sit, they travel with the + // train, so no yard/status gate applies. + if (physicalWagon.trainId !== builtTrainId) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is not part of this schedule's train`, + ); + } + } else { + if (physicalWagon.trainId) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is coupled to a built train and cannot be pinned as a loose wagon`, + ); + } + if (!this.isWagonPhysicallyUsable(physicalWagon)) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is not available (${physicalWagon.status})`, + ); + } + if ( + physicalWagon.currentTrainScheduleId && + physicalWagon.currentTrainScheduleId !== scheduleId + ) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is out on a dispatched train`, + ); + } + if (physicalWagon.currentYardId !== schedule.originStationId) { + throw new ConflictException( + `Wagon ${physicalWagon.wagonNumber} is at yard ${physicalWagon.currentYardId} but schedule originates from ${schedule.originStationId}`, + ); + } } + // The pin lives ONLY on the schedule's slot — the Wagon entity keeps + // its status untouched so other schedules can still use the wagon. await manager.getRepository(TrainSetWagon).update(assignment.trainSetWagonId, { physicalWagonId: assignment.physicalWagonId, status: 'RESERVED', }); - await manager.getRepository(Wagon).update(assignment.physicalWagonId, { - trainSetWagonId: assignment.trainSetWagonId, - currentTrainScheduleId: scheduleId, - status: WagonStatus.Assigned, - }); + for (const [physicalId, slotId] of slotIdByPhysicalId) { + if (slotId === assignment.trainSetWagonId) { + slotIdByPhysicalId.delete(physicalId); + break; + } + } + slotIdByPhysicalId.set(assignment.physicalWagonId, assignment.trainSetWagonId); } }); @@ -1837,6 +1900,22 @@ export class TrainSchedulingService { // 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); + // Same rule for wagons: many schedules may pin the same wagon, but it can + // only be OUT on one dispatched train at a time. + const pinnedPhysicalIds = (schedule.trainSet?.wagons ?? []) + .map((slot) => slot.physicalWagonId) + .filter((id): id is string => Boolean(id)); + if (pinnedPhysicalIds.length) { + const rolling = await this.dataSource.getRepository(Wagon).find({ + where: { id: In(pinnedPhysicalIds), currentTrainScheduleId: Not(IsNull()) }, + }); + const busy = rolling.filter((w) => w.currentTrainScheduleId !== scheduleId); + if (busy.length) { + throw new ConflictException( + `Cannot dispatch: wagon(s) ${busy.map((w) => w.wagonNumber).join(', ')} are still out on another dispatched train`, + ); + } + } const now = new Date(); await this.dataSource.transaction(async (manager) => { @@ -2674,7 +2753,23 @@ export class TrainSchedulingService { manager: EntityManager, schedule: TrainSchedule, ): Promise { - if (schedule.trainNumber) return schedule.trainNumber; + if (schedule.trainNumber) { + // Creation-assigned pair number: two live runs may never share a number, + // so block dispatch while another DISPATCHED schedule still carries it. + const clash = await manager + .getRepository(TrainSchedule) + .createQueryBuilder('s') + .where('s.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) + .andWhere('s.train_number = :trainNumber', { trainNumber: schedule.trainNumber }) + .andWhere('s.id != :id', { id: schedule.id }) + .getOne(); + if (clash) { + throw new ConflictException( + `Train number ${schedule.trainNumber} is already out on ${clash.reference ?? clash.id}; it must arrive before this train dispatches`, + ); + } + return schedule.trainNumber; + } // Count container vs bulk wagons from the planned allocations. let containerWagons = 0; @@ -2695,17 +2790,38 @@ export class TrainSchedulingService { // Lock the set of currently-active numbered schedules so two concurrent // dispatches serialize and can't both claim the same lowest-free number. + // DRAFT/SCHEDULED are included because pair numbers are now assigned at + // creation and must be invisible to pool picks. const activeNumbered = await manager .getRepository(TrainSchedule) .createQueryBuilder('schedule') .setLock('pessimistic_write') - .where('schedule.status = :status', { status: TrainScheduleStatusEnum.Dispatched }) + .where('schedule.status IN (:...statuses)', { + statuses: [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + TrainScheduleStatusEnum.Dispatched, + ], + }) .andWhere('schedule.train_number IS NOT NULL') .getMany(); - const usedNumbers = activeNumbered - .map((s) => s.trainNumber) - .filter((n): n is string => Boolean(n)); + // Every typed train pair is reserved for its train — the pool may never + // hand one out, even when that train has no active schedule right now. + const pairRows: { n: string }[] = await manager.query( + `SELECT import_train_number AS n FROM freight.trains + WHERE deleted_at IS NULL AND import_train_number IS NOT NULL + UNION + SELECT export_train_number FROM freight.trains + WHERE deleted_at IS NULL AND export_train_number IS NOT NULL`, + ); + + const usedNumbers = [ + ...activeNumbered + .map((s) => s.trainNumber) + .filter((n): n is string => Boolean(n)), + ...pairRows.map((row) => row.n), + ]; const number = pickLowestFreeNumber(pool.numbers, usedNumbers); if (!number) { @@ -3675,10 +3791,11 @@ export class TrainSchedulingService { originYardId: string, targetScheduleId?: string, ): Promise> { - const [wagons, wagonTypes, builtTrainId] = await Promise.all([ + const [wagons, wagonTypes, builtTrainId, pinnedToTargetIds] = await Promise.all([ this.dataSource.getRepository(Wagon).find(), this.dataSource.getRepository(WagonType).find(), this.builtTrainIdOfSchedule(targetScheduleId), + this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); @@ -3690,10 +3807,20 @@ export class TrainSchedulingService { if (builtTrainId) { if (wagon.trainId !== builtTrainId) continue; } else { - const pinnedOnTarget = targetScheduleId - ? wagon.currentTrainScheduleId === targetScheduleId - : false; - if (wagon.status !== WagonStatus.Available && !pinnedOnTarget) continue; + // Schedule-scoped availability: pins held by OTHER schedules never + // consume a wagon here — the same physical wagon may serve the July 17 + // and the July 20 run. A wagon is unusable only when it is coupled to a + // built train's consist, physically blocked, or out on a dispatched + // train right now. + const pinnedOnTarget = pinnedToTargetIds.has(wagon.id); + if (wagon.trainId) continue; + if (!this.isWagonPhysicallyUsable(wagon) && !pinnedOnTarget) continue; + if ( + wagon.currentTrainScheduleId && + wagon.currentTrainScheduleId !== targetScheduleId + ) { + continue; + } if (wagon.currentYardId !== originYardId) continue; } @@ -3752,22 +3879,59 @@ export class TrainSchedulingService { }; } - private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) { - const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } }); - const physicalIds = slots - .map((slot) => slot.physicalWagonId) - .filter((id): id is string => Boolean(id)); - if (!physicalIds.length) return; - const wagons = await manager.getRepository(Wagon).find({ where: { id: In(physicalIds) } }); - for (const wagon of wagons) { - await manager.getRepository(Wagon).update(wagon.id, { - // Built-train wagons stay coupled to their train (ASSIGNED); loose - // wagons return to the open AVAILABLE pool. - status: wagon.trainId ? WagonStatus.Assigned : WagonStatus.Available, - trainSetWagonId: null, - currentTrainScheduleId: null, - }); - } + /** + * A wagon in a blocked physical state can never be planned or pinned. + * ASSIGNED no longer blocks: it only means the wagon is coupled to a built + * train or stamped by a live run — schedule-level occupancy is tracked on + * the schedule's own TrainSetWagon slots, never on the Wagon entity. + */ + private isWagonPhysicallyUsable(wagon: Wagon): boolean { + return ( + wagon.status === WagonStatus.Available || wagon.status === WagonStatus.Assigned + ); + } + + /** + * Physical wagons already pinned to THIS schedule's slots. Availability is + * schedule-scoped: only a duplicate pin within the same schedule conflicts; + * pins held by other schedules of the same train are irrelevant. + */ + private async pinnedPhysicalWagonIdsForSchedule( + scheduleId: string | undefined, + manager?: EntityManager, + ): Promise> { + if (!scheduleId) return new Set(); + const runner = manager ?? this.dataSource; + const rows: { physical_wagon_id: string }[] = await runner.query( + `SELECT tsw.physical_wagon_id + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE ts.id = $1 + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + AND tsw.physical_wagon_id IS NOT NULL`, + [scheduleId], + ); + return new Set(rows.map((row) => row.physical_wagon_id)); + } + + /** + * Physical wagons pinned to any slot of a live (DRAFT/SCHEDULED/DISPATCHED) + * schedule. Used to guard consist trims — the Wagon entity itself carries no + * schedule-occupancy state anymore. + */ + private async wagonIdsPinnedToLiveSchedules(manager?: EntityManager): Promise> { + const runner = manager ?? this.dataSource; + const rows: { physical_wagon_id: string }[] = await runner.query( + `SELECT DISTINCT tsw.physical_wagon_id + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + AND tsw.physical_wagon_id IS NOT NULL`, + ); + return new Set(rows.map((row) => row.physical_wagon_id)); } private async autoPinWagonsForSchedule( @@ -3779,6 +3943,10 @@ export class TrainSchedulingService { const wagons = await manager.getRepository(Wagon).find(); const wagonTypes = await manager.getRepository(WagonType).find(); const builtTrainId = await this.builtTrainIdOfSchedule(scheduleId, manager); + const pinnedToScheduleIds = await this.pinnedPhysicalWagonIdsForSchedule( + scheduleId, + manager, + ); const typeCodeById = new Map(wagonTypes.map((wt) => [wt.id, wt.code])); const planSlots = [...slots] @@ -3797,6 +3965,7 @@ export class TrainSchedulingService { scheduleId, originYardId, builtTrainId, + pinnedToScheduleIds, ); if (unpinnable.length) { throw new BadRequestException({ @@ -3814,18 +3983,17 @@ export class TrainSchedulingService { originYardId, assignedPhysicalIds, builtTrainId, + pinnedToScheduleIds, ); if (!physical) continue; + // Pin lives ONLY on the schedule's own slot — the Wagon entity is never + // touched here, so the same physical wagon stays free for every other + // schedule (it gets stamped at dispatch, when it physically leaves). await manager.getRepository(TrainSetWagon).update(slot.trainSetWagonId!, { physicalWagonId: physical.id, status: 'RESERVED', }); - await manager.getRepository(Wagon).update(physical.id, { - trainSetWagonId: slot.trainSetWagonId, - currentTrainScheduleId: scheduleId, - status: WagonStatus.Assigned, - }); assignedPhysicalIds.add(physical.id); } } @@ -3838,9 +4006,10 @@ export class TrainSchedulingService { ): Promise { if (!wagonPlan.length) return []; - const [wagons, builtTrainId] = await Promise.all([ + const [wagons, builtTrainId, pinnedToScheduleIds] = await Promise.all([ this.dataSource.getRepository(Wagon).find(), this.builtTrainIdOfSchedule(targetScheduleId), + this.pinnedPhysicalWagonIdsForSchedule(targetScheduleId), ]); return this.findUnpinnableWagonSlots( wagonPlan.map((slot) => ({ @@ -3853,6 +4022,7 @@ export class TrainSchedulingService { targetScheduleId, originYardId, builtTrainId, + pinnedToScheduleIds, ); } @@ -3867,6 +4037,7 @@ export class TrainSchedulingService { scheduleId: string | undefined, originYardId: string, builtTrainId: string | null = null, + pinnedToScheduleIds: Set = new Set(), ): string[] { const violations: string[] = []; const assignedPhysicalIds = new Set(); @@ -3879,6 +4050,7 @@ export class TrainSchedulingService { originYardId, assignedPhysicalIds, builtTrainId, + pinnedToScheduleIds, ); if (!physical) { violations.push( @@ -3904,14 +4076,22 @@ export class TrainSchedulingService { originYardId: string, assignedPhysicalIds: Set, builtTrainId: string | null = null, + pinnedToScheduleIds: Set = new Set(), ): Wagon | undefined { const usable = (wagon: Wagon): boolean => { if (wagon.wagonTypeId !== slot.wagonTypeId) return false; if (assignedPhysicalIds.has(wagon.id)) return false; - const pinnedOnSchedule = scheduleId - ? wagon.currentTrainScheduleId === scheduleId - : false; - return wagon.status === WagonStatus.Available || pinnedOnSchedule; + // Loose pool never lends a wagon coupled to a built train's consist. + if (wagon.trainId) return false; + // Out on a dispatched train right now — physically gone. + if ( + wagon.currentTrainScheduleId && + wagon.currentTrainScheduleId !== scheduleId + ) { + return false; + } + const pinnedOnSchedule = pinnedToScheduleIds.has(wagon.id); + return this.isWagonPhysicallyUsable(wagon) || pinnedOnSchedule; }; // Train-bound schedule: ONLY the built train's own wagons may be pinned — // wherever they currently sit (they travel with the train), never a loose @@ -4614,6 +4794,8 @@ export class TrainSchedulingService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, currentYardId: train.currentYardId ?? null, currentYard: train.currentYard ? { @@ -4684,6 +4866,7 @@ export class TrainSchedulingService { .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) .map((slot) => slot.physicalWagonId as string), ); + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(); const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); @@ -4740,8 +4923,8 @@ export class TrainSchedulingService { wagons: wagons.map((wagon) => ({ ...mapWagon(wagon), loaded: loadedWagonIds.has(wagon.id), - // Free = not pinned to any run; only free wagons can be trimmed. - removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id), + // Free = not pinned to any live run's slot; only free wagons can be trimmed. + removable: !pinnedToLiveIds.has(wagon.id) && !loadedWagonIds.has(wagon.id), })), addableWagons: addableWagons.map(mapWagon), adjustments: adjustments.map((log) => ({ @@ -4820,13 +5003,14 @@ export class TrainSchedulingService { const consistById = new Map(consist.map((w) => [w.id, w])); // --- validate removals: must be coupled and free (no cargo, no pin) --- + const pinnedToLiveIds = await this.wagonIdsPinnedToLiveSchedules(manager); const removed: Wagon[] = []; for (const wagonId of removeWagonIds) { const wagon = consistById.get(wagonId); if (!wagon) { throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); } - if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) { + if (loadedWagonIds.has(wagon.id) || pinnedToLiveIds.has(wagon.id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, ); @@ -5307,10 +5491,10 @@ export class TrainSchedulingService { /** * Cargo-aware day pool: the EAT days a customer may pick for this cargo. A day * is selectable when ≥1 OPEN schedule on the route that day still has remaining - * train capacity (not fully allocated). Wagon availability is deliberately NOT - * checked here: whether a matching wagon currently sits in the right yard is an - * operational question staff resolve when they approve or reject the booking, - * not something the customer can act on while choosing a date. Same + * train capacity (not fully allocated) AND its wagon stock can physically carry + * the selected cargo/container type (wagon-TYPE gate). Quantity is deliberately + * NOT gated — a booking bigger than the free capacity is accepted and the batch + * engine offers a partial split later. No counts are exposed: same * `{ days: string[] }` shape as getAvailableDays — the customer picks a DAY, * not a train. */ @@ -5318,9 +5502,11 @@ export class TrainSchedulingService { originYardId?: string; destinationYardId?: string; freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; cargoTypeCode?: string | null; totalWeightTons?: number; containers?: Array<{ containerSize: string; quantity: number }>; + containerTypeIds?: string[]; }): Promise<{ days: string[] }> { const schedules = await this.getBookableScheduleEntities( input.originYardId, @@ -5328,17 +5514,233 @@ export class TrainSchedulingService { ); if (schedules.length === 0) return { days: [] }; + const withCapacity = schedules.filter( + (s) => Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0, + ); + const compatible = await this.filterCargoCompatibleSchedules(withCapacity, input); + const days = new Set(); - for (const s of schedules) { - const hasCapacity = - Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0; - if (!hasCapacity) continue; + for (const s of compatible) { if (s.scheduledDepartureDate) days.add(eatDay(new Date(s.scheduledDepartureDate))); } return { days: [...days].sort() }; } + /** + * Wagon-TYPE compatibility gate (customer booking): keep only the schedules + * whose wagon stock can physically carry the selected cargo — every container + * line (or the bulk cargo type) must map to at least one wagon type the + * schedule's stock actually has. Stock = the built train's own consist, or the + * origin yard's loose pool for schedules assembled from loose locomotives. + * QUANTITY is deliberately ignored: an over-sized booking is allowed and gets + * a partial split offer from the batch engine later. + */ + private async filterCargoCompatibleSchedules( + schedules: TrainSchedule[], + cargo: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + cargoTypeCode?: string | null; + containers?: Array<{ containerSize: string; quantity: number }>; + containerTypeIds?: string[]; + }, + ): Promise { + if (!schedules.length) return schedules; + const required = await this.requiredWagonTypeSets(cargo); + // No cargo identity supplied — nothing to gate on (legacy callers). + if (required === null) return schedules; + + const stockByScheduleId = await this.scheduleWagonTypeStock(schedules); + return schedules.filter((s) => { + const stock = stockByScheduleId.get(s.id) ?? new Set(); + return required.every((set) => { + for (const typeId of set) if (stock.has(typeId)) return true; + return false; + }); + }); + } + + /** + * One Set of allowed wagon-type ids per required cargo dimension: per + * container line's type (or per container size when only sizes are known), + * or a single set for the bulk cargo type. `null` = no cargo identity given, + * skip gating. An EMPTY set means "nothing can carry this" (no wagon types + * configured) — the gate then blocks every schedule, mirroring the hard + * config violation scheduling raises for the same state. + */ + private async requiredWagonTypeSets(cargo: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + cargoTypeCode?: string | null; + containers?: Array<{ containerSize: string; quantity: number }>; + containerTypeIds?: string[]; + }): Promise[] | null> { + if (cargo.freightType === 'CONTAINER') { + const typeIds = [...new Set((cargo.containerTypeIds ?? []).filter(Boolean))]; + if (typeIds.length) { + const rows: { container_type_id: string; wagon_type_id: string | null }[] = + await this.dataSource.query( + `SELECT ct.id AS container_type_id, wt.id AS wagon_type_id + FROM freight.container_types ct + LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id + LEFT JOIN freight.wagon_types wt + ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true + WHERE ct.id = ANY($1::uuid[]) AND ct.deleted_at IS NULL`, + [typeIds], + ); + const byType = new Map>(typeIds.map((id) => [id, new Set()])); + for (const row of rows) { + if (row.wagon_type_id) byType.get(row.container_type_id)?.add(row.wagon_type_id); + } + return [...byType.values()]; + } + // Legacy callers only know sizes ("20ft"/"40ft"): a size is carriable when + // ANY active container type of that size has a matching wagon type. + const sizes = [ + ...new Set( + (cargo.containers ?? []) + .map((line) => parseInt(String(line.containerSize), 10)) + .filter((n) => Number.isFinite(n) && n > 0), + ), + ]; + if (!sizes.length) return null; + const rows: { size_ft: number; wagon_type_id: string | null }[] = + await this.dataSource.query( + `SELECT ct.size_ft, wt.id AS wagon_type_id + FROM freight.container_types ct + LEFT JOIN freight.container_type_wagon_types ctwt ON ctwt.container_type_id = ct.id + LEFT JOIN freight.wagon_types wt + ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true + WHERE ct.size_ft = ANY($1::int[]) AND ct.deleted_at IS NULL + AND (ct.is_active IS DISTINCT FROM false)`, + [sizes], + ); + const bySize = new Map>(sizes.map((s) => [s, new Set()])); + for (const row of rows) { + if (row.wagon_type_id) bySize.get(Number(row.size_ft))?.add(row.wagon_type_id); + } + return [...bySize.values()]; + } + + if (!cargo.cargoTypeId && !cargo.cargoTypeCode) return null; + const rows: { wagon_type_id: string | null }[] = await this.dataSource.query( + `SELECT wt.id AS wagon_type_id + FROM freight.cargo_types c + LEFT JOIN freight.cargo_type_wagon_types ctwt ON ctwt.cargo_type_id = c.id + LEFT JOIN freight.wagon_types wt + ON wt.id = ctwt.wagon_type_id AND wt.deleted_at IS NULL AND wt.is_active = true + WHERE c.deleted_at IS NULL + AND (($1::uuid IS NOT NULL AND c.id = $1::uuid) OR ($1::uuid IS NULL AND c.code = $2))`, + [cargo.cargoTypeId ?? null, cargo.cargoTypeCode ?? null], + ); + const set = new Set(); + for (const row of rows) if (row.wagon_type_id) set.add(row.wagon_type_id); + return [set]; + } + + /** + * Wagon-type ids each schedule's stock can offer: the built train's own + * consist for train-bound schedules, the origin yard's loose usable pool + * otherwise. Batched — two queries for the whole schedule list. + */ + private async scheduleWagonTypeStock( + schedules: TrainSchedule[], + ): Promise>> { + const builtTrainIds = [ + ...new Set( + schedules + .map((s) => s.trainSet?.trainId) + .filter((id): id is string => Boolean(id)), + ), + ]; + const looseOriginYardIds = [ + ...new Set( + schedules + .filter((s) => !s.trainSet?.trainId) + .map((s) => s.originStationId) + .filter(Boolean), + ), + ]; + + const [trainRows, yardRows] = await Promise.all([ + builtTrainIds.length + ? (this.dataSource.query( + `SELECT train_id, wagon_type_id + FROM freight.wagons + WHERE train_id = ANY($1::uuid[]) AND deleted_at IS NULL + GROUP BY train_id, wagon_type_id`, + [builtTrainIds], + ) as Promise<{ train_id: string; wagon_type_id: string }[]>) + : Promise.resolve([] as { train_id: string; wagon_type_id: string }[]), + looseOriginYardIds.length + ? (this.dataSource.query( + `SELECT current_yard_id, wagon_type_id + FROM freight.wagons + WHERE train_id IS NULL AND deleted_at IS NULL + AND status IN ('AVAILABLE', 'ASSIGNED') + AND current_yard_id = ANY($1::uuid[]) + GROUP BY current_yard_id, wagon_type_id`, + [looseOriginYardIds], + ) as Promise<{ current_yard_id: string; wagon_type_id: string }[]>) + : Promise.resolve([] as { current_yard_id: string; wagon_type_id: string }[]), + ]); + + const byTrain = new Map>(); + for (const row of trainRows) { + const set = byTrain.get(row.train_id) ?? new Set(); + set.add(row.wagon_type_id); + byTrain.set(row.train_id, set); + } + const byYard = new Map>(); + for (const row of yardRows) { + const set = byYard.get(row.current_yard_id) ?? new Set(); + set.add(row.wagon_type_id); + byYard.set(row.current_yard_id, set); + } + + const result = new Map>(); + for (const s of schedules) { + const trainId = s.trainSet?.trainId; + result.set( + s.id, + trainId + ? byTrain.get(trainId) ?? new Set() + : byYard.get(s.originStationId) ?? new Set(), + ); + } + return result; + } + + /** + * Booking-time gate for a chosen day: does the route have an OPEN departure + * that day at all, and can any of that day's departures physically carry the + * cargo (wagon-TYPE only — quantity never blocks, oversized bookings get a + * partial split offer instead). + */ + async checkDayCargoCompatibility( + originYardId: string, + destinationYardId: string, + day: string, + cargo: { + freightType: 'CONTAINER' | 'BULK'; + cargoTypeId?: string | null; + containerTypeIds?: string[]; + }, + ): Promise<{ hasDeparture: boolean; hasCompatible: boolean }> { + const schedules = await this.getBookableScheduleEntities( + originYardId, + destinationYardId, + ); + const onDay = schedules.filter( + (s) => + s.scheduledDepartureDate && eatDay(new Date(s.scheduledDepartureDate)) === day, + ); + if (!onDay.length) return { hasDeparture: false, hasCompatible: false }; + const compatible = await this.filterCargoCompatibleSchedules(onDay, cargo); + return { hasDeparture: true, hasCompatible: compatible.length > 0 }; + } + /** * Ordered stop yards of a schedule's route: origin → milestones → destination, * de-duplicated. Falls back to the two-endpoint pseudo-route when the schedule @@ -5491,6 +5893,7 @@ export class TrainSchedulingService { status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, + maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, requiresLoadingConfirmation, loadingConfirmed, @@ -5512,7 +5915,7 @@ export class TrainSchedulingService { // Per-schedule booking-window rule snapshot — powers the "Booking window // settings" editor on the ops board (prefill + save one schedule's // override). docReview/payment are not snapshotted per schedule (only their - // sum, as reopenDelayMinutes), so the editor prefills them from live config. + // sum, as the frozen reopen gap), so the editor prefills them from live config. windowRule: { windowOpenHour: schedule.ruleWindowOpenHour ?? null, windowCloseHour: schedule.ruleWindowCloseHour ?? null, @@ -5520,7 +5923,6 @@ export class TrainSchedulingService { schedule.ruleWindowDurationHours != null ? Number(schedule.ruleWindowDurationHours) : null, - reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null, docReviewMinutes: windowCfg.docReviewMinutes, @@ -6148,6 +6550,7 @@ export class TrainSchedulingService { yardWagonsAvailable: number; canAssign: boolean; blockReason: string | null; + shortage: BookingWagonShortage | null; }> { if (!schedule.trainSet?.locomotive) { return { @@ -6156,6 +6559,7 @@ export class TrainSchedulingService { yardWagonsAvailable: 0, canAssign: false, blockReason: 'Schedule has no locomotive', + shortage: null, }; } @@ -6176,6 +6580,7 @@ export class TrainSchedulingService { yardWagonsAvailable: 0, canAssign: false, blockReason: 'No suitable wagon type found', + shortage: null, }; } @@ -6220,6 +6625,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: err instanceof Error ? err.message : 'Validation failed', + shortage: null, }; } @@ -6230,6 +6636,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: validation.violations[0] ?? 'Booking validation failed', + shortage: null, }; } @@ -6249,6 +6656,16 @@ export class TrainSchedulingService { deferred?.reason ?? yardShortfall ?? `Need ${wagonsRequired} ${requiredWagonTypeCode} wagon(s) at origin yard`, + shortage: + deferred?.shortage ?? + (yardShortfall + ? { + wagonTypeCodes: requiredWagonTypeCode, + wagonsNeeded: wagonsRequired, + wagonsAvailable: yardWagonsAvailable, + wagonsShort: Math.max(1, wagonsRequired - yardWagonsAvailable), + } + : null), }; } @@ -6267,6 +6684,7 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: false, blockReason: missing.issue, + shortage: null, }; } } @@ -6277,9 +6695,49 @@ export class TrainSchedulingService { yardWagonsAvailable, canAssign: true, blockReason: null, + shortage: null, }; } + /** + * Fleet-shortage preflight for a PAID booking targeting a schedule: the + * structured per-type shortage this booking would hit if placed on top of the + * schedule's current wagon assignments, or null when it fits (or is blocked + * by something other than missing wagons — those keep the legacy link-then- + * fix-manually path). + */ + async previewPaidBookingWagonShortage( + scheduleId: string, + bookingId: string, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule?.trainSet?.locomotive) return null; + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) return null; + + const [booking] = await this.bookingsRepository.findByIdsForScheduling([bookingId]); + if (!booking) return null; + + const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId); + const fleetCounts = await this.countFleetAvailability( + schedule.originStationId, + scheduleId, + ); + const fleetByTypeId = new Map( + fleetCounts.map((row) => [ + row.wagonTypeId, + { code: row.wagonTypeCode, available: row.available }, + ]), + ); + + const assignability = await this.previewUnassignedBookingAssignability( + schedule, + wagonAssignedIds, + booking, + fleetByTypeId, + ); + return assignability.shortage; + } + /** Paid (or government) bookings that may be loaded onto wagons — excludes expired / awaiting payment. */ private isReadyToLoadBooking(booking: { status: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts new file mode 100644 index 000000000..157666f55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -0,0 +1,133 @@ +import { Booking } from '../bookings/entities/booking.entity'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { planWagonsWithStock } from './wagon-plan-flex.util'; + +const nw6: WagonType = { + id: 'wt-nw6', + code: 'NW6', + name: 'Flat Wagon', + capacityTons: 70, + lengthMeters: 14, + supportedLoadTypes: ['CONTAINER'], + isActive: true, + supportsContainer: true, +} as WagonType; + +const cw3: WagonType = { + id: 'wt-cw3', + code: 'CW3', + name: 'Covered Wagon', + capacityTons: 60, + lengthMeters: 14, + supportedLoadTypes: ['BULK'], + isActive: true, + supportsContainer: false, +} as WagonType; + +const containerBooking = (id: string, quantity: number, wagonsRequired: number): Booking => + ({ + id, + reference: id, + freightType: 'CONTAINER', + cargoTotalWeightVgm: quantity * 25, + bookingContainers: [ + { + id: `${id}-line-0`, + containerTypeId: 'ct-1', + quantity, + wagonsRequired, + vgmPerUnitTons: 25, + }, + ], + }) as Booking; + +describe('planWagonsWithStock — shortage detail', () => { + it('defers with a structured per-type shortage when container stock runs out', () => { + const result = planWagonsWithStock({ + bookings: [containerBooking('BKG-1', 2, 1)], + allowed: { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[nw6.id, 0]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + expect(result.fitting).toHaveLength(0); + expect(result.deferred).toHaveLength(1); + const row = result.deferred[0]!; + expect(row.reference).toBe('BKG-1'); + expect(row.reason).toContain('No available NW6 wagon at the yard'); + expect(row.reason).toContain('short 1'); + expect(row.shortage).toEqual({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 1, + wagonsAvailable: 0, + wagonsShort: 1, + }); + }); + + it('counts the stock the deferred booking actually saw, not its rolled-back usage', () => { + // Two wagons needed (2 × 40ft), one in stock: booking rolls back entirely, + // the shortage reports 1 available / 1 short. + const fortyFooter = containerBooking('BKG-2', 2, 2); + fortyFooter.bookingContainers![0]!.containerType = { + code: '40GP', + sizeFt: 40, + wagonsPerUnit: 1, + } as never; + const result = planWagonsWithStock({ + bookings: [fortyFooter], + allowed: { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + expect(result.deferred).toHaveLength(1); + expect(result.deferred[0]?.shortage).toEqual({ + wagonTypeCodes: 'NW6', + wagonsNeeded: 2, + wagonsAvailable: 1, + wagonsShort: 1, + }); + // The rolled-back wagon is plannable again for later bookings. + expect(result.plan).toHaveLength(0); + }); + + it('leaves shortage unset for configuration problems', () => { + const bulkBooking = { + id: 'BKG-3', + reference: 'BKG-3', + freightType: 'BULK', + cargoTotalWeightVgm: 40, + cargoTypeId: 'cargo-1', + cargoType: { id: 'cargo-1', cargoTypeName: 'Fertilizer' }, + bookingContainers: [], + } as unknown as Booking; + + const result = planWagonsWithStock({ + bookings: [bulkBooking], + allowed: { + byContainerTypeId: new Map(), + byCargoTypeId: new Map(), // no wagon types configured → config issue + }, + stock: { + mode: 'YARD', + remainingByTypeId: new Map([[cw3.id, 5]]), + codesByTypeId: new Map([[cw3.id, cw3.code]]), + }, + }); + + expect(result.configIssues).toHaveLength(1); + expect(result.deferred[0]?.shortage).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index b4b8657ba..ad4c29aa1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -2,9 +2,14 @@ import { AllocationLoadType } from '@edr/types'; import { Booking } from '../bookings/entities/booking.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { sortBookingsForScheduling, type DeferredBookingRow } from './fleet-plan.util'; +import { + sortBookingsForScheduling, + type BookingWagonShortage, + type DeferredBookingRow, +} from './fleet-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON, + containerWagonsForLines, expandBookingContainerUnits, roundTons, tareTonsOf, @@ -55,7 +60,12 @@ type OpenSlot = { freeCapacityTons: number; }; -type PlacementProblem = { kind: 'config' | 'stock'; message: string }; +type PlacementProblem = { + kind: 'config' | 'stock'; + message: string; + /** Wagon types the failing placement could have used (stock problems only). */ + candidates?: WagonType[]; +}; const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanSlot => ({ sequenceNo: 0, // stamped at the end @@ -69,6 +79,38 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS slotLoadType: kind, }); +/** + * Booking-level shortage against the wagon types the failing placement could + * use: wagons the whole booking needs vs stock left for those types. Container + * counts are TEU-packed per booking; bulk divides by the largest candidate. + */ +const shortageFor = ( + booking: Booking, + candidates: WagonType[], + remaining: Map, +): BookingWagonShortage => { + const wagonsNeeded = + booking.freightType === 'BULK' + ? Math.max( + 1, + Math.ceil( + Number(booking.cargoTotalWeightVgm ?? 0) / + Math.max(1, ...candidates.map((wt) => Number(wt.capacityTons))), + ), + ) + : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); + const wagonsAvailable = candidates.reduce( + (sum, wt) => sum + (remaining.get(wt.id) ?? 0), + 0, + ); + return { + wagonTypeCodes: [...new Set(candidates.map((wt) => wt.code))].join('/'), + wagonsNeeded, + wagonsAvailable, + wagonsShort: Math.max(1, wagonsNeeded - wagonsAvailable), + }; +}; + const addAllocation = ( slot: WagonPlanSlot, bookingId: string, @@ -120,7 +162,9 @@ export function planWagonsWithStock(params: { cargoTypeId: string | null, ): OpenSlot | PlacementProblem => { const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); - if (!inStock.length) return { kind: 'stock', message: noStockMessage(candidates) }; + if (!inStock.length) { + return { kind: 'stock', message: noStockMessage(candidates), candidates }; + } // Bulk favors the largest wagon (fewest wagons for the tonnage); containers // favor the deepest stock so the consist drains evenly. Ties keep config order. const chosen = [...inStock].sort((a, b) => @@ -271,7 +315,21 @@ export function planWagonsWithStock(params: { }); if (problem.kind === 'config') configIssues.add(problem.message); - deferred.push({ id: booking.id, reference: booking.reference, reason: problem.message }); + // remaining is rolled back here, so the shortage counts the stock this + // booking actually saw — not what its own partial placement consumed. + const shortage = + problem.kind === 'stock' && problem.candidates?.length + ? shortageFor(booking, problem.candidates, remaining) + : null; + deferred.push({ + id: booking.id, + reference: booking.reference, + reason: shortage + ? `${problem.message} — needs ${shortage.wagonsNeeded} × ${shortage.wagonTypeCodes}, ` + + `${shortage.wagonsAvailable} available (short ${shortage.wagonsShort})` + : problem.message, + shortage, + }); } return { diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index c44be8786..5ba77fb10 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -5,14 +5,26 @@ import { IsOptional, IsString, IsUUID, + Matches, MaxLength, } from 'class-validator'; export class BuildTrainDto { - @ApiProperty({ example: '81001', description: 'Operator-assigned train code (unique)' }) + @ApiProperty({ example: '8001', description: 'EXPORT run number (odd, unique across trains)' }) @IsString() - @MaxLength(32) - code!: string; + @MaxLength(20) + @Matches(/^\d*[13579]$/, { + message: 'Export train number must be numeric and odd (e.g. 8001)', + }) + exportTrainNumber!: string; + + @ApiProperty({ example: '8002', description: 'IMPORT run number (even, unique across trains)' }) + @IsString() + @MaxLength(20) + @Matches(/^\d*[02468]$/, { + message: 'Import train number must be numeric and even (e.g. 8002)', + }) + importTrainNumber!: string; @ApiProperty({ format: 'uuid', description: 'Yard the train is built in' }) @IsUUID() diff --git a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts index 078b07a20..5b26c696a 100644 --- a/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts +++ b/apps/edr-freight-api/src/modules/trains/entities/train.entity.ts @@ -32,6 +32,14 @@ export class Train extends BaseEntity { @Column({ name: 'notes', type: 'text', nullable: true }) notes?: string | null; + /** Fixed IMPORT (even) run number typed at build time; unique via partial index. */ + @Column({ name: 'import_train_number', type: 'varchar', length: 20, nullable: true }) + importTrainNumber!: string | null; + + /** Fixed EXPORT (odd) run number typed at build time; unique via partial index. */ + @Column({ name: 'export_train_number', type: 'varchar', length: 20, nullable: true }) + exportTrainNumber!: string | null; + // --- new required fields --- @Column({ name: 'train_number', type: 'varchar', length: 20, unique: true, nullable: true }) trainNumber?: string; diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 7ed3e4c42..eec68fcfc 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -85,6 +85,16 @@ export class TrainBuilderController { return this.trainBuilderService.removeWagon(id, wagonId); } + @Post(':id/wagons/:wagonId/maintenance') + @FleetManage() + @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) + sendWagonToMaintenance( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + ) { + return this.trainBuilderService.sendWagonToMaintenance(id, wagonId); + } + @Post(':id/reorder-wagons') @FleetManage() @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 16ff29d38..3b5ad16cd 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -27,6 +27,15 @@ import { const round = (value: unknown) => Math.round((Number(value) || 0) * 100) / 100; +/** The one active (DRAFT/SCHEDULED/DISPATCHED) schedule surfaced per built train. */ +export interface ActiveScheduleRef { + id: string; + status: string; + reference: string | null; + direction: string | null; + trainNumber: string | null; +} + /** * Train Builder — assembles persistent fleet trains (code + 2+ locomotives + * ordered wagons, all in one yard) that scheduling can later reference as a @@ -50,10 +59,25 @@ export class TrainBuilderService { } const trainId = await this.dataSource.transaction(async (manager) => { - const code = dto.code.trim(); - const existing = await manager.getRepository(Train).findOne({ where: { code } }); - if (existing) { - throw new ConflictException(`Train code ${code} is already in use`); + const code = await this.generateTrainCode(manager); + + // Friendly 409 before the partial unique indexes (the race-proof backstop): + // the typed pair may not collide with any train's pair or legacy number. + const importTrainNumber = dto.importTrainNumber.trim(); + const exportTrainNumber = dto.exportTrainNumber.trim(); + const numberClash: { code: string }[] = await manager.query( + `SELECT code FROM freight.trains + WHERE deleted_at IS NULL + AND (import_train_number IN ($1, $2) + OR export_train_number IN ($1, $2) + OR train_number IN ($1, $2)) + LIMIT 1`, + [importTrainNumber, exportTrainNumber], + ); + if (numberClash.length) { + throw new ConflictException( + `Train number ${importTrainNumber}/${exportTrainNumber} is already used by train ${numberClash[0].code}`, + ); } const yard = await manager.getRepository(Yard).findOne({ where: { id: dto.currentYardId } }); @@ -76,6 +100,8 @@ export class TrainBuilderService { status: Freight.TrainStatus.Available, trainName: dto.trainName?.trim() || undefined, notes: dto.notes?.trim() || undefined, + importTrainNumber, + exportTrainNumber, }), ); @@ -117,12 +143,42 @@ export class TrainBuilderService { take, }); + const activeByTrain = await this.loadActiveScheduleByTrain(trains.map((t) => t.id)); + return { - items: trains.map((train) => this.mapSummary(train)), + items: trains.map((train) => this.mapSummary(train, activeByTrain.get(train.id) ?? null)), meta: buildPaginationMeta(total, page, pageSize), }; } + /** + * One ACTIVE schedule per train for the page (prefer the DISPATCHED run, + * else the earliest upcoming departure) — feeds the list's direction tint + * and in-use train number. + */ + private async loadActiveScheduleByTrain( + trainIds: string[], + ): Promise> { + if (!trainIds.length) return new Map(); + const rows: (ActiveScheduleRef & { trainId: string })[] = await this.dataSource.query( + `SELECT DISTINCT ON (tset.train_id) + tset.train_id AS "trainId", + ts.id, + ts.status, + ts.reference, + ts.direction, + ts.train_number AS "trainNumber" + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = ANY($1) + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY tset.train_id, (ts.status = 'DISPATCHED') DESC, ts.scheduled_departure_date ASC`, + [trainIds], + ); + return new Map(rows.map(({ trainId, ...schedule }) => [trainId, schedule])); + } + /** Full consist: yard, ordered locomotives + wagons, totals vs. haul limits. */ async getComposition(id: string) { const train = await this.dataSource.getRepository(Train).findOne({ @@ -139,17 +195,17 @@ export class TrainBuilderService { }); if (!train) throw new NotFoundException(`Train ${id} not found`); - const schedules: { id: string; status: string; reference: string | null }[] = - await this.dataSource.query( - `SELECT ts.id, ts.status, ts.reference - FROM freight.train_schedules ts - JOIN freight.train_sets tset ON tset.id = ts.train_set_id - WHERE tset.train_id = $1 - AND ts.deleted_at IS NULL - AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') - ORDER BY ts.scheduled_departure_date ASC`, - [id], - ); + const schedules: ActiveScheduleRef[] = await this.dataSource.query( + `SELECT ts.id, ts.status, ts.reference, ts.direction, + ts.train_number AS "trainNumber" + FROM freight.train_schedules ts + JOIN freight.train_sets tset ON tset.id = ts.train_set_id + WHERE tset.train_id = $1 + AND ts.deleted_at IS NULL + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + ORDER BY ts.scheduled_departure_date ASC`, + [id], + ); const locomotives = (train.locomotives ?? []) .filter((link) => link.locomotive) @@ -212,6 +268,8 @@ export class TrainBuilderService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, notes: train.notes ?? null, createdAt: train.createdAt, currentYard: train.currentYard @@ -341,7 +399,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (wagon.currentTrainScheduleId) { + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { throw new ConflictException( `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); @@ -356,6 +414,56 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Detach one wagon AND flag it for maintenance: it leaves the consist and + * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until + * it clears maintenance. The freed sequence gap is closed. + */ + async sendWagonToMaintenance(id: string, wagonId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); + } + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Maintenance, + }); + await this.resequenceWagons(manager, train.id); + }); + return this.getComposition(id); + } + + /** + * Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot), + * not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/ + * DISPATCHED) schedule has it pinned to one of its slots. + */ + private async isWagonPinnedToLiveSchedule( + manager: EntityManager, + wagonId: string, + ): Promise { + const rows: { exists: boolean }[] = await manager.query( + `SELECT TRUE AS exists + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = $1 + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL + LIMIT 1`, + [wagonId], + ); + return rows.length > 0; + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -407,7 +515,30 @@ export class TrainBuilderService { // ---------------------------------------------------------------- internals - private mapSummary(train: Train) { + /** + * System-assigned train code `TR-NNNNN`. Draws the next number from the + * highest existing `TR-` code and probes past any manual collision so the + * unique constraint never rejects the build. + */ + private async generateTrainCode(manager: EntityManager): Promise { + const [row]: { max_seq: string | null }[] = await manager.query( + `SELECT MAX(CAST(SUBSTRING(code FROM '^TR-([0-9]+)$') AS INTEGER)) AS max_seq + FROM freight.trains + WHERE code ~ '^TR-[0-9]+$'`, + ); + let seq = Number(row?.max_seq ?? 0) + 1; + for (let attempt = 0; attempt < 50; attempt += 1) { + const code = `TR-${String(seq).padStart(5, '0')}`; + const exists = await manager + .getRepository(Train) + .findOne({ where: { code }, withDeleted: true }); + if (!exists) return code; + seq += 1; + } + throw new ConflictException('Could not allocate a unique train code'); + } + + private mapSummary(train: Train, activeSchedule: ActiveScheduleRef | null) { const locomotives = [...(train.locomotives ?? [])] .sort((a, b) => a.sequenceNo - b.sequenceNo) .map((link) => link.locomotive) @@ -421,6 +552,9 @@ export class TrainBuilderService { code: train.code, trainName: train.trainName ?? null, status: train.status, + importTrainNumber: train.importTrainNumber ?? null, + exportTrainNumber: train.exportTrainNumber ?? null, + activeSchedule, createdAt: train.createdAt, currentYard: train.currentYard ? { id: train.currentYard.id, code: train.currentYard.code, label: train.currentYard.label } diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts new file mode 100644 index 000000000..28d0ec418 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-fulfill-transfer-requests.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMaxSize, ArrayMinSize, IsArray, IsUUID } from 'class-validator'; + +/** + * OCC bulk accept-and-execute: the subset of PENDING request ids to execute + * now. Requests not listed (or that cannot be executed) stay PENDING. + */ +export class BulkFulfillTransferRequestsDto { + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(200) + @IsUUID('all', { each: true }) + requestIds!: string[]; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index 4dd9f0f75..e747b69f2 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -1,10 +1,20 @@ -import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, +} from 'class-validator'; /** * A count-only wagon-transfer request. The requester picks source yard, wagon * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks - * those at fulfilment. + * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that + * type currently in the source yard, and a reason is mandatory. */ export class CreateTransferRequestDto { @IsUUID() @@ -21,6 +31,12 @@ export class CreateTransferRequestDto { @Max(1000) quantity!: number; + @ApiProperty({ description: 'Why the wagons are needed — shown on the OCC queue' }) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + reason!: string; + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index 40005de26..c81b6c365 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -59,4 +59,11 @@ export class WagonTransferRequest extends BaseEntity { @Column({ name: 'note', type: 'text', nullable: true }) note?: string | null; + + /** + * Why the wagons are needed — required for every new request and shown on + * the OCC queue. Nullable only for rows that predate the requirement. + */ + @Column({ name: 'reason', type: 'text', nullable: true }) + reason?: string | null; } diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts index 9f2d41416..b66fc3f9d 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon.entity.ts @@ -15,7 +15,7 @@ export const WAGON_STATUSES = [ WagonStatus.ImportReady, WagonStatus.ExportReady, WagonStatus.Maintenance, - WagonStatus.Retired, + WagonStatus.Detained, ] as const; export type WagonStatusType = (typeof WAGON_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index 12fdaf27c..d6925b71b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -19,6 +19,7 @@ import { WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; +import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @@ -51,6 +52,22 @@ export class WagonTransferRequestsController { return this.service.listRequests(status); } + // NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')` + // — Express matches in declaration order, so they would otherwise be captured + // by the `:id` param route (and rejected by ParseUUIDPipe). + @Post('bulk-fulfill') + @WagonTransferFulfill() + @ApiOperation({ + summary: + 'OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)', + }) + bulkFulfill( + @Body() dto: BulkFulfillTransferRequestsDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.bulkFulfill(dto.requestIds, user?.id); + } + // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express // matches in declaration order, so `/history` would otherwise be captured by // the `:id` param route (and rejected by ParseUUIDPipe). diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index bf69d767d..068d4dc6d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -1,4 +1,4 @@ -import { WagonTransferRequestStatus } from '@edr/types'; +import { WagonStatus, WagonTransferRequestStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -48,7 +48,12 @@ export class WagonTransferRequestsService { private readonly wagonsService: WagonsService, ) {} - /** Record a PENDING request. Count-only — no wagons are picked here. */ + /** + * Record a PENDING request. Count-only — no wagons are picked here, but the + * count is capped at the AVAILABLE wagons of that type currently sitting in + * the source yard: staff may only ask for wagons that are actually there to + * give. A reason is mandatory and is shown on the OCC queue. + */ async createRequest( dto: CreateTransferRequestDto, userId?: string | null, @@ -58,6 +63,14 @@ export class WagonTransferRequestsService { 'Source and destination yard must be different', ); } + const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId); + if (available < dto.quantity) { + throw new BadRequestException( + available === 0 + ? 'No available wagons of this type in the source yard' + : `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`, + ); + } const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, @@ -65,12 +78,24 @@ export class WagonTransferRequestsService { quantity: dto.quantity, status: WagonTransferRequestStatus.Pending, requestedByUserId: userId ?? null, + reason: dto.reason, note: dto.note ?? null, }); const saved = await this.requestRepo.save(request); return this.findById(saved.id); } + /** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */ + private countAvailable(yardId: string, wagonTypeId: string): Promise { + return this.wagonRepo.count({ + where: { + currentYardId: yardId, + wagonTypeId, + status: WagonStatus.Available, + }, + }); + } + /** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */ async listRequests( status?: WagonTransferRequestStatus, @@ -136,6 +161,14 @@ export class WagonTransferRequestsService { .join(', ')}`, ); } + const notAvailable = wagons.filter((w) => w.status !== WagonStatus.Available); + if (notAvailable.length) { + throw new BadRequestException( + `These wagons are not available: ${notAvailable + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows, // each stamped with this request's id so history can link them back). @@ -152,6 +185,71 @@ export class WagonTransferRequestsService { return this.findById(id); } + /** + * OCC accepts AND executes a subset of pending requests in one action. For + * each selected request the system auto-picks the required number of + * AVAILABLE wagons of the requested type from the source yard (lowest wagon + * number first) and runs the audited transfer. A request that cannot be + * executed — already decided, or not enough available wagons left after the + * ones processed before it — is SKIPPED and simply stays PENDING, visible to + * both teams; nothing is rolled back for the others. + */ + async bulkFulfill( + requestIds: string[], + userId?: string | null, + ): Promise<{ + fulfilled: WagonTransferRequest[]; + skipped: Array<{ id: string; reason: string }>; + }> { + const fulfilled: WagonTransferRequest[] = []; + const skipped: Array<{ id: string; reason: string }> = []; + + // Sequential on purpose: each executed transfer moves wagons out of the + // source yard, and the next request's auto-pick must see that new state. + for (const id of [...new Set(requestIds)]) { + const request = await this.requestRepo.findOne({ where: { id } }); + if (!request) { + skipped.push({ id, reason: 'Request not found' }); + continue; + } + if (request.status !== WagonTransferRequestStatus.Pending) { + skipped.push({ + id, + reason: `Already ${request.status.toLowerCase()}`, + }); + continue; + } + const wagons = await this.wagonRepo.find({ + where: { + currentYardId: request.fromYardId, + wagonTypeId: request.wagonTypeId, + status: WagonStatus.Available, + }, + order: { wagonNumber: 'ASC' }, + take: request.quantity, + }); + if (wagons.length < request.quantity) { + skipped.push({ + id, + reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`, + }); + continue; + } + await this.wagonsService.bulkTransfer( + { wagonIds: wagons.map((w) => w.id), toYardId: request.toYardId }, + userId, + { transferRequestId: request.id }, + ); + request.status = WagonTransferRequestStatus.Fulfilled; + request.fulfilledByUserId = userId ?? null; + request.fulfilledAt = new Date(); + await this.requestRepo.save(request); + fulfilled.push(await this.findById(id)); + } + + return { fulfilled, skipped }; + } + /** * Per-user transfer history: the requests a user filed OR fulfilled, plus the * individual wagons they physically moved (linked back to their request when diff --git a/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts b/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts new file mode 100644 index 000000000..f4d04a6a5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/current-actor.util.ts @@ -0,0 +1,13 @@ +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +/** + * Human-readable actor label for audit stamps (`performed_by` / `moved_by`). + * Prefers a display name, then username/email, so the activity log shows a + * person rather than a UUID. Returns undefined when there is no authenticated + * user (internal/cron calls), letting callers fall back to their prior value. + */ +export function actorLabel(user?: TCurrentUser | null): string | undefined { + if (!user) return undefined; + const name = user.name?.en?.trim() || user.name?.am?.trim(); + return name || user.username || user.email || user.id || undefined; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 41ca7facb..fd967cc8c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -144,7 +144,7 @@ export class SchedulingReadFacade { `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE deleted_at IS NULL - AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE') + AND UPPER(status) NOT IN ('DETAINED', 'MAINTENANCE') ORDER BY wagon_number ASC`, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts index 92aa36b85..3b61d7ff0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.service.ts @@ -330,6 +330,80 @@ export class WarehouseFeeService { return best; } + /** + * On-time dispatch rate: the share of items dispatched in the last N days that + * LEFT before their storage free-days expired — CEIL((dispatched−arrived)/day) + * <= freeDays, with freeDays resolved by the same rule matching the fee engine + * uses (bestRule over active STORAGE_FEE rules). onTimePct is null when there + * is nothing to measure (e.g. no dispatched items / no storage rules). + */ + async onTimeDispatchStats( + windowDays = 90, + ): Promise<{ sampleSize: number; onTimeCount: number; onTimePct: number | null }> { + const storageRules = ( + await this.feeRuleRepository.findAll({ where: { isActive: true } }) + ).filter((r) => r.ruleType === 'STORAGE_FEE'); + + // Batched attribute pull mirroring loadItem's scope joins (multi-row) — only + // the fields bestRule/matchScore reads, plus the two clock timestamps. + const rows: Array< + ItemAttributes & { arrivedAt: string; dispatchedAt: string } + > = await this.dataSource.query( + `SELECT inv.arrived_at AS "arrivedAt", + inv.dispatched_at AS "dispatchedAt", + inv.warehouse_id AS "warehouseId", + inv.yard_id AS "yardId", + inv.zone_id AS "zoneId", + w.facility_id AS "facilityId", + b.freight_type AS "freightType", + b.trade_direction AS "tradeDirection", + COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode", + COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode", + NULL AS "vehicleType" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.bookings b ON b.id = inv.booking_id + LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id + LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id + LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id + LEFT JOIN freight.containers ct ON ct.id = inv.container_id + LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id + LEFT JOIN LATERAL ( + SELECT bc.container_type_id + FROM freight.booking_container bc + WHERE bc.booking_id = inv.booking_id + AND bc.deleted_at IS NULL + AND bc.container_type_id IS NOT NULL + ORDER BY bc.created_at ASC + LIMIT 1 + ) booking_container_type ON true + LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id + WHERE inv.deleted_at IS NULL + AND inv.arrived_at IS NOT NULL + AND inv.dispatched_at IS NOT NULL + AND inv.dispatched_at > now() - ($1 || ' days')::interval`, + [windowDays], + ); + + let onTimeCount = 0; + for (const row of rows) { + const freeDays = this.bestRule(storageRules, row)?.freeDays ?? 0; + const elapsed = Math.max( + 0, + Math.ceil( + (new Date(row.dispatchedAt).getTime() - new Date(row.arrivedAt).getTime()) / MS_PER_DAY, + ), + ); + if (elapsed <= freeDays) onTimeCount += 1; + } + const sampleSize = rows.length; + return { + sampleSize, + onTimeCount, + onTimePct: sampleSize ? Math.round((onTimeCount / sampleSize) * 100) : null, + }; + } + private normalizeCurrency(currency?: string | null): 'ETB' | 'USD' { return currency === 'ETB' ? 'ETB' : 'USD'; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 8b14e6cdc..499e40f2e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -1,7 +1,10 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Request, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { actorLabel } from './current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; @@ -73,6 +76,35 @@ export class WarehouseInventoryController { return this.inventoryService.zoneOccupancy(yardId); } + @Get('throughput') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Received-vs-dispatched throughput time series (week/month/year)' }) + throughput(@Query('granularity') granularity?: string) { + const g = granularity === 'week' || granularity === 'year' ? granularity : 'month'; + return this.inventoryService.throughput(g); + } + + @Get('dwell-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Dwell time of in-warehouse items: average + aging buckets' }) + dwellStats() { + return this.inventoryService.dwellStats(); + } + + @Get('cycle-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Average stage cycle times over recently dispatched items' }) + cycleStats() { + return this.inventoryService.cycleStats(); + } + + @Get('gate-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'Gate/dock throughput: cleared today, turnaround, hourly clearances' }) + gateStats() { + return this.inventoryService.gateStats(); + } + @Post('auto-unload-arrived') @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) @@ -98,7 +130,8 @@ export class WarehouseInventoryController { @Post('receive-bulk') @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) - receiveBulk(@Body() dto: BulkReceiveDto) { + receiveBulk(@Body() dto: BulkReceiveDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; return this.inventoryService.bulkReceive(dto); } @@ -144,15 +177,16 @@ export class WarehouseInventoryController { loadItemsOntoTrain( @Param('scheduleId', ParseUUIDPipe) scheduleId: string, @Body() dto: { inventoryIds: string[]; performedBy?: string }, + @CurrentUser() user: TCurrentUser, ) { - return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], dto.performedBy); + return this.inventoryService.loadItemsOntoTrain(scheduleId, dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy); } @Post('bulk-dispatch-export') @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) - bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { - return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); + bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], actorLabel(user) ?? dto.performedBy); } @Post('bulk-mark-inspected') @@ -175,8 +209,12 @@ export class WarehouseInventoryController { @Post(':id/gate-clearance') @BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass) @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) - gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.gateClearance(id, performedBy); + gateClearance( + @Param('id', ParseUUIDPipe) id: string, + @Body('performedBy') performedBy: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.gateClearance(id, actorLabel(user) ?? performedBy); } @Get('import/arrive-queue') @@ -201,10 +239,10 @@ export class WarehouseInventoryController { warehouseId?: string; performedBy?: string; assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[]; - }) { + }, @CurrentUser() user: TCurrentUser) { return this.inventoryService.autoUnloadArrivedBookings( dto.scheduleId, - dto.performedBy, + actorLabel(user) ?? dto.performedBy, dto.warehouseId, dto.assignments, ); @@ -246,8 +284,8 @@ export class WarehouseInventoryController { @Post('export/auto-unload-at-djibouti') @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) - autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { - return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); + autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, actorLabel(user) ?? dto.performedBy); } @Get('import/pickup-ready-queue') @@ -274,14 +312,16 @@ export class WarehouseInventoryController { @Post('receive') @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) - receive(@Body() dto: ReceiveWarehouseInventoryDto) { + receive(@Body() dto: ReceiveWarehouseInventoryDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; return this.inventoryService.receive(dto); } @Post('reserve') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) - reserve(@Body() dto: ReserveInventoryDto) { + reserve(@Body() dto: ReserveInventoryDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; return this.inventoryService.reserve(dto); } @@ -316,15 +356,19 @@ export class WarehouseInventoryController { @Post(':id/store') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) - store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { - return this.inventoryService.store(id, dto.performedBy, dto); + store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto, @CurrentUser() user: TCurrentUser) { + return this.inventoryService.store(id, actorLabel(user) ?? dto.performedBy, dto); } @Post(':id/ready-for-loading') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) - readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.readyForLoading(id, performedBy); + readyForLoading( + @Param('id', ParseUUIDPipe) id: string, + @Body('performedBy') performedBy: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.readyForLoading(id, actorLabel(user) ?? performedBy); } @Post(':id/load') @@ -337,8 +381,12 @@ export class WarehouseInventoryController { @Post(':id/ready-for-pickup') @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) - readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.readyForPickup(id, performedBy); + readyForPickup( + @Param('id', ParseUUIDPipe) id: string, + @Body('performedBy') performedBy: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.readyForPickup(id, actorLabel(user) ?? performedBy); } @Post(':id/release') @@ -400,10 +448,11 @@ export class WarehouseInventoryController { @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: ApproveDeliveryDto, @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, ) { return this.inventoryService.approveDeliveryForBooking( bookingId, - req.user?.id ?? req.user?.sub, + user?.id ?? req.user?.id ?? req.user?.sub, dto.signerName, ); } @@ -465,14 +514,19 @@ export class WarehouseInventoryController { @Post(':id/deliver') @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) - deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { + deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; return this.inventoryService.deliver(id, dto); } @Patch(':id/dispatch') @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) - dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { - return this.inventoryService.dispatch(id, performedBy); + dispatch( + @Param('id', ParseUUIDPipe) id: string, + @Body('performedBy') performedBy: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.dispatch(id, actorLabel(user) ?? performedBy); } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 9a42bc895..6dd941d26 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -406,12 +406,14 @@ export class WarehouseInventoryService { */ async opsStats(): Promise<{ receivedToday: number; + receivedYesterday: number; pendingInspection: number; trucksOnSite: number; itemsAging: number; }> { const [row]: Array<{ receivedToday: number; + receivedYesterday: number; pendingInspection: number; trucksOnSite: number; itemsAging: number; @@ -419,6 +421,8 @@ export class WarehouseInventoryService { `SELECT (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE - 1) AS "receivedYesterday", (SELECT count(*)::int FROM freight.warehouse_inventory WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", (SELECT count(*)::int FROM freight.customer_truck_assignments @@ -430,12 +434,208 @@ export class WarehouseInventoryService { ); return { receivedToday: row?.receivedToday ?? 0, + receivedYesterday: row?.receivedYesterday ?? 0, pendingInspection: row?.pendingInspection ?? 0, trucksOnSite: row?.trucksOnSite ?? 0, itemsAging: row?.itemsAging ?? 0, }; } + /** In-warehouse statuses used by the dwell / aging metrics. */ + private readonly IN_WAREHOUSE_STATUSES = [ + 'RECEIVED', + 'UNLOADED', + 'STORED', + 'RESERVED', + 'READY_FOR_LOADING', + 'READY_FOR_PICKUP', + ]; + + /** + * Dwell time of items still in the warehouse: average days held plus a count + * per aging bucket (0–3 / 4–7 / 8–14 / 15+). Clock starts at arrival (falling + * back to created_at). Powers the dwell / aging histogram. + */ + async dwellStats(): Promise<{ + avgDwellDays: number; + inWarehouseCount: number; + buckets: Array<{ key: string; label: string; count: number }>; + }> { + const [row]: Array<{ + avgDwellDays: number | null; + inWarehouseCount: number; + b0: number; + b1: number; + b2: number; + b3: number; + }> = await this.dataSource.query( + `WITH held AS ( + SELECT EXTRACT(EPOCH FROM (now() - COALESCE(arrived_at, created_at))) / 86400.0 AS age_days + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND status = ANY($1) + ) + SELECT COALESCE(round(avg(age_days)::numeric, 1), 0)::float8 AS "avgDwellDays", + count(*)::int AS "inWarehouseCount", + count(*) FILTER (WHERE age_days < 4)::int AS b0, + count(*) FILTER (WHERE age_days >= 4 AND age_days < 8)::int AS b1, + count(*) FILTER (WHERE age_days >= 8 AND age_days < 15)::int AS b2, + count(*) FILTER (WHERE age_days >= 15)::int AS b3 + FROM held`, + [this.IN_WAREHOUSE_STATUSES], + ); + return { + avgDwellDays: row?.avgDwellDays ?? 0, + inWarehouseCount: row?.inWarehouseCount ?? 0, + buckets: [ + { key: '0-3', label: '0–3 days', count: row?.b0 ?? 0 }, + { key: '4-7', label: '4–7 days', count: row?.b1 ?? 0 }, + { key: '8-14', label: '8–14 days', count: row?.b2 ?? 0 }, + { key: '15+', label: '15+ days', count: row?.b3 ?? 0 }, + ], + }; + } + + /** + * Average stage cycle times over items dispatched in the last 90 days: + * arrived→ready, ready→loaded, loaded→dispatched, and the total + * arrived→dispatched (dock-to-dispatch). Days, to one decimal. + */ + async cycleStats(): Promise<{ + sampleSize: number; + avgDockToDispatchDays: number; + stages: Array<{ key: string; label: string; avgDays: number }>; + }> { + const gapDays = (from: string, to: string) => + `round((avg(EXTRACT(EPOCH FROM (${to} - ${from})) / 86400.0) FILTER (WHERE ${from} IS NOT NULL AND ${to} IS NOT NULL))::numeric, 1)::float8`; + const [row]: Array<{ + sampleSize: number; + total: number | null; + arrivedReady: number | null; + readyLoaded: number | null; + loadedDispatched: number | null; + }> = await this.dataSource.query( + `SELECT count(*)::int AS "sampleSize", + ${gapDays('arrived_at', 'dispatched_at')} AS "total", + ${gapDays('arrived_at', 'ready_for_loading_at')} AS "arrivedReady", + ${gapDays('ready_for_loading_at', 'loaded_at')} AS "readyLoaded", + ${gapDays('loaded_at', 'dispatched_at')} AS "loadedDispatched" + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND arrived_at IS NOT NULL + AND dispatched_at IS NOT NULL + AND dispatched_at > now() - interval '90 days'`, + ); + return { + sampleSize: row?.sampleSize ?? 0, + avgDockToDispatchDays: row?.total ?? 0, + stages: [ + { key: 'arrived-ready', label: 'Arrived → Ready', avgDays: row?.arrivedReady ?? 0 }, + { key: 'ready-loaded', label: 'Ready → Loaded', avgDays: row?.readyLoaded ?? 0 }, + { key: 'loaded-dispatched', label: 'Loaded → Dispatched', avgDays: row?.loadedDispatched ?? 0 }, + ], + }; + } + + /** + * Gate / dock throughput: items cleared through the gate today, the average + * arrival→gate-clearance turnaround (hours, last 30 days), and gate clearances + * bucketed per hour over the last 24 hours. Powers the gate throughput card. + */ + async gateStats(): Promise<{ + clearedToday: number; + avgTurnaroundHours: number | null; + byHour: Array<{ hour: string; count: number }>; + }> { + const [scalar]: Array<{ clearedToday: number; avgTurnaroundHours: number | null }> = + await this.dataSource.query( + `SELECT + count(*) FILTER (WHERE gate_cleared_at::date = CURRENT_DATE)::int AS "clearedToday", + round( + avg(EXTRACT(EPOCH FROM (gate_cleared_at - arrived_at)) / 3600.0) + FILTER ( + WHERE gate_cleared_at IS NOT NULL AND arrived_at IS NOT NULL + AND gate_cleared_at > now() - interval '30 days' + )::numeric, + 1 + )::float8 AS "avgTurnaroundHours" + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL`, + ); + const byHour: Array<{ hour: string; count: number }> = await this.dataSource.query( + `WITH hours AS ( + SELECT gs AS h + FROM generate_series( + date_trunc('hour', now()) - interval '23 hours', + date_trunc('hour', now()), + interval '1 hour' + ) gs + ) + SELECT to_char(hours.h, 'HH24:00') AS hour, + COALESCE(g.cnt, 0)::int AS count + FROM hours + LEFT JOIN ( + SELECT date_trunc('hour', gate_cleared_at) AS ph, count(*) AS cnt + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND gate_cleared_at IS NOT NULL + GROUP BY 1 + ) g ON g.ph = hours.h + ORDER BY hours.h`, + ); + return { + clearedToday: scalar?.clearedToday ?? 0, + avgTurnaroundHours: scalar?.avgTurnaroundHours ?? null, + byHour, + }; + } + + /** + * Received-vs-dispatched throughput as a server-side time series. Buckets by + * date_trunc over the last N periods (8 weeks / 12 months / 5 years) with a + * generate_series so empty periods still return a zero row — replaces the + * client-side approach that downloaded the whole inventory to bucket it. + */ + async throughput( + granularity: 'week' | 'month' | 'year' = 'month', + ): Promise> { + // Whitelist the unit — it is interpolated into date_trunc / interval literals. + const unit: 'week' | 'month' | 'year' = ['week', 'month', 'year'].includes(granularity) + ? granularity + : 'month'; + const back = unit === 'week' ? 7 : unit === 'month' ? 11 : 4; + + const rows: Array<{ periodStart: string; received: number; dispatched: number }> = + await this.dataSource.query( + `WITH periods AS ( + SELECT gs AS period_start + FROM generate_series( + date_trunc('${unit}', now()) - ($1 || ' ${unit}')::interval, + date_trunc('${unit}', now()), + '1 ${unit}'::interval + ) gs + ) + SELECT p.period_start AS "periodStart", + COALESCE(r.cnt, 0)::int AS received, + COALESCE(d.cnt, 0)::int AS dispatched + FROM periods p + LEFT JOIN ( + SELECT date_trunc('${unit}', arrived_at) AS ps, count(*) AS cnt + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND arrived_at IS NOT NULL + GROUP BY 1 + ) r ON r.ps = p.period_start + LEFT JOIN ( + SELECT date_trunc('${unit}', dispatched_at) AS ps, count(*) AS cnt + FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND dispatched_at IS NOT NULL + GROUP BY 1 + ) d ON d.ps = p.period_start + ORDER BY p.period_start`, + [back], + ); + return rows; + } + /** * Live occupancy per zone: rated capacity vs the weight/items currently held * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index 635ca1a10..4ad469ce0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -1,7 +1,10 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { actorLabel } from './current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; @@ -17,7 +20,8 @@ export class WarehouseInvoiceController { @Post('warehouse-inventory/:id/generate-fee-invoice') @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) - generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { + generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto, @CurrentUser() user: TCurrentUser) { + dto.performedBy = actorLabel(user) ?? dto.performedBy; return this.invoiceService.generateForInventory(id, dto); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index 7a5c53d38..3a60de4ea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -96,6 +96,13 @@ export class WarehouseRulesController { return this.feeService.accrualDashboard(billingCurrency); } + @Get('warehouse-fees/on-time-dispatch') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) + @ApiOperation({ summary: 'On-time dispatch rate — items that left before storage free-days expired' }) + onTimeDispatch() { + return this.feeService.onTimeDispatchStats(); + } + @Post('warehouse-fees/accrual/:inventoryId/acknowledge') @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index 0e9ab450e..a7187638e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -1,19 +1,13 @@ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { - Anchor, - Button, - Modal, - Select, - Stack, - Text, - Textarea, -} from "@mantine/core"; +import { Button, Modal, Stack, Text, Textarea } from "@mantine/core"; import { Check, + FilePen, FileSignature, MessageSquareWarning, + RefreshCw, ShieldCheck, Sparkles, XCircle, @@ -23,6 +17,7 @@ import type { Freight } from "@edr/types"; import { api } from "@/services/api"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; /** Dropdown-settings code holding the admin-configured contract validity days. */ @@ -54,8 +49,8 @@ export function ContractActionsToolbar({ const navigate = useNavigate(); const { status } = contract; - const [acceptOpen, setAcceptOpen] = useState(false); - const [validityDays, setValidityDays] = useState(null); + const [editorOpen, setEditorOpen] = useState(false); + const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); const [changesOpen, setChangesOpen] = useState(false); const [changesNote, setChangesNote] = useState(""); const [rejectOpen, setRejectOpen] = useState(false); @@ -76,12 +71,6 @@ export function ContractActionsToolbar({ .map((o) => ({ value: String(o.value), label: o.label })), [validitySetting], ); - // Default the selection to the first configured option when the dialog opens. - useEffect(() => { - if (acceptOpen && !validityDays && validityOptions.length > 0) { - setValidityDays(validityOptions[0].value); - } - }, [acceptOpen, validityDays, validityOptions]); if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) { return null; @@ -98,10 +87,16 @@ export function ContractActionsToolbar({ } const canAccept = status === "SUBMITTED"; - // Generation only becomes available once EVERY approval step is complete and - // the contract reaches APPROVED. While any step is still pending the contract - // stays in PENDING_APPROVAL, so this button does not appear after only the - // first (line-staff) approval — the director step must land first. + // While the contract is PENDING_APPROVAL and NO approver has acted yet, staff + // can edit this contract's articles and (re)generate its PDF. The first + // approval action locks the document. + const docLocked = + status !== "PENDING_APPROVAL" || + (contract.approvalSteps ?? []).some((s) => s.status !== "PENDING"); + const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked; + const documentGenerated = Boolean(contract.contractGeneratedAt); + // Legacy fallback: if a contract ever lands on APPROVED without a document + // (older flow), still offer a manual generate that moves it to CONTRACT_READY. const needsManualGenerate = status === "APPROVED" && !contract.contractGeneratedAt; // Signing now happens on the contract VIEW page (staff must open and read the @@ -131,7 +126,10 @@ export function ContractActionsToolbar({ fullWidth color="edr-green" leftSection={} - onClick={() => setAcceptOpen(true)} + onClick={() => { + setEditorMode("accept"); + setEditorOpen(true); + }} > Accept for approval @@ -156,6 +154,43 @@ export function ContractActionsToolbar({ )} + {canEditGenerate && ( + <> + + {documentGenerated + ? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval." + : "Review the contract document, edit its articles if needed, then generate it so approvers can review."} + + + + + )} + {needsManualGenerate && (