diff --git a/CLAUDE.md b/CLAUDE.md index d67e6c3d5..b90d3b1dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,7 @@ Monorepo for the Ethio Djibouti Railway (EDR) digital platform. Contains the Fre | ---------------------- | ---------------------------------------------------------------------------------- | | `@edr/types` | Shared TypeScript interfaces and enums | | `@edr/api-common` | Shared NestJS decorators, filters, interceptors, pipes, BaseEntity, BaseRepository | +| `@edr/iam-seed` | IAM baseline seeder for the apps sharing the `iam` schema (freight + passenger) | | `@edr/ui-common` | Shared React components and theme | | `@edr/eslint-config` | Shared ESLint configurations (base/nestjs/react) | | `@edr/tsconfig` | Shared TypeScript configurations | diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d80ce75c6..58fb60b18 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -42,8 +42,17 @@ JWT_REFRESH_TOKEN_EXPIRES=7d # IAM seed defaults (used by @tria-plc/iamapi-common on first boot) SUPER_ADMIN_EMAIL=superadmin@tria.com SUPER_ADMIN_PHONE= +# Super-admin password. Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_DEFAULT_PASSWORD= DEFAULT_PASSWORD=password@tria +# IAM baseline shared with edr-passenger-api (roles, IAM app + permissions, +# position types, organization types + default units, org/unit settings, super +# admin). Replaces the seeder that shipped inside @tria-plc/iamapi-common — see +# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only. +# Set to false to opt out. +SEED_IAM_BASELINE=true + # Freight org + staff (bookings / rule-engine IAM) SEED_EDR_ORG=true SEED_FREIGHT_STAFF=true diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 66909a037..0531d056b 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -35,12 +35,12 @@ "iam:migration:run": "pnpm run iam:typeorm:cli migration:run", "iam:migration:revert": "pnpm run iam:typeorm:cli migration:revert", "iam:migration:show": "pnpm run iam:typeorm:cli migration:show", - "iam:seed:run": "cross-env APP_MODULE_PATH=./dist/app.module dotenv -- node ./node_modules/@tria-plc/iamapi-common/dist/db/seed.cli.js", "migrate": "ts-node -r tsconfig-paths/register src/scripts/run-migrations.ts", "script": "ts-node -r tsconfig-paths/register src/scripts/main.ts" }, "dependencies": { "@edr/api-common": "workspace:*", + "@edr/iam-seed": "workspace:*", "@edr/payment-providers": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index aab9e0e4a..64df33f35 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,8 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -153,6 +154,18 @@ import { LoggerMiddleware } from "./logger.middleware"; applications: [EDR_FREIGHT_APPLICATION], permissions: EDR_FREIGHT_PERMISSIONS, }), + // Replaces the package's DataSeeder. Shared with edr-passenger-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr_freight", + unitKey: "edr_freight_app", + fallbackEmail: "superadmin@tria.com", + }, + }), BookingsModule, ContractsModule, SignaturesModule, @@ -231,7 +244,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -261,13 +274,22 @@ export class AppModule implements OnApplicationBootstrap { // Permissions foundation — keep enabled: // freightPermissionKeyMigration → renames legacy permission keys - // seeder (IAM DataSeeder) → seeds the IAM app, roles, permissions // edrOrgSeeder → seeds org/unit + the Permission catalog + // iamBaselineSeeder → @edr/iam-seed: IAM app, roles, permissions, + // position types, organization types + + // default units, org/unit settings and the + // super-admin account. Replaces the package's + // DataSeeder, and is shared with + // edr-passenger-api so one writer owns the + // `iam` schema. Runs after edrOrgSeeder + // because the super admin attaches to the + // edr_freight org/unit. + // Writes nothing unless SEED_IAM_BASELINE=true. // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); await this.edrOrgSeeder.run(); + await this.iamBaselineSeeder.run(); await this.freightPositionsSeeder.run(); // File upload settings — keep enabled. diff --git a/apps/edr-freight-api/src/common/grn.util.spec.ts b/apps/edr-freight-api/src/common/grn.util.spec.ts new file mode 100644 index 000000000..d95c934ca --- /dev/null +++ b/apps/edr-freight-api/src/common/grn.util.spec.ts @@ -0,0 +1,40 @@ +import { generateGrnNumber, grnOwnerSlug } from './grn.util'; + +/** + * The GRN is mapped to the goods owner for BOTH directions, so a note is + * identifiable by who owns the cargo. The reference slice stays the uniqueness + * anchor — one owner can have several bookings received the same day. + */ +const date = new Date('2026-07-27T09:15:00Z'); +const bookingId = '1a2b3c4d-1111-2222-3333-444455556666'; + +describe('GRN number', () => { + it('maps an import GRN to the owner', () => { + expect(generateGrnNumber('IMPORT', bookingId, date, 'Shafici Pharmaceutical')).toBe( + 'GRN-IMPORT-20260727-SHAFICIPHARM-1A2B3C4D', + ); + }); + + it('maps an export GRN to the owner the same way', () => { + expect(generateGrnNumber('EXPORT', bookingId, date, 'Tria Trading PLC')).toBe( + 'GRN-EXPORT-20260727-TRIATRADINGP-1A2B3C4D', + ); + }); + + it('keeps the owner-less format when there is no owner (manual walk-in)', () => { + expect(generateGrnNumber('WH', bookingId, date)).toBe('GRN-WH-20260727-1A2B3C4D'); + expect(generateGrnNumber('WH', bookingId, date, ' ')).toBe('GRN-WH-20260727-1A2B3C4D'); + }); + + it('stays unique per booking for one owner on one day', () => { + const a = generateGrnNumber('IMPORT', bookingId, date, 'Acme'); + const b = generateGrnNumber('IMPORT', 'ffffffff-9999-0000-0000-000000000000', date, 'Acme'); + expect(a).not.toBe(b); + }); + + it('strips punctuation and caps the owner segment', () => { + expect(grnOwnerSlug('Ethio-Djibouti Railway S.C.')).toBe('ETHIODJIBOUT'); + expect(grnOwnerSlug('a/b c')).toBe('ABC'); + expect(grnOwnerSlug(null)).toBeNull(); + }); +}); diff --git a/apps/edr-freight-api/src/common/grn.util.ts b/apps/edr-freight-api/src/common/grn.util.ts index 5cae30302..128e0496a 100644 --- a/apps/edr-freight-api/src/common/grn.util.ts +++ b/apps/edr-freight-api/src/common/grn.util.ts @@ -1,13 +1,41 @@ /** - * Goods Received Note number: `GRN---`. + * Goods Received Note number: `GRN----`. + * + * The GRN is mapped to the goods OWNER (the booking's customer / consignee) for + * both import and export, so a note is identifiable by who owns the cargo + * without opening it. The trailing reference slice stays as the uniqueness + * anchor — one owner can have several bookings received on the same day. + * Owner-less receipts (manual walk-ins with no booking) fall back to the + * original `GRN---` form. * * Shared so a GRN raised at a load/unload facility is indistinguishable from one * raised in a warehouse — the two live in different tables * (facility_handling_events vs warehouse_inventory), and a second generator would * eventually let their formats drift apart. */ -export function generateGrnNumber(direction: string, referenceId: string, date: Date): string { +export function generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, +): string { const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); - return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + const owner = grnOwnerSlug(ownerName); + const base = `GRN-${direction.toUpperCase()}-${stamp}`; + return owner ? `${base}-${owner}-${suffix}` : `${base}-${suffix}`; +} + +/** + * Owner name → GRN-safe token: letters/digits only, upper-cased, capped so a + * long company name can't run away with the number. Null when there is nothing + * usable, which drops the segment rather than emitting an empty `--`. + */ +export function grnOwnerSlug(ownerName?: string | null): string | null { + const slug = (ownerName ?? '') + .normalize('NFKD') + .replace(/[^a-zA-Z0-9]+/g, '') + .toUpperCase() + .slice(0, 12); + return slug || null; } diff --git a/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts new file mode 100644 index 000000000..710ad12ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-AddPerTruckDetentionWindow.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Per-truck detention clocks. Detention was timed once per last-mile leg + * (last_mile.arrived_at / delivered_at), so every truck on a multi-truck + * delivery shared one window and was billed identical days — wrong the moment + * two trucks arrive or return at different times. + * + * Deliberately NEW columns rather than reusing the existing per-truck + * arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out + * events stamped by release(), whereas detention runs from arrival at the + * DESTINATION until the truck is released/returned. + * + * Both nullable — a truck without its own window falls back to the leg-level + * timestamps, so legacy legs keep billing exactly as before. + */ +export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface { + name = 'AddPerTruckDetentionWindow2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz, + ADD COLUMN IF NOT EXISTS returned_at timestamptz; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.last_mile_vehicle_assignments + DROP COLUMN IF EXISTS returned_at, + DROP COLUMN IF EXISTS destination_arrived_at; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts new file mode 100644 index 000000000..d89e0c04b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2990000000000-IndodeYardsAndCargoRouting.ts @@ -0,0 +1,131 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Indode's real 11-yard layout, plus the plumbing to auto-route a booking to + * the right yard by cargo type (and, for container yards, trade direction): + * + * - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only + * meaningful for CONTAINER_YARD, where import and export stacks are + * physically separate (Yard 5 vs Yard 6). Everything else takes cargo + * either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means + * "not a customer cargo yard" — Yards 10/11 (service/equipment) are + * CONTAINER_YARD structurally but must never be offered for ordinary + * import/export cargo, so the frontend match requires an EXACT IMPORT/ + * EXPORT direction hit for container freight rather than treating BOTH as + * a wildcard. + * - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors + * the existing `cargo_type_wagon_types` join table). Empty = open to any + * cargo type of the yard's structural type (additive, never restrictive + * by default), so this cannot break a yard nobody has configured yet. + * + * Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here + * so Yards 1 and 9 have a real mapping ready for when they reopen. + */ +export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface { + name = "IndodeYardsAndCargoRouting2990000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.warehouse_yards + ADD COLUMN IF NOT EXISTS direction varchar(10) + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types ( + yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE, + cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE, + PRIMARY KEY (yard_id, cargo_type_id) + ) + `); + + // New cargo types Indode's yard list names but the catalog didn't have yet. + await queryRunner.query(` + INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active) + VALUES + ('FERTILIZER', 'Fertilizer', 'PER_TON', true), + ('COFFEE', 'Coffee', 'PER_TON', true), + ('TEA', 'Tea', 'PER_TON', true) + ON CONFLICT (code) DO NOTHING + `); + + // The 11 real yards at Indode Open Warehouse (code 'IOW'). + await queryRunner.query(` + INSERT INTO freight.warehouse_yards + (warehouse_id, name, code, type, direction, status, is_active) + SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE' + FROM freight.warehouses w + CROSS JOIN (VALUES + ('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'), + ('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'), + ('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'), + ('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'), + ('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'), + ('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'), + ('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'), + ('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'), + ('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE') + ) AS y(code, name, type, direction, status) + WHERE w.code = 'IOW' + ON CONFLICT (warehouse_id, code) DO NOTHING + `); + + // One default zone per new yard, matching its yard's type — every existing + // yard (CY-1, CY-A) already follows this one-zone-per-yard shape. + await queryRunner.query(` + INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active) + SELECT y.id, y.name || ' Zone 1', 'Z1', + CASE y.type + WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE' + WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE' + WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE' + WHEN 'BULK_YARD' THEN 'BULK_ZONE' + ELSE 'GENERAL_CARGO_ZONE' + END, + y.status, y.status = 'ACTIVE' + FROM freight.warehouse_yards y + JOIN freight.warehouses w ON w.id = y.warehouse_id + WHERE w.code = 'IOW' AND y.code LIKE 'Y%' + ON CONFLICT (yard_id, code) DO NOTHING + `); + + // Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally + // left with no rows — direction alone decides those, per the entity comment. + await queryRunner.query(` + INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id) + SELECT y.id, ct.id + FROM freight.warehouses w + JOIN freight.warehouse_yards y ON y.warehouse_id = w.id + JOIN (VALUES + ('Y1', 'FERTILIZER'), + ('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'), + ('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'), + ('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'), + ('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'), + ('Y7', 'PERISHABLE'), + ('Y9', 'COFFEE'), ('Y9', 'TEA') + ) AS m(yard_code, cargo_code) ON m.yard_code = y.code + JOIN freight.cargo_types ct ON ct.code = m.cargo_code + WHERE w.code = 'IOW' + ON CONFLICT (yard_id, cargo_type_id) DO NOTHING + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE FROM freight.warehouse_zones z + USING freight.warehouse_yards y, freight.warehouses w + WHERE z.yard_id = y.id AND y.warehouse_id = w.id + AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + await queryRunner.query(` + DELETE FROM freight.warehouse_yards y + USING freight.warehouses w + WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%' + `); + // Cargo types and the join table are left in place — other data may have + // started referencing them since; dropping columns/tables is not reversible + // once real rows exist, and leaving them is harmless. + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts new file mode 100644 index 000000000..9f103e15b --- /dev/null +++ b/apps/edr-freight-api/src/modules/last-mile/dto/set-detention-times.dto.ts @@ -0,0 +1,29 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsDateString, IsOptional, IsUUID, ValidateNested } from 'class-validator'; + +/** + * One truck's detention window. Each truck reaches the destination and is + * released at its own time, so detention days differ between trucks on the + * same delivery. Null clears the value (falls back to the leg-level pair). + */ +export class TruckDetentionTimeInput { + @IsUUID() + vehicleId!: string; + + /** Detention clock start — this truck reached the destination. */ + @IsOptional() + @IsDateString() + destinationArrivedAt?: string | null; + + /** Detention clock end — this truck was released/returned. Omit = still out. */ + @IsOptional() + @IsDateString() + returnedAt?: string | null; +} + +export class SetDetentionTimesDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => TruckDetentionTimeInput) + trucks!: TruckDetentionTimeInput[]; +} diff --git a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts index e57c16b8a..f2b4e2467 100644 --- a/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts +++ b/apps/edr-freight-api/src/modules/last-mile/entities/last-mile-vehicle-assignment.entity.ts @@ -51,6 +51,21 @@ export class LastMileVehicleAssignment extends BaseEntity { @Column({ name: 'departed_at', type: 'timestamptz', nullable: true }) departedAt?: Date | null; + /** + * Detention clock START for THIS truck: reached the delivery destination. + * Distinct from `arrivedAt` (warehouse gate-in). Null falls back to the + * leg-level `last_mile.arrived_at`. + */ + @Column({ name: 'destination_arrived_at', type: 'timestamptz', nullable: true }) + destinationArrivedAt?: Date | null; + + /** + * Detention clock END for THIS truck: released / returned by the customer. + * Null (with no leg-level `delivered_at`) means still out — detention accrues. + */ + @Column({ name: 'returned_at', type: 'timestamptz', nullable: true }) + returnedAt?: Date | null; + /** Weighed gross on exit, in TONNES (not kg — see the migration note). */ @Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true }) grossWeightTons?: number | null; diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts index 29e857e2f..e8fa57cdc 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.controller.ts @@ -23,6 +23,7 @@ import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLastMileDto } from './dto/create-last-mile.dto'; import { UpdateLastMileDto } from './dto/update-last-mile.dto'; import { SetVehiclesDto } from './dto/set-vehicles.dto'; +import { SetDetentionTimesDto } from './dto/set-detention-times.dto'; import { SetDistancesDto } from './dto/set-distances.dto'; import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto'; import { LastMileStatus } from './entities/last-mile.entity'; @@ -131,6 +132,18 @@ export class LastMileController { return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment); } + @Post(':id/detention-times') + @BookingStaff(FREIGHT_PERMS.lastMile.update) + @ApiOperation({ + summary: 'Set each truck\'s own detention window (arrived at destination / returned)', + }) + async setDetentionTimes( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetDetentionTimesDto, + ) { + return this.lastMileService.setDetentionTimes(id, dto.trucks); + } + @Post(':id/proof-of-delivery') @BookingStaff(FREIGHT_PERMS.lastMile.update) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 601e03aec..d7c45bb3c 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -869,6 +869,46 @@ export class LastMileService { * sum and drives billing; `remainingPayment` (total km × rate) is recomputed * client-side. Does NOT generate an invoice — that's a separate explicit step. */ + /** + * Per-truck detention windows. Each truck reaches the destination and is + * released at its own time, so every truck gets its own clock (and therefore + * its own chargeable days). Locked once the detention invoice exists. + */ + async setDetentionTimes( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ): Promise { + await this.findById(id); + + const invoices = await this.billing.findBySourceIds('last_mile', [id]); + if (invoices.length) { + throw new BadRequestException( + 'Detention times cannot be changed after the invoice is generated', + ); + } + + for (const t of trucks) { + const start = t.destinationArrivedAt ? new Date(t.destinationArrivedAt) : null; + const end = t.returnedAt ? new Date(t.returnedAt) : null; + if (start && end && end.getTime() < start.getTime()) { + throw new BadRequestException( + 'A truck cannot be returned before it arrived — check the detention times', + ); + } + await this.dataSource.manager.update( + LastMileVehicleAssignment, + { lastMileId: id, vehicleId: t.vehicleId }, + { destinationArrivedAt: start, returnedAt: end }, + ); + } + + return this.findById(id); + } + async setDistances( id: string, distances: Array<{ vehicleId: string; distanceKm: number }>, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts index 20ac4859a..4fe20dbf5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/facility-handling.service.ts @@ -48,10 +48,12 @@ export class FacilityHandlingService { if (!facility?.hasFacility) return null; const occurredAt = input.occurredAt ?? new Date(); + // Mapped to the goods owner, same as every warehouse-raised GRN. const grnNumber = generateGrnNumber( booking.tradeDirection ?? 'DOMESTIC', booking.id, occurredAt, + booking.company?.name ?? null, ); // Link the storage record when this facility keeps cargo — that link is diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts index 56b9d0810..39a90ef8e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-warehouse-yard.dto.ts @@ -1,7 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { IsArray, IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; -import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity'; +import { + WAREHOUSE_YARD_DIRECTIONS, + WAREHOUSE_YARD_TYPES, + WarehouseYardDirection, + WarehouseYardType, +} from '../entities/warehouse-yard.entity'; export class CreateWarehouseYardDto { @ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' }) @@ -46,4 +51,22 @@ export class CreateWarehouseYardDto { @IsNumber() @Min(0) maxVolume?: number; + + @ApiPropertyOptional({ + enum: WAREHOUSE_YARD_DIRECTIONS, + description: 'Trade direction this yard serves. Only meaningful for CONTAINER_YARD — omit/BOTH for everything else.', + }) + @IsOptional() + @IsEnum(WAREHOUSE_YARD_DIRECTIONS) + direction?: WarehouseYardDirection; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: 'Cargo types this yard accepts. Empty/omitted = open to any cargo type of this yard\'s structural type.', + }) + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + cargoTypeIds?: string[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts index 6e3c93292..5169f486a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-yard.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm'; +import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { Warehouse } from './warehouse.entity'; import { WarehouseZone } from './warehouse-zone.entity'; @@ -16,6 +17,15 @@ export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number]; export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const; export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number]; +/** + * Which trade direction this yard serves. Only meaningful for CONTAINER_YARD, + * where import and export stacks are physically separate areas (e.g. Indode's + * Yard 5 for import vs Yard 6 for export) — every other yard type takes cargo + * either way, so BOTH/null is the right default there. + */ +export const WAREHOUSE_YARD_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; +export type WarehouseYardDirection = (typeof WAREHOUSE_YARD_DIRECTIONS)[number]; + @Entity({ schema: 'freight', name: 'warehouse_yards' }) @Index(['warehouseId']) @Index(['type']) @@ -64,6 +74,25 @@ export class WarehouseYard extends BaseEntity { @Column({ name: 'is_active', type: 'boolean', default: true }) isActive!: boolean; + /** Null = BOTH (no direction restriction). Only relevant for CONTAINER_YARD. */ + @Column({ name: 'direction', type: 'varchar', length: 10, nullable: true }) + direction?: WarehouseYardDirection | null; + + /** + * Cargo types this yard accepts — e.g. Yard 3 (Ro-Ro) takes Automobile/Truck, + * Yard 9 (Coffee and Tea) takes only those two. Empty/no rows = open to any + * cargo type of the yard's structural `type` (the pre-existing behavior), + * so this is additive and never blocks a yard that hasn't been configured. + */ + @ManyToMany(() => CargoType) + @JoinTable({ + name: 'warehouse_yard_cargo_types', + schema: 'freight', + joinColumn: { name: 'yard_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'cargo_type_id', referencedColumnName: 'id' }, + }) + cargoTypes?: CargoType[]; + @OneToMany(() => WarehouseZone, (zone) => zone.yard) zones?: WarehouseZone[]; } diff --git a/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts new file mode 100644 index 000000000..471b82965 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/per-truck-detention.spec.ts @@ -0,0 +1,82 @@ +import { WarehouseFeeService } from './warehouse-fee.service'; + +/** + * Detention is per truck: two trucks on the same delivery with different + * windows must produce different chargeable days and amounts (the old + * leg-level clock billed them identically). + */ +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +const svc = Object.create(WarehouseFeeService.prototype) as { + computeTruckDetention: ( + rule: Record | null, + row: { arrivedAt: Date | string | null; deliveredAt: Date | string | null; truckCount: number }, + now: Date, + billingCurrency: string, + ) => Promise<{ chargeableDays: number; billableUnits: number; amount: number; endIsOpen: boolean }>; + normalizeCurrency: (c?: string | null) => string; + convertAmount: (a: number, from: string, to: string) => Promise; + calculateTieredAmount: unknown; +}; +svc.normalizeCurrency = (c) => (c ? String(c).toUpperCase() : 'USD'); +svc.convertAmount = async (a) => a; + +// 3h grace, 50/truck/day, no tiers. +const rule = { freeHours: 3, ratePerDay: 50, currency: 'USD', id: 'r1', name: 'Detention', tiers: [] }; +const now = new Date('2026-07-25T12:00:00Z'); + +describe('per-truck detention', () => { + it('bills each truck on its own window', async () => { + // Truck A: out ~1 day past grace. Truck B: out ~3 days past grace. + const a = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + const b = await svc.computeTruckDetention( + rule, + { + arrivedAt: new Date(now.getTime() - 3 * DAY - 4 * HOUR), + deliveredAt: now, + truckCount: 1, + }, + now, + 'USD', + ); + + expect(a.chargeableDays).toBe(2); + expect(b.chargeableDays).toBe(4); + expect(a.amount).toBe(100); + expect(b.amount).toBe(200); + // The whole point: same delivery, different bills. + expect(a.amount).not.toBe(b.amount); + }); + + it('charges nothing inside the grace window', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * HOUR), deliveredAt: now, truckCount: 1 }, + now, + 'USD', + ); + expect(out.chargeableDays).toBe(0); + expect(out.amount).toBe(0); + }); + + it('keeps accruing against now when a truck has not returned', async () => { + const out = await svc.computeTruckDetention( + rule, + { arrivedAt: new Date(now.getTime() - 2 * DAY), deliveredAt: null, truckCount: 1 }, + now, + 'USD', + ); + expect(out.endIsOpen).toBe(true); + expect(out.chargeableDays).toBe(2); + }); +}); 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 fd967cc8c..59f4b5478 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 @@ -17,6 +17,8 @@ export interface ImportTrainRow { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — lets the frontend restrict the unload warehouse picker to the warehouse actually at this station, instead of listing every warehouse. */ + destinationStationId: string | null; arrivalTime: string | null; totalBookings: number; totalContainers: number; @@ -38,6 +40,8 @@ export interface ImportTrainItemRow { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -205,6 +209,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", oy.country AS "originCountry", dy.country AS "destinationCountry", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", @@ -280,6 +285,7 @@ export class SchedulingReadFacade { WHERE c.booking_id = b.id AND c.deleted_at IS NULL ORDER BY c.container_number LIMIT 1) AS "containerNumber", COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType", + cgt.code AS "cargoTypeCode", b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", @@ -376,6 +382,7 @@ export class SchedulingReadFacade { ts.train_number AS "trainNumber", oy.code AS "origin", dy.code AS "destination", + dy.id AS "destinationStationId", dy.label AS "destinationName", oy.country AS "originCountry", dy.country AS "destinationCountry", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts index aa4d5e7b1..e23c6d1f8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-fee.bulk-quantity.spec.ts @@ -37,6 +37,10 @@ describe('WarehouseFeeService bulk quantity billing', () => { inventoryWeight: 25, bookingContainerCount: 0, cargoUnitOfMeasure: null, + // Double handling now bills only when staff answered Yes after unloading; + // these quantity-basis cases assume that answer (the gate itself is covered + // in double-handling-gate.spec.ts). + doubleHandling: true, facilityId: null, warehouseId: null, yardId: null, 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 0d8c6549f..aa30b31e7 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 @@ -92,10 +92,20 @@ export interface FeePreview { ratePerDay: number; amount: number; }>; - /** Truck detention: per-vehicle-type breakdown — each truck-type group billed by its own matching rule. */ + /** + * Truck detention: one row PER TRUCK — each truck has its own detention + * window (it arrives and is released at its own time) and its own matching + * rule by truck type, so days and amount differ between trucks. + */ groups?: Array<{ + assignmentId: string | null; + vehicleId: string | null; + plateNumber: string | null; vehicleType: string | null; truckCount: number; + startDate: string | null; + endDate: string | null; + endIsOpen: boolean; chargeableDays: number; ratePerDay: number; amount: number; @@ -827,25 +837,48 @@ export class WarehouseFeeService { }; } - // Group the leg's vehicles by CANONICAL truck type so each type is billed - // by its own matching rule (rates differ by truck type). The FK to - // truck_types is the source of truth — renaming a type's label no longer - // silently unmatches its rule; the normalized legacy vehicle_type code is - // only a fallback for vehicles without the FK (LEFT JOIN keeps them billed - // instead of dropping them). Falls back to one untyped group. - const groupRows: Array<{ vehicleType: string | null; truckCount: number | string }> = - await this.dataSource.query( - `SELECT COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", - count(*)::int AS "truckCount" - FROM freight.last_mile_vehicle_assignments va - JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL - LEFT JOIN freight.truck_types t - ON t.id = v.truck_type_id AND t.deleted_at IS NULL - WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL - GROUP BY 1`, - [lastMileId], - ); - const groups = groupRows.length ? groupRows : [{ vehicleType: null, truckCount: 1 }]; + // One row PER TRUCK: each truck has its own detention window (it reaches the + // destination and is released at its own time) and resolves its own rule by + // CANONICAL truck type — the truck_types FK is the source of truth, with the + // normalized legacy vehicle_type code as fallback so FK-less vehicles keep + // billing. Per-truck timestamps fall back to the leg-level pair for legacy + // legs recorded before per-truck tracking. + const truckRows: Array<{ + assignmentId: string; + vehicleId: string; + plateNumber: string | null; + vehicleType: string | null; + startAt: Date | string | null; + endAt: Date | string | null; + }> = await this.dataSource.query( + `SELECT va.id AS "assignmentId", + va.vehicle_id AS "vehicleId", + COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber", + COALESCE(t.code, NULLIF(UPPER(TRIM(v.vehicle_type)), '')) AS "vehicleType", + COALESCE(va.destination_arrived_at, $2::timestamptz) AS "startAt", + COALESCE(va.returned_at, $3::timestamptz) AS "endAt" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.vehicles v ON v.id = va.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN freight.truck_types t + ON t.id = v.truck_type_id AND t.deleted_at IS NULL + WHERE va.last_mile_id = $1 AND va.deleted_at IS NULL + ORDER BY va.created_at ASC`, + [lastMileId, leg.arrivedAt ?? null, leg.deliveredAt ?? null], + ); + // No trucks assigned yet: keep the leg-level single-truck estimate so the + // preview still tells the operator what detention would cost. + const trucks = truckRows.length + ? truckRows + : [ + { + assignmentId: null as string | null, + vehicleId: null as string | null, + plateNumber: null as string | null, + vehicleType: null as string | null, + startAt: leg.arrivedAt ?? null, + endAt: leg.deliveredAt ?? null, + }, + ]; const rules = await this.feeRuleRepository.findAll({ where: { isActive: true } }); const detentionRules = rules.filter((r) => r.ruleType === 'TRUCK_DETENTION_FEE'); @@ -853,7 +886,7 @@ export class WarehouseFeeService { const targetCurrency = this.normalizeCurrency(billingCurrency); const computed = await Promise.all( - groups.map(async (g) => { + trucks.map(async (t) => { const item: ItemAttributes = { arrivedAt: null, gateClearedAt: null, @@ -862,7 +895,7 @@ export class WarehouseFeeService { tradeDirection: leg.tradeDirection ?? null, cargoTypeCode: null, containerTypeCode: null, - vehicleType: g.vehicleType ?? null, + vehicleType: t.vehicleType ?? null, inventoryQuantity: 1, inventoryWeight: 0, bookingContainerCount: 1, @@ -875,37 +908,47 @@ export class WarehouseFeeService { zoneId: null, }; const rule = this.bestRule(detentionRules, item); + // truckCount 1 — this row IS one truck. const c = await this.computeTruckDetention( rule, - { arrivedAt: leg.arrivedAt, deliveredAt: leg.deliveredAt, truckCount: g.truckCount }, + { arrivedAt: t.startAt, deliveredAt: t.endAt, truckCount: 1 }, now, billingCurrency, ); - return { vehicleType: g.vehicleType ?? null, truckCount: Math.max(1, Math.round(Number(g.truckCount) || 1)), c }; + return { ...t, c }; }), ); const totalAmount = Math.round(computed.reduce((s, x) => s + x.c.amount, 0) * 100) / 100; - const totalTrucks = computed.reduce((s, x) => s + x.truckCount, 0); + const totalTrucks = computed.length; const totalBillable = computed.reduce((s, x) => s + x.c.billableUnits, 0); - const chargeableDays = computed[0]?.c.chargeableDays ?? 0; + // Header days: the worst truck — a single number can't represent per-truck + // windows, and the longest detention is the one operations must act on. + const chargeableDays = computed.reduce((m, x) => Math.max(m, x.c.chargeableDays), 0); const single = computed.length === 1 ? computed[0].c : null; const anyRuleName = computed.find((x) => x.c.ruleId)?.c.ruleName ?? null; + const earliestStart = computed + .map((x) => (x.startAt ? new Date(x.startAt).getTime() : null)) + .filter((n): n is number => n != null) + .sort((a, b) => a - b)[0]; + const anyOpen = computed.some((x) => x.c.endIsOpen); return { ruleType: 'TRUCK_DETENTION_FEE', basis: null, unitLabel: 'truck', ruleId: single?.ruleId ?? null, - ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck-type rules' : anyRuleName, + ruleName: single ? single.ruleName : computed.length > 1 && anyRuleName ? 'Per truck rules' : anyRuleName, freeDays: 0, ratePerDay: single?.ratePerDay ?? 0, currency: targetCurrency, ruleCurrency: single?.ruleCurrency ?? null, billingCurrency: targetCurrency, - startDate: leg.arrivedAt ? new Date(leg.arrivedAt).toISOString() : null, - endDate: (leg.deliveredAt ? new Date(leg.deliveredAt) : now).toISOString(), - endIsOpen: !leg.deliveredAt, + startDate: earliestStart != null ? new Date(earliestStart).toISOString() : null, + endDate: (anyOpen ? now : new Date(Math.max( + ...computed.map((x) => (x.endAt ? new Date(x.endAt).getTime() : now.getTime())), + ))).toISOString(), + endIsOpen: anyOpen, elapsedDays: chargeableDays, chargeableDays, containerCount: totalTrucks, @@ -913,8 +956,14 @@ export class WarehouseFeeService { amount: totalAmount, tiers: single ? single.tiers : [], groups: computed.map((x) => ({ + assignmentId: x.assignmentId, + vehicleId: x.vehicleId, + plateNumber: x.plateNumber, vehicleType: x.vehicleType, - truckCount: x.truckCount, + truckCount: 1, + startDate: x.startAt ? new Date(x.startAt).toISOString() : null, + endDate: x.c.endDate, + endIsOpen: x.c.endIsOpen, chargeableDays: x.c.chargeableDays, ratePerDay: x.c.ratePerDay, amount: x.c.amount, 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 275cf08dc..c0b3cc060 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 @@ -1141,6 +1141,8 @@ export class WarehouseInventoryService { async autoUnloadArrived(): Promise { const arrived: { id: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -1158,10 +1160,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.bookings b LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE b.status = ANY($1) AND b.deleted_at IS NULL AND inv.id IS NULL`, [this.ARRIVED_BOOKING_STATUSES], ); @@ -1198,7 +1202,7 @@ export class WarehouseInventoryService { status: 'RECEIVED', arrivedAt: new Date(), ...(booking.tradeDirection === 'EXPORT' - ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) } + ? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date(), booking.customer) } : {}), notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue', }); @@ -1223,12 +1227,19 @@ export class WarehouseInventoryService { // A GRN is the receipt for cargo entering the warehouse, so every booking // gets one on unload — import as well as export. The direction only decides // the GRN prefix, not whether one is issued. - const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query( - `SELECT trade_direction AS "tradeDirection" - FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); + // The GRN is mapped to the goods owner (the booking's company), so pull it + // alongside the direction rather than issuing an owner-less number. + const [bookingRow]: Array<{ tradeDirection: string | null; ownerName: string | null }> = + await this.dataSource.query( + `SELECT b.trade_direction AS "tradeDirection", + company.name AS "ownerName" + FROM freight.bookings b + LEFT JOIN freight.companies company ON company.id = b.company_id + WHERE b.id = $1 AND b.deleted_at IS NULL`, + [bookingId], + ); const grnDirection = bookingRow?.tradeDirection ?? 'WH'; + const ownerName = bookingRow?.ownerName ?? null; let location: DefaultLocation | null = dto.warehouseId && dto.yardId && dto.zoneId @@ -1252,7 +1263,7 @@ export class WarehouseInventoryService { // Keep an already-issued GRN rather than reissuing; mint one otherwise. ...(existing[0].grnNumber ? {} - : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt) }), + : { grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName) }), notes: dto.notes ?? existing[0].notes ?? 'Unloaded', }); return this.findById(existing[0].id); @@ -1267,7 +1278,7 @@ export class WarehouseInventoryService { weight: 0, status: 'RECEIVED', arrivedAt, - grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt), + grnNumber: this.generateGrnNumber(grnDirection, bookingId, arrivedAt, ownerName), notes: dto.notes ?? 'Unloaded', }); return this.findById(saved.id); @@ -1577,7 +1588,7 @@ export class WarehouseInventoryService { } const now = new Date(); - const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now); + const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now, booking.customer); const truckEntrance = dto.truckEntrance ? this.mergeSystemTruckEntrance(dto.truckEntrance, booking) : undefined; @@ -2143,6 +2154,8 @@ export class WarehouseInventoryService { const bookings: { id: string; status: string; + /** Goods owner (company) — the GRN number is mapped to it. */ + customer: string | null; weight: string | null; freightType: string | null; tradeDirection: string | null; @@ -2165,10 +2178,12 @@ export class WarehouseInventoryService { WHERE bc2.booking_id = b.id AND bcu.deleted_at IS NULL) ) AS weight, b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", - cgt.code AS "cargoTypeCode" + cgt.code AS "cargoTypeCode", + company.name AS customer FROM freight.train_schedule_bookings tsb JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id + LEFT JOIN freight.companies company ON company.id = b.company_id WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL AND b.destination_yard_id = $2`, [scheduleId, schedule.destinationStationId], @@ -2235,7 +2250,7 @@ export class WarehouseInventoryService { unloadedAt: now, arrivedAt: existing.arrivedAt ?? now, // Import GRN is issued automatically at train unload. - ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now) }), + ...(existing.grnNumber ? {} : { grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer) }), }); await this.activityLog.record({ activityType: 'INVENTORY_UNLOADED', @@ -2285,7 +2300,7 @@ export class WarehouseInventoryService { quantity: 1, weight: Number(booking.weight) || 0, status: 'UNLOADED', - grnNumber: this.generateGrnNumber('IMPORT', booking.id, now), + grnNumber: this.generateGrnNumber('IMPORT', booking.id, now, booking.customer), arrivedAt: now, unloadedAt: now, notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train', @@ -2719,7 +2734,12 @@ export class WarehouseInventoryService { this.assertCapacity('Zone', zone, weight, volume, containerCount); const now = new Date(); - const grnNumber = this.generateGrnNumber(bookingDirection ?? 'WH', dto.bookingId ?? 'MANUAL', now); + const grnNumber = this.generateGrnNumber( + bookingDirection ?? 'WH', + dto.bookingId ?? 'MANUAL', + now, + truckEntrance?.ownerName ?? bookingSource?.customer, + ); const receiveNote = this.buildReceiveNote({ grnNumber, notes: dto.notes?.trim() || 'Single booking received', @@ -5350,7 +5370,9 @@ export class WarehouseInventoryService { }); const rows: Array<[string, unknown]> = [ ['Booking Reference', data.bookingReference], - ['Customer / Consignee', data.customerName], + // The GRN is mapped to the owner (import: consignee, export: shipper) — + // named explicitly so the note reads the same for both directions. + ["Owner's Name", data.customerName], ['Customer TIN', data.customerTin], ['Booking Status', data.bookingStatus], ['Service Type', data.serviceType], @@ -5981,8 +6003,13 @@ export class WarehouseInventoryService { } /** Shared with the facility handling flow — see common/grn.util.ts. */ - private generateGrnNumber(direction: string, referenceId: string, date: Date): string { - return generateGrnNumber(direction, referenceId, date); + private generateGrnNumber( + direction: string, + referenceId: string, + date: Date, + ownerName?: string | null, + ): string { + return generateGrnNumber(direction, referenceId, date, ownerName); } private async generateReleaseReference(item: WarehouseInventory): Promise { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts index 99bbdd21f..41f6aacaf 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.repository.ts @@ -1,8 +1,9 @@ import { BaseRepository } from '@edr/api-common'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DeepPartial, Repository } from 'typeorm'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @Injectable() @@ -10,4 +11,20 @@ export class WarehouseYardsRepository extends BaseRepository { constructor(@InjectRepository(WarehouseYard) repository: Repository) { super(repository); } + + /** The cargoTypes relation can't ride a column UPDATE — sync it via entity save, like the plain columns. */ + async update(id: string, data: DeepPartial): Promise { + const { cargoTypes, ...columns } = data; + if (Object.keys(columns).length) { + await this.repository.update(id, columns as never); + } + if (cargoTypes) { + const entity = await this.repository.findOne({ where: { id } as never }); + if (entity) { + entity.cargoTypes = cargoTypes as CargoType[]; + await this.repository.save(entity); + } + } + return this.findById(id); + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 5b5e2b227..874de75db 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -15,7 +16,7 @@ export class WarehouseYardsService { findAll(): Promise { return this.yardsRepository.findAll({ - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } @@ -23,14 +24,14 @@ export class WarehouseYardsService { findByWarehouse(warehouseId: string): Promise { return this.yardsRepository.findAll({ where: { warehouseId }, - relations: { zones: true }, + relations: { zones: true, cargoTypes: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const yard = await this.yardsRepository.findById(id, { - relations: { warehouse: true, zones: true }, + relations: { warehouse: true, zones: true, cargoTypes: true }, }); if (!yard) { @@ -51,6 +52,7 @@ export class WarehouseYardsService { name: dto.name.trim(), code: dto.code.trim(), type: dto.type, + direction: dto.direction ?? null, capacityWeight: dto.capacityWeight ?? null, capacityContainers: dto.capacityContainers ?? null, maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null, @@ -60,6 +62,8 @@ export class WarehouseYardsService { currentVolume: 0, status: 'ACTIVE', isActive: true, + // Join rows are written by the save (RESTRICT FK rejects unknown ids). + cargoTypes: (dto.cargoTypeIds ?? []).map((id) => ({ id }) as CargoType), }); } @@ -84,12 +88,16 @@ export class WarehouseYardsService { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, + direction: dto.direction ?? existing.direction, capacityWeight: newCapacityWeight, capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, isActive: status === 'ACTIVE', + ...(dto.cargoTypeIds + ? { cargoTypes: dto.cargoTypeIds.map((cargoTypeId) => ({ id: cargoTypeId }) as CargoType) } + : {}), }); if (!updated) { diff --git a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx index c9748d80a..934832c34 100644 --- a/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/operations/TruckDetentionModal.tsx @@ -43,21 +43,56 @@ function Stat({ label, value, strong }: { label: string; value: React.ReactNode; ); } +type TruckRow = { + vehicleId: string; + label: string; + arrived: Date | null; + returned: Date | null; +}; + +const plateOf = (a: NonNullable[number]) => + [a.vehicle?.code, a.vehicle?.plateNumber].filter(Boolean).join(' · ') || a.vehicleId; + /** - * View/override the detention clock (arrival + delivery/return) for a last-mile - * leg, preview the per-truck-per-day charge, and generate the detention invoice. + * Detention is PER TRUCK: every truck reaches the destination and is released at + * its own time, so each row carries its own clock, days and amount. Legs with no + * trucks assigned fall back to the single leg-level window. */ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionModalProps) { const { toast } = useToast(); const qc = useQueryClient(); const id = record?.id ?? null; + const assignments = record?.vehicleAssignments ?? []; + const perTruck = assignments.length > 0; + + const [rows, setRows] = useState([]); + // Leg-level fallback (no trucks assigned yet). const [arrived, setArrived] = useState(null); const [delivered, setDelivered] = useState(null); useEffect(() => { + setRows( + assignments.map((a) => ({ + vehicleId: a.vehicleId, + label: plateOf(a), + // Fall back to the leg-level pair so a truck without its own window + // shows what it is actually being billed on today. + arrived: a.destinationArrivedAt + ? new Date(a.destinationArrivedAt) + : record?.arrivedAt + ? new Date(record.arrivedAt) + : null, + returned: a.returnedAt + ? new Date(a.returnedAt) + : record?.deliveredAt + ? new Date(record.deliveredAt) + : null, + })), + ); setArrived(record?.arrivedAt ? new Date(record.arrivedAt) : null); setDelivered(record?.deliveredAt ? new Date(record.deliveredAt) : null); - }, [record?.id, record?.arrivedAt, record?.deliveredAt, opened]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [record?.id, record?.arrivedAt, record?.deliveredAt, assignments.length, opened]); const previewQuery = useQuery({ queryKey: ['truck-detention-preview', id], @@ -65,19 +100,35 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM enabled: opened && Boolean(id), }); const preview = previewQuery.data; + // With several trucks the header rule is null by design (each truck resolves + // its own) — only warn when NO truck matched a rule. + const hasAnyRule = Boolean(preview?.ruleId) || (preview?.groups ?? []).some((g) => g.ruleId); + const byVehicle = new Map((preview?.groups ?? []).map((g) => [g.vehicleId ?? '', g])); const saveTimes = useMutation({ mutationFn: () => - lastMileService.update(id as string, { - arrivedAt: arrived ? arrived.toISOString() : null, - deliveredAt: delivered ? delivered.toISOString() : null, - }), + perTruck + ? lastMileService.setDetentionTimes( + id as string, + rows.map((r) => ({ + vehicleId: r.vehicleId, + destinationArrivedAt: r.arrived ? r.arrived.toISOString() : null, + returnedAt: r.returned ? r.returned.toISOString() : null, + })), + ) + : lastMileService.update(id as string, { + arrivedAt: arrived ? arrived.toISOString() : null, + deliveredAt: delivered ? delivered.toISOString() : null, + }), onSuccess: () => { void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT }); void previewQuery.refetch(); toast({ title: 'Detention times saved' }); }, - onError: () => toast({ title: 'Save failed', variant: 'destructive' }), + onError: (e: unknown) => { + const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message; + toast({ title: 'Save failed', description, variant: 'destructive' }); + }, }); const generate = useMutation({ @@ -93,12 +144,40 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM }, }); + const handleSave = () => { + const values = perTruck + ? rows.flatMap((r) => [r.arrived, r.returned]) + : [arrived, delivered]; + // No backdating: detention times are recorded as they happen. + if (values.some((v) => isBackdated(v))) { + toast({ variant: 'destructive', title: 'Detention times cannot be in the past' }); + return; + } + const reversed = perTruck + ? rows.find((r) => r.arrived && r.returned && r.returned < r.arrived) + : arrived && delivered && delivered < arrived + ? { label: 'this delivery' } + : undefined; + if (reversed) { + toast({ + variant: 'destructive', + title: 'Return time is before arrival', + description: `Check the times for ${reversed.label}.`, + }); + return; + } + saveTimes.mutate(); + }; + + const patchRow = (vehicleId: string, patch: Partial) => + setRows((prev) => prev.map((r) => (r.vehicleId === vehicleId ? { ...r, ...patch } : r))); + return ( Truck detention{record?.booking?.reference ? ` · ${record.booking.reference}` : ''} @@ -106,40 +185,94 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM } > - - setArrived(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - setDelivered(v ? new Date(v) : null)} - minDate={new Date()} - clearable - /> - + {perTruck ? ( + + + Each truck has its own detention clock — record when it reached the destination and + when it was released. Days and charges are calculated per truck. + + {rows.map((r) => { + const g = byVehicle.get(r.vehicleId); + return ( + + + + + {r.label} + + {g?.vehicleType && ( + + {g.vehicleType} + + )} + + {g && ( + + + {g.chargeableDays} day{g.chargeableDays === 1 ? '' : 's'} + {g.endIsOpen ? ' · still out' : ''} + + + {money(g.amount, preview?.currency ?? 'USD')} + + + )} + + + patchRow(r.vehicleId, { arrived: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + patchRow(r.vehicleId, { returned: v ? new Date(v) : null })} + minDate={new Date()} + clearable + /> + + {g && !g.ruleId && ( + + No detention rule matches this truck type — it will not be billed. + + )} + + ); + })} + + ) : ( + <> + + No trucks assigned yet — this records the delivery-level detention window. Assign + trucks to track each one separately. + + + setArrived(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + setDelivered(v ? new Date(v) : null)} + minDate={new Date()} + clearable + /> + + + )} - @@ -154,7 +287,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM No preview available. - ) : !preview.ruleId ? ( + ) : !hasAnyRule ? ( No active Truck Detention rule matches this booking. Create one under Warehouse → Fee rules (rule type "Truck Detention Cost"). @@ -162,39 +295,47 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM ) : ( - + - + {preview.endIsOpen && ( - Still accruing — no delivery/return time yet. The amount grows until the vehicle is returned. + Still accruing — at least one truck has no release time yet. The amount grows until + every truck is returned. )} - {preview.groups && preview.groups.length > 1 ? ( + {preview.groups && preview.groups.length > 0 ? ( - Truck type - Trucks + Truck + Type Days - Rate / truck / day + Rate / day Amount {preview.groups.map((g, i) => ( - + - {g.vehicleType ?? 'Unknown'} + {g.plateNumber ?? 'Unassigned'} {!g.ruleId && ( {' '}· no rule )} - {g.truckCount} - {g.chargeableDays} + {g.vehicleType ?? 'Unknown'} + + {g.chargeableDays} + {g.endIsOpen && ( + + {' '}· open + + )} + {money(g.ratePerDay, preview.currency)} {money(g.amount, preview.currency)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 54e60d8b2..cfbdd7b82 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -80,7 +80,7 @@ import { MoveInventoryModal } from './MoveInventoryModal'; import { ReleaseOrderModal } from './ReleaseOrderModal'; import { StoreInventoryModal } from './StoreInventoryModal'; import { WarehouseInquiryTable } from './WarehouseInquiryTable'; -import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions } from './options'; +import { extractDownloadErrorMessage, extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions, warehousesAtStation, yardsForBooking } from './options'; import { openPdfBlob } from './pdf'; import '@/components/overview/overview.css'; @@ -1978,14 +1978,6 @@ function LoadedExportTab({ ); } -const importLocationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isImportContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -2039,10 +2031,60 @@ function ImportTrainDetailTable({ enabled: Boolean(train.scheduleId), }), ); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isImportUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isImportUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isImportUnloadPending); @@ -2093,12 +2135,17 @@ function ImportTrainDetailTable({ {items.map((it: ImportTrainItem) => { const draft = assignments[it.bookingId] ?? {}; - const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: it.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: it.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isImportUnloadPending(it); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts new file mode 100644 index 000000000..9c0181b40 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; + +import { warehousesAtStation, yardsForBooking } from "./options"; +import type { Warehouse, WarehouseYard } from "@/types/warehouse"; + +// Mirrors Indode's real 11-yard layout at a reduced scale, so these cases read +// against the actual booking-routing decisions staff rely on. +const yard = (overrides: Partial): WarehouseYard => + ({ + id: overrides.code, + warehouseId: "indode", + name: overrides.code, + code: overrides.code, + type: "GENERAL_CARGO_YARD", + capacityWeight: null, + capacityContainers: null, + maxWeight: null, + maxVolume: null, + currentWeight: 0, + currentContainers: 0, + currentVolume: 0, + status: "ACTIVE", + isActive: true, + ...overrides, + }) as WarehouseYard; + +const YARDS: WarehouseYard[] = [ + yard({ code: "Y2", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "1", code: "STEEL_BILLET" }] }), + yard({ code: "Y3", type: "GENERAL_CARGO_YARD", cargoTypes: [{ id: "2", code: "AUTOMOBILE" }, { id: "3", code: "TRUCK" }] }), + yard({ code: "Y4", type: "BULK_YARD", status: "INACTIVE", isActive: false, cargoTypes: [{ id: "4", code: "WHEAT" }] }), + yard({ code: "Y5", type: "CONTAINER_YARD", direction: "IMPORT" }), + yard({ code: "Y6", type: "CONTAINER_YARD", direction: "EXPORT" }), + yard({ code: "Y10", type: "CONTAINER_YARD", direction: "BOTH" }), // service yard + yard({ code: "Y11", type: "CONTAINER_YARD", direction: "BOTH" }), // equipment yard +]; + +describe("yardsForBooking", () => { + it("container import narrows to exactly the import stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y5"]); + }); + + it("container export narrows to exactly the export stack", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "EXPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).toEqual(["Y6"]); + }); + + it("never offers a BOTH-direction container yard (service/equipment) for ordinary cargo", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "CONTAINER", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("Y10"); + expect(result.map((y) => y.code)).not.toContain("Y11"); + }); + + it("bulk cargo narrows to the yard configured for that exact cargo type", () => { + const automobile = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "AUTOMOBILE", + }); + expect(automobile.map((y) => y.code)).toEqual(["Y3"]); + + const steel = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(steel.map((y) => y.code)).toEqual(["Y2"]); + }); + + it("falls back to every non-container yard when the one configured for this cargo type is closed", () => { + // Y4 (Dry Bulk, WHEAT) is inactive — never strand staff with an empty + // picker just because the ideal yard is closed; same safety net as + // warehousesAtStation falling back when a station has no mapped warehouse. + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "WHEAT", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("falls back to every non-container yard when no yard is configured for that cargo type yet", () => { + const result = yardsForBooking(YARDS, { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "SOMETHING_UNMAPPED", + }); + expect(result.map((y) => y.code).sort()).toEqual(["Y2", "Y3"]); + }); + + it("a yard with no configured cargo types is open to anything (unconfigured, not restrictive)", () => { + const openYard = yard({ code: "GENERIC", type: "BULK_YARD" }); + const result = yardsForBooking([...YARDS, openYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: "STEEL_BILLET", + }); + expect(result.map((y) => y.code).sort()).toEqual(["GENERIC", "Y2"]); + }); + + it("only offers yards at the requested warehouse", () => { + const otherWarehouseYard = yard({ code: "SEBETA-Y1", warehouseId: "sebeta", type: "GENERAL_CARGO_YARD" }); + const result = yardsForBooking([...YARDS, otherWarehouseYard], { + warehouseId: "indode", + freightType: "BULK", + tradeDirection: "IMPORT", + cargoTypeCode: null, + }); + expect(result.map((y) => y.code)).not.toContain("SEBETA-Y1"); + }); +}); + +describe("warehousesAtStation", () => { + const warehouse = (id: string, stationId: string | null): Warehouse => + ({ id, stationId, name: id, code: id } as Warehouse); + + it("restricts to the warehouse at the given station", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-a"); + expect(result.map((w) => w.id)).toEqual(["indode"]); + }); + + it("falls back to every warehouse when the station has no match", () => { + const warehouses = [warehouse("indode", "station-a"), warehouse("sebeta", "station-b")]; + const result = warehousesAtStation(warehouses, "station-unknown"); + expect(result).toEqual(warehouses); + }); + + it("falls back to every warehouse when the station is null", () => { + const warehouses = [warehouse("indode", "station-a")]; + expect(warehousesAtStation(warehouses, null)).toEqual(warehouses); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index d124a3325..958e2d7f7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -4,6 +4,8 @@ import { WAREHOUSE_ZONE_TYPES, WAREHOUSE_STATUSES, INVENTORY_STATUSES, + type Warehouse, + type WarehouseYard, } from '@/types/warehouse'; export const humanizeEnum = (value: string) => @@ -16,6 +18,58 @@ export const humanizeEnum = (value: string) => const toOptions = (values: readonly string[]) => values.map((value) => ({ value, label: humanizeEnum(value) })); +/** + * Warehouses actually located at a train's station — e.g. a train destined for + * Indode should only offer Indode's own warehouse, not Sebeta's or Modjo's. + * Falls back to every warehouse when the station is unmapped (no `stationId` + * match anywhere), so unusual/legacy data never blocks the unload flow entirely. + */ +export const warehousesAtStation = (warehouses: Warehouse[], stationId: string | null | undefined) => { + if (!stationId) return warehouses; + const atStation = warehouses.filter((w) => w.stationId === stationId); + return atStation.length ? atStation : warehouses; +}; + +/** + * Yards at ONE warehouse eligible to receive a booking, given what it actually + * is — e.g. at Indode: container import always narrows to Yard 5, export to + * Yard 6; a Wheat booking narrows to Yard 4 (Dry Bulk), not Break Bulk or + * Coffee/Tea. Mirrors `warehousesAtStation`'s fallback philosophy: an + * unconfigured yard (no cargo types set) stays open rather than disappearing, + * but a yard that IS configured for other cargo never shows for a mismatch. + * + * Container yards are the one case with no such fallback: a CONTAINER_YARD + * left at direction BOTH/null (Indode's Yard 10 service yard, Yard 11 + * equipment yard) is a service/equipment yard, not a customer cargo yard, and + * must never be offered just because the exact-direction stack is missing. + */ +export const yardsForBooking = ( + yards: WarehouseYard[], + params: { + warehouseId: string | null | undefined; + freightType: string | null | undefined; + tradeDirection: string | null | undefined; + cargoTypeCode: string | null | undefined; + }, +): WarehouseYard[] => { + const atWarehouse = yards.filter((y) => y.warehouseId === params.warehouseId && y.isActive); + const isContainer = (params.freightType ?? '').toUpperCase() === 'CONTAINER'; + + if (isContainer) { + const direction = (params.tradeDirection ?? '').toUpperCase(); + return atWarehouse.filter((y) => y.type === 'CONTAINER_YARD' && y.direction === direction); + } + + const nonContainer = atWarehouse.filter((y) => y.type !== 'CONTAINER_YARD'); + if (!params.cargoTypeCode) return nonContainer; + + const cargoMatched = nonContainer.filter((y) => { + const codes = (y.cargoTypes ?? []).map((c) => c.code); + return codes.length === 0 || codes.includes(params.cargoTypeCode as string); + }); + return cargoMatched.length ? cargoMatched : nonContainer; +}; + export const warehouseTypeOptions = toOptions(WAREHOUSE_TYPES); export const yardTypeOptions = toOptions(WAREHOUSE_YARD_TYPES); export const zoneTypeOptions = toOptions(WAREHOUSE_ZONE_TYPES); diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index 61afa426c..8bb1080d9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -18,6 +18,8 @@ import { WarehouseOpsKpiStrip, formatDate, formatNumber, + warehousesAtStation, + yardsForBooking, } from '@/components/warehouses'; import { useAutoUnloadArrivedBookings, @@ -49,14 +51,6 @@ const getPendingUnloadBookings = (train: ImportTrain) => const isFullyUnloaded = (train: ImportTrain) => Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); -const locationTypesForFreight = (freightType: string | null | undefined) => { - const normalized = (freightType ?? '').toUpperCase(); - if (normalized === 'CONTAINER') { - return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] }; - } - return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] }; -}; - const isContainerFreight = (freightType: string | null | undefined) => (freightType ?? '').toUpperCase() === 'CONTAINER'; @@ -65,7 +59,7 @@ function isUnloadPending(item: ImportTrainItem) { } function ImportTrainDetailRows({ - scheduleId, + train, warehouses, yards, zones, @@ -73,7 +67,7 @@ function ImportTrainDetailRows({ onAssignmentChange, onReadyChange, }: { - scheduleId: string; + train: ImportTrain; warehouses: Warehouse[]; yards: WarehouseYard[]; zones: WarehouseZone[]; @@ -81,11 +75,61 @@ function ImportTrainDetailRows({ onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void; onReadyChange: (ready: boolean) => void; }) { - const { data: items = [], isLoading } = useImportTrainItems(scheduleId); - const warehouseOptions = useMemo( - () => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), - [warehouses], + const { data: items = [], isLoading } = useImportTrainItems(train.scheduleId); + // A train only ever unloads at the warehouse actually sitting at its + // destination station — Indode's train never offers Sebeta's warehouse. + const scopedWarehouses = useMemo( + () => warehousesAtStation(warehouses, train.destinationStationId), + [warehouses, train.destinationStationId], ); + const warehouseOptions = useMemo( + () => scopedWarehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })), + [scopedWarehouses], + ); + // With exactly one warehouse at the station there is nothing to choose — + // pre-fill it so staff only has to pick yard/zone, not re-discover Indode. + useEffect(() => { + if (scopedWarehouses.length !== 1) return; + const onlyWarehouseId = scopedWarehouses[0].id; + items.filter(isUnloadPending).forEach((item) => { + if (!assignments[item.bookingId]?.warehouseId) { + onAssignmentChange(item.bookingId, { ...assignments[item.bookingId], warehouseId: onlyWarehouseId }); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopedWarehouses, items]); + + // Once a booking's warehouse is known, its yard (and then zone) follow from + // what the cargo actually is — a Wheat booking only ever has one candidate + // yard (Dry Bulk) once Indode's real yard layout is configured, so staff + // never see a picker for something that isn't actually a choice. + useEffect(() => { + items.filter(isUnloadPending).forEach((item) => { + const draft = assignments[item.bookingId]; + if (!draft?.warehouseId) return; + + if (!draft.yardId) { + const candidateYards = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }); + if (candidateYards.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, yardId: candidateYards[0].id }); + } + return; + } + + if (!draft.zoneId) { + const candidateZones = zones.filter((zone) => zone.yardId === draft.yardId); + if (candidateZones.length === 1) { + onAssignmentChange(item.bookingId, { ...draft, zoneId: candidateZones[0].id }); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assignments, items, yards, zones]); useEffect(() => { const pending = items.filter(isUnloadPending); @@ -135,12 +179,17 @@ function ImportTrainDetailRows({ {items.map((item: ImportTrainItem) => { const draft = assignments[item.bookingId] ?? {}; - const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType); - const yardOptions = yards - .filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type)) - .map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + const yardOptions = yardsForBooking(yards, { + warehouseId: draft.warehouseId, + freightType: item.freightType, + tradeDirection: 'IMPORT', + cargoTypeCode: item.cargoTypeCode, + }).map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` })); + // The yard is already scoped to what this cargo can go into — a + // zone's own type always matches its parent yard's purpose (see the + // Indode seed migration), so no separate zone-type filter is needed. const zoneOptions = zones - .filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type)) + .filter((zone) => zone.yardId === draft.yardId) .map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` })); const pending = isUnloadPending(item); @@ -395,7 +444,7 @@ export default function ArrivalQueuePage() { api.get(`${LM.BASE}/${id}/truck-detention-preview`), + /** Per-truck detention windows — each truck has its own clock. */ + setDetentionTimes: ( + id: string, + trucks: Array<{ + vehicleId: string; + destinationArrivedAt?: string | null; + returnedAt?: string | null; + }>, + ) => api.post(`${LM.BASE}/${id}/detention-times`, { trucks }), }; diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index c38d9b40d..8ff87c251 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -118,12 +118,19 @@ export interface WarehouseZone { isActive: boolean; } +/** IMPORT | EXPORT | BOTH | null. Only meaningful for CONTAINER_YARD — everything else takes cargo either way. */ +export type WarehouseYardDirection = 'IMPORT' | 'EXPORT' | 'BOTH'; + export interface WarehouseYard { id: string; warehouseId: string; name: string; code: string; type: WarehouseYardType; + /** For CONTAINER_YARD: which direction this stack serves. BOTH/null on a container yard means "not a customer cargo yard" (service/equipment), not "any direction". */ + direction?: WarehouseYardDirection | null; + /** Cargo types this yard accepts. Empty/absent = open to any cargo type of this yard's structural type. */ + cargoTypes?: Array<{ id: string; code: string }>; capacityWeight: number | null; capacityContainers: number | null; maxWeight: number | null; @@ -518,6 +525,8 @@ export interface ImportTrain { route: string | null; origin: string | null; destination: string | null; + /** freight.yards.id the train is heading to — matches Warehouse.stationId, so the unload picker can be scoped to the warehouse actually at this station. */ + destinationStationId: string | null; departureTime?: string | null; arrivalTime: string | null; totalBookings: number; @@ -632,6 +641,8 @@ export interface ImportTrainItem { freightType: string | null; containerNumber: string | null; cargoType: string | null; + /** Cargo type CODE (e.g. "WHEAT"), for matching against a yard's configured cargo types — `cargoType` above is the display name. */ + cargoTypeCode: string | null; weight: number | null; arrivalTime: string | null; currentStatus: string | null; @@ -856,10 +867,16 @@ export interface FeePreview { billableUnits: number; amount: number; tiers?: FeePreviewTier[]; - /** Truck detention: per-vehicle-type breakdown. */ + /** Truck detention: one row per truck — each has its own window and rule. */ groups?: Array<{ + assignmentId?: string | null; + vehicleId?: string | null; + plateNumber?: string | null; vehicleType: string | null; truckCount: number; + startDate?: string | null; + endDate?: string | null; + endIsOpen?: boolean; chargeableDays: number; ratePerDay: number; amount: number; diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index 215490361..cf00cfe51 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -181,6 +181,17 @@ GITHUB_PACKAGE_TOKEN= # Login endpoint for backoffice users: POST /v1/auth/login SEED_EDR_PASSENGER_ORG=false SEED_PASSENGER_STAFF=false +# IAM baseline shared with edr-freight-api (roles, IAM app + permissions, position +# types, organization types + default units, org/unit settings, super admin). +# Replaces the seeder that used to ship inside @tria-plc/iamapi-common — see +# packages/iam-seed. Seeds by DEFAULT when unset; every write is insert-only. +# Set to false to opt out. +SEED_IAM_BASELINE=true +# Super-admin account seeded by the above. Shared across the apps on this schema. +SUPER_ADMIN_EMAIL=superadmin@tria.com +SUPER_ADMIN_PHONE= +# Falls back to DEFAULT_PASSWORD when empty. +SUPER_ADMIN_DEFAULT_PASSWORD= # Plain-text password set on seeded staff accounts. Defaults to '12345678' if unset. DEFAULT_PASSWORD=Admin@1234 diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index c4587e98a..f58cd7bcf 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -27,6 +27,7 @@ "prisma:verify": "ts-node prisma/verify-backfill.ts" }, "dependencies": { + "@edr/iam-seed": "workspace:*", "@edr/types": "workspace:*", "@golevelup/nestjs-rabbitmq": "^5.5.0", "@nestjs/axios": "^4.0.1", diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index cce0f5e7d..014e4f5c6 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -4,8 +4,8 @@ import { ConfigModule, ConfigService } from "@nestjs/config"; import { ScheduleModule } from "@nestjs/schedule"; import { EventEmitterModule } from "@nestjs/event-emitter"; import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; +import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule as TriaIamModule } from "@tria-plc/iamapi-common/iam.module"; -import { DataSeeder } from "@tria-plc/iamapi-common/db/seed/seeder"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import { EDR_PASSENGER_APPLICATION, @@ -103,6 +103,17 @@ import { EOtpType } from "@tria-plc/iamapi-common"; `Set your EDR Passenger password using this link: ${route}`, }, }), + // Replaces the package's DataSeeder. Shared with edr-freight-api, which + // seeds the same `iam` schema — see packages/iam-seed. + IamSeedModule.forRoot({ + superAdmin: { + username: "superadmin", + name: { am: "ሱፐር አድሚን", en: "Super Admin" }, + roleKey: "super_admin", + organizationKey: "edr", + fallbackEmail: "superadmin@tria.com", + }, + }), SharedAuthModule, PrismaModule, AuditModule, @@ -151,18 +162,23 @@ import { EOtpType } from "@tria-plc/iamapi-common"; export class AppModule implements OnApplicationBootstrap { private readonly logger = new Logger(AppModule.name); constructor( - private readonly seeder: DataSeeder, + private readonly iamBaselineSeeder: IamBaselineSeeder, private readonly edrPassengerOrgSeeder: EdrPassengerOrgSeeder, private readonly passengerStaffUsersSeeder: PassengerStaffUsersSeeder, private readonly segmentFareSeeder: SegmentFareSeeder, ) {} async onApplicationBootstrap() { + // Runs first so the roles it seeds exist before EdrPassengerOrgSeeder links + // super_admin permissions. Its own super-admin account attaches to the `edr` + // organization, which that seeder creates — so on a brand-new database the + // account lands on the next boot; it logs a warning and skips until then. + // Non-fatal internally, but the wrapper stays for symmetry with the rest. try { - await this.seeder.run(); + await this.iamBaselineSeeder.run(); } catch (err) { this.logger.error( - "[DataSeeder] Seed failed (non-fatal):", + "[IamBaselineSeeder] Seed failed (non-fatal):", (err as Error).message, ); } diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts index 36c0f872c..5a0fb50b1 100644 --- a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -1,19 +1,21 @@ /** - * Resolves the booking/check-in cutoff for one boarding stop. + * Resolves the booking/check-in cutoff for one boarding stop: stop-level + * `RouteStop.checkinMinutesBefore` override wins, else the route-level default + * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with + * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching + * that stop), not its departure or the schedule's overall origin departure — a downstream + * stop's cutoff must be independent of how long ago the train left its origin. The first stop + * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. * - * Priority for checkinMinutes: RouteStop.checkinMinutesBefore → Route.checkinMinutesBefore → 30. - * - * Anchor (segmentTime): plannedDepartureAt ?? plannedArrivalAt ?? schedule.departureAt. - * - For the origin stop: plannedDepartureAt = schedule.departureAt (no arrival). - * - For intermediate stops: plannedDepartureAt = plannedArrivalAt + dwell (checkinMinutesBefore). - * cutoffAt = departureAt − checkinMinutesBefore = arrivalAt, so booking closes the - * moment the train reaches the stop — independent of how long ago it left the origin. - * - * Single source of truth — SeatsService.holdSeats and SearchService.buildScheduleResult both - * apply it; GuestBookingService.createGuestBooking also applies it per boarding stop. + * Single source of truth for this computation — SeatsService.holdSeats and + * SearchService.buildScheduleResult already applied it (search results only ever showed a + * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking + * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin + * departure, which could reject a booking the search/hold steps had just accepted under the + * route's actual configured cutoff. */ export interface CheckinCutoff { - /** The stop's planned departure time (or arrival / schedule departure as fallback). */ + /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ segmentTime: Date; /** Minutes before segmentTime that booking/holding closes. */ checkinMinutes: number; @@ -32,7 +34,7 @@ export function resolveCheckinCutoff( stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, stationId: string | null | undefined, ): CheckinCutoff { - const segmentTime = stopTime?.plannedDepartureAt ?? stopTime?.plannedArrivalAt ?? schedule.departureAt; + const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; return { diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index bd04ed34f..b14084d43 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -369,6 +369,24 @@ export class BookingsController { return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId); } + @Delete("reservations/:seatId") + @PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin]) + @ApiBearerAuth("IAM-auth") + @ApiOperation({ + summary: "Cancel a seat's pending-payment reservation and release the seat", + description: + "For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).", + }) + @ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" }) + cancelReservationForSeat( + @Param("seatId") seatId: string, + @Query("scheduleId") scheduleId: string, + @Req() req: any, + ) { + const actingUserId = req.user?.id ?? req.user?.sub ?? null; + return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId); + } + @Get("pay/:token") @SetMetadata("isPublic", true) @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b5825ddc5..26ab854d3 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -2138,6 +2138,39 @@ export class BookingsService { return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency}; } + /** + * Staff releasing a seat that already has an in-flight backoffice reservation must not + * leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler + * could pay for a seat that's since been given away. Finds the active reservation covering + * this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's + * still unpaid), then separately releases the SeatHold issueBookingFromReservation created — + * cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet + * for an unpaid reservation, so without this the seat would stay held until the hold's own + * expiry. Once status flips to CANCELLED, getByPayToken's existing status check already + * rejects the old payToken with "This booking is no longer awaiting payment" — no separate + * payToken invalidation needed. + */ + async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) { + const bookingSeat = await this.prisma.bookingSeat.findFirst({ + where: { + seatId, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' }, + }, + include: { booking: true }, + }); + if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat'); + + const { bookingRef } = bookingSeat.booking; + const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined); + + await this.prisma.seatHold.deleteMany({ + where: { scheduleId, seatIds: { hasSome: [seatId] } }, + }); + + return { ...result, bookingRef }; + } + async update(id: string, dto: any) { const booking = await this.prisma.booking.findUnique({ where: { id } }); if (!booking) throw new NotFoundException('Booking not found'); diff --git a/apps/edr-passenger-api/src/modules/live/live.module.ts b/apps/edr-passenger-api/src/modules/live/live.module.ts index 268ba2385..aa46c7d57 100644 --- a/apps/edr-passenger-api/src/modules/live/live.module.ts +++ b/apps/edr-passenger-api/src/modules/live/live.module.ts @@ -2,5 +2,5 @@ import { Module } from '@nestjs/common'; import { LiveController } from './live.controller'; import { LiveService } from './live.service'; -@Module({ controllers: [LiveController], providers: [LiveService] }) +@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] }) export class LiveModule {} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 12f549e9f..9ef922bb3 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -312,6 +312,18 @@ export class NotificationsService { const ref = booking?.bookingRef ?? payload.booking.bookingRef; const passengerId = booking?.passengerId ?? payload.booking.passengerId; + // A backoffice-issued reservation already sends its own purpose-built message — + // GuestBookingService.issueBookingFromReservation texts /reserve/pay/ for a + // PASSENGER-kind booking (the traveler has no portal session, so this generic template's + // /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized + // immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is + // the correct one to send, not a redundant/contradictory "awaiting payment" notice. + const source = (booking as any)?.source ?? payload.booking?.source; + if (source === 'BACKOFFICE_RESERVATION') { + this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`); + return; + } + const template = await this.prisma.notificationTemplate.findUnique({ where: { code: 'booking.created' }, }); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 8cee1e94f..3f6228bf3 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SchedulesService } from './schedules.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; @@ -177,6 +177,22 @@ export class SchedulesController { @Body() dto: UpdateStopTimeDto, ) { return this.service.updateStop(id, sequence, dto); } + @Post(':id/delay') + @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') + @ApiOperation({ + summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount', + description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence +onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned +times, this is the only action needed for booking closure to reflect the delay — no separate cutoff +update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and +records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`, + }) + @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) + @ApiResponse({ status: 200, description: 'Schedule with shifted stop times' }) + applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) { + return this.service.applyDelay(id, dto); + } + @Put(':scheduleId/fares/:seatClassId') @PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') @ApiOperation({ diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 49dec1d96..333eb7d26 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto { @ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus; } +export class ApplyDelayDto { + @ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' }) + @IsInt() delayMinutes: number; + + @ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' }) + @IsOptional() @IsInt() @Min(1) fromSequence?: number; +} + export class BulkCreateSchedulesDto { @ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string; @ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string; diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts index 18d88d631..e01dc616b 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.module.ts @@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller'; import { RoutesService } from './routes.service'; import { FareEngineModule } from '../fare-engine/fare-engine.module'; import { AuditModule } from '../../common/audit.module'; +import { LiveModule } from '../live/live.module'; @Module({ - imports: [FareEngineModule, AuditModule], + imports: [FareEngineModule, AuditModule, LiveModule], controllers: [RoutesController, SchedulesController], providers: [RoutesService, SchedulesService], exports: [RoutesService, SchedulesService], diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index d6dfda6ec..7680d8f9c 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; -import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto'; +import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils'; import { AuditService } from '../../common/audit.service'; +import { LiveService } from '../live/live.service'; import { computePlannedStopTimes } from '../../common/utils/schedule-times.utils'; @Injectable() @@ -17,8 +18,47 @@ export class SchedulesService { private routesService: RoutesService, private fareEngine: FareEngineService, private auditService: AuditService, + private liveService: LiveService, ) { } + /** + * Computes each stop's planned arrival/departure time by walking the route in sequence + * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the + * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for + * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed + * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays + * authoritative even if per-stop estimates drift. + */ + private computePlannedTimes( + route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, + dep: Date, + arr: Date, + ) { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + let cursor = dep; + return route.stops.map((stop, index) => { + if (index === 0) { + cursor = dep; + } else if (index === route.stops.length - 1) { + cursor = arr; + } else if (stop.travelMinutesToStop != null) { + cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + cursor = new Date(dep.getTime() + totalDuration * progress); + this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), + }; + }); + } + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); @@ -89,6 +129,7 @@ export class SchedulesService { include: { coach: true }, orderBy: { positionNumber: 'asc' }, }, + liveStatus: { select: { delayMinutes: true } }, _count: { select: { coachAssignments: true, bookings: true } }, }, orderBy: { departureAt: 'asc' }, @@ -132,7 +173,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = computePlannedStopTimes(route, dep, arr); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -209,6 +250,7 @@ export class SchedulesService { orderBy: { positionNumber: 'asc' }, }, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + liveStatus: { select: { delayMinutes: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -303,7 +345,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - plannedTimes = computePlannedStopTimes(route, dep, arr); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -425,6 +467,70 @@ export class SchedulesService { }); } + /** + * Shifts stored planned times additively rather than reusing updateSchedulePartial's + * recompute-from-route-interpolation path — that path also guards `departureAt must be in the + * future`, which a delay report for an already-departed/EN_ROUTE train would legitimately + * fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived + * directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the + * stored values here is the entire fix — neither of those needs to change. + */ + async applyDelay(scheduleId: string, dto: ApplyDelayDto) { + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } }); + if (!schedule) throw new NotFoundException('Schedule not found'); + + const stopWhere: any = { scheduleId }; + if (dto.fromSequence != null) { + stopWhere.sequence = { gte: dto.fromSequence }; + } else { + // Default: only stops the train hasn't reached yet — a delay report must not retroactively + // move a stop that's already BOARDED/COMPLETED. + stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] }; + } + + const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere }); + const shiftMs = dto.delayMinutes * 60_000; + const includesOrigin = stopsToShift.some((s) => s.sequence === 1); + + await this.prisma.$transaction(async (tx) => { + for (const stop of stopsToShift) { + await tx.tripStopTime.update({ + where: { id: stop.id }, + data: { + plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined, + plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined, + }, + }); + } + + // Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window + // queries and the displayed departure time, so they must move too (both together, so + // durationMinutes stays correct). + if (includesOrigin) { + await tx.trainSchedule.update({ + where: { id: scheduleId }, + data: { + departureAt: new Date(schedule.departureAt.getTime() + shiftMs), + arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs), + }, + }); + } + }); + + const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } }); + const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes); + await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes }); + + await this.auditService.log({ + action: 'UPDATE', + entityType: 'Schedule', + entityId: scheduleId, + newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes }, + }); + + return this.getSchedule(scheduleId); + } + async upsertScheduleFare( scheduleId: string, seatClassId: string, @@ -681,7 +787,7 @@ export class SchedulesService { include: { stops: { orderBy: { sequence: 'asc' } } }, }); if (route && route.stops.length >= 2) { - const plannedTimes = computePlannedStopTimes(route, dep, arr); + const plannedTimes = this.computePlannedTimes(route, dep, arr); const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); } diff --git a/apps/edr-passenger-api/src/modules/search/search.controller.ts b/apps/edr-passenger-api/src/modules/search/search.controller.ts index 384592dc9..92aff000a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.controller.ts +++ b/apps/edr-passenger-api/src/modules/search/search.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { SearchService } from './search.service'; -import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto'; +import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto'; @ApiTags('Search') @Controller('search') @@ -67,6 +67,21 @@ Nationality-Based: return this.service.getFareQuote(dto); } + @Get('available-dates') + @ApiOperation({ + summary: 'Which dates in a range have a bookable schedule for an origin/destination pair', + description: `Used to disable schedule-less dates on the search date picker before the user submits a search. + +For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one +schedule exists for the origin→destination pair whose status/package/coach state is bookable and +whose check-in cutoff has not yet passed. This does not check seat-level availability — a date +can be marked available and still turn out fully booked when actually searched.`, + }) + @ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' }) + getAvailableDates(@Query() dto: AvailableDatesQueryDto) { + return this.service.getAvailableDates(dto); + } + @Get('fare-breakdown') @ApiOperation({ summary: 'Per-passenger fare breakdown for booking review page', diff --git a/apps/edr-passenger-api/src/modules/search/search.dto.ts b/apps/edr-passenger-api/src/modules/search/search.dto.ts index cfb1075ca..1c99ce418 100644 --- a/apps/edr-passenger-api/src/modules/search/search.dto.ts +++ b/apps/edr-passenger-api/src/modules/search/search.dto.ts @@ -29,6 +29,20 @@ export class SearchTripsDto { @IsOptional() @IsDateString() returnDate?: string; } +export class AvailableDatesQueryDto { + @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' }) + @IsDateString() from: string; + + @ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' }) + @IsDateString() to: string; +} + export class FareQuoteDto { @ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' }) @IsString() scheduleId: string; diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 9c368a976..de07f5d6a 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -5,6 +5,7 @@ import { FareQuoteDto, FareBreakdownRequestDto, FareBreakdownPassengerDto, + AvailableDatesQueryDto, } from "./search.dto"; import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; @@ -383,16 +384,9 @@ export class SearchService { // 1. Does any active route connect these two stations, in this direction, at all — // ignoring date entirely? - const candidateRoutes = await this.prisma.route.findMany({ - where: { active: true, stops: { some: { stationId: originStationId } } }, - select: { stops: { select: { stationId: true, sequence: true } } }, - }); - const routeExists = candidateRoutes.some((r) => { - const o = r.stops.find((s) => s.stationId === originStationId); - const d = r.stops.find((s) => s.stationId === destinationStationId); - return !!o && !!d && o.sequence < d.sequence; - }); - if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return withCode(Passenger.SearchEmptyReasonCode.NoRoute); + } // 2. A route exists — is there any schedule at all on the requested date for this pair // (regardless of status/package/coach/cutoff — those are checked next)? @@ -425,12 +419,7 @@ export class SearchService { // 3. Schedules exist that date — narrow to ones that would otherwise be bookable // (right status, not package-only, has at least one coach assigned). - const bookable = sameDayForPair.filter( - (s) => - (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && - !s.isPackageOnly && - s.coachAssignments.length > 0, - ); + const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s)); if (bookable.length === 0) { if (sameDayForPair.every((s) => s.status === "CANCELLED")) return withCode(Passenger.SearchEmptyReasonCode.Cancelled); @@ -452,6 +441,110 @@ export class SearchService { return withCode(Passenger.SearchEmptyReasonCode.FullyBooked); } + /** + * Whether any active route connects originStationId → destinationStationId in this + * direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and + * getAvailableDates. + */ + private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise { + const candidateRoutes = await this.prisma.route.findMany({ + where: { active: true, stops: { some: { stationId: originStationId } } }, + select: { stops: { select: { stationId: true, sequence: true } } }, + }); + return candidateRoutes.some((r) => { + const o = r.stops.find((s) => s.stationId === originStationId); + const d = r.stops.find((s) => s.stationId === destinationStationId); + return !!o && !!d && o.sequence < d.sequence; + }); + } + + /** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */ + private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean { + return ( + (["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) && + !s.isPackageOnly && + s.coachAssignments.length > 0 + ); + } + + private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90; + private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000; + private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000; + + /** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */ + private toAddisDateStr(d: Date): string { + return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10); + } + + /** + * For each date in the (server-clamped) range, whether at least one bookable schedule exists + * for originStationId → destinationStationId — used to disable schedule-less dates on the + * search date picker before the user submits a search. Reuses the same route-existence and + * bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used + * throughout this service, but does not compute seat-level availability (see buildScheduleResult) + * — a date can be marked available and still turn out fully booked when actually searched. + */ + async getAvailableDates(dto: AvailableDatesQueryDto) { + const { originStationId, destinationStationId } = dto; + + const todayStr = this.toAddisDateStr(new Date()); + const from = dto.from > todayStr ? dto.from : todayStr; + const fromDate = new Date(`${from}T00:00:00+03:00`); + + const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS); + const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`); + const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate; + const to = this.toAddisDateStr(toDate); + + if (!(await this.routeExistsForPair(originStationId, destinationStationId))) { + return { + originStationId, + destinationStationId, + from, + to, + routeExists: false, + dates: [] as { date: string; available: boolean }[], + }; + } + + const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS); + const schedules = await this.prisma.trainSchedule.findMany({ + where: { + departureAt: { gte: fromDate, lt: rangeEnd }, + stopTimes: { some: { stationId: originStationId } }, + }, + select: { + departureAt: true, + status: true, + isPackageOnly: true, + route: { + select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } }, + }, + stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + coachAssignments: { select: { id: true } }, + }, + }); + + const now = Date.now(); + const availableDays = new Set(); + for (const s of schedules) { + const originStop = s.stopTimes.find((st) => st.stationId === originStationId); + const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId); + if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue; + if (!this.isBookableSchedule(s)) continue; + if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue; + availableDays.add(this.toAddisDateStr(s.departureAt)); + } + + const dates: { date: string; available: boolean }[] = []; + for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) { + const dateStr = this.toAddisDateStr(cursor); + dates.push({ date: dateStr, available: availableDays.has(dateStr) }); + } + + return { originStationId, destinationStationId, from, to, routeExists: true, dates }; + } + // ── Transit search ───────────────────────────────────────────────────────── private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 79773a712..77e13740a 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -45,13 +45,16 @@ export class SeatsService { }); const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id)); - const effectiveStatuses = await this.resolveEffectiveStatuses( - scheduleId, - allSeatIds, - originStationId ?? schedule.originStationId, - destinationStationId ?? schedule.destinationStationId, - journeyDirection - ); + const [effectiveStatuses, reservations] = await Promise.all([ + this.resolveEffectiveStatuses( + scheduleId, + allSeatIds, + originStationId ?? schedule.originStationId, + destinationStationId ?? schedule.destinationStationId, + journeyDirection + ), + this.resolveActiveReservations(allSeatIds, scheduleId), + ]); return { coaches: assignments.map((a) => { @@ -70,6 +73,7 @@ export class SeatsService { ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); + const reservation = reservations.get(s.id); return { id: s.id, seatNumber: s.seatNumber, @@ -88,6 +92,15 @@ export class SeatsService { position: this.colToPosition(s.col, a.coach.arrangement), bed_type: this.bedPositionToType(resolvedBedPosition), } : {}), + // Backoffice-issued reservation covering this seat, if any — lets staff see who's + // paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without + // leaving the seat map. See resolveActiveReservations. + ...(reservation ? { + bookingRef: reservation.bookingRef, + reservationStatus: reservation.status, + reservationPassengerName: reservation.passengerName, + reservationContactPhone: reservation.contactPhone, + } : {}), }; }); @@ -259,6 +272,46 @@ export class SeatsService { return statusMap; } + /** + * Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on + * this schedule — a booking created via GuestBookingService.issueBookingFromReservation + * (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet + * paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the + * backoffice seat map so staff can see who's paying/ticketed for a given seat without + * looking it up separately. + */ + private async resolveActiveReservations( + seatIds: string[], + scheduleId: string, + ): Promise> { + const map = new Map(); + if (seatIds.length === 0) return map; + + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + seatId: { in: seatIds }, + scheduleId, + booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } }, + }, + select: { + seatId: true, + passengerName: true, + booking: { select: { bookingRef: true, status: true, contactPhone: true } }, + }, + }); + + for (const bs of bookingSeats) { + if (!bs.seatId) continue; + map.set(bs.seatId, { + bookingRef: bs.booking.bookingRef, + status: bs.booking.status, + passengerName: bs.passengerName, + contactPhone: bs.booking.contactPhone, + }); + } + return map; + } + async holdSeats(dto: HoldSeatsDto) { const passengerIds = dto.passengers.map(p => p.passengerId); const seatIds = dto.passengers.map(p => p.seatId); @@ -295,10 +348,9 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - // Departure basis: plannedDepartureAt = arrival + dwell. For the origin there is no - // arrival so plannedDepartureAt = schedule.departureAt. cutoffAt = departure - dwell = arrival, - // so holding closes the moment the train reaches the boarding stop. - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? originStopTime?.plannedArrivalAt ?? schedule.departureAt; + // Arrival basis: the origin stop's own estimated arrival, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its departure. + const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( diff --git a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts index 75f822289..5d63d7a06 100644 --- a/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts +++ b/apps/edr-passenger-api/test/reserve-seat-issue-booking.e2e-spec.ts @@ -26,6 +26,7 @@ import { validateSync } from "class-validator"; import { plainToInstance } from "class-transformer"; import { SchedulesService } from "../src/modules/schedules/schedules.service"; import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; import { TicketsService } from "../src/modules/tickets/tickets.service"; import { PaymentsService } from "../src/modules/payments/payments.service"; import { CurrencyService } from "../src/modules/currency/currency.service"; @@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config. import { BookingsService } from "../src/modules/bookings/bookings.service"; import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto"; +import { NotificationsService } from "../src/modules/notifications/notifications.service"; import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; @@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { let seatsService: SeatsService; let guestBookingService: GuestBookingService; let bookingsService: BookingsService; + let notificationsService: NotificationsService; let smsClient: { sendSms: jest.Mock }; + let emailClient: { sendEmail: jest.Mock }; beforeAll(async () => { harness = await createServiceHarness(); @@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { const fareEngine = harness.moduleRef.get(FareEngineService); const systemConfig = new SystemConfigService(harness.prisma as any); - seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + // Real SegmentsService (not asyncStub) — the getSeatMap test below exercises + // resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs + // an actual Map back, not asyncStub's `async () => undefined`. + const segmentsService = new SegmentsService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub()); const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); const paymentsService = new PaymentsService( harness.prisma as any, @@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { harness.prisma as any, asyncStub(), // dataSource seatsService, - { emit: () => true } as any, + ticketsService, + { emit: () => true } as any, // eventEmitter asyncStub(), // verifaydaService currencyService, fareEngine, asyncStub(), // auditService ); + + emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) }; + // Same smsClient instance guestBookingService uses — lets the notification-suppression + // test assert on ONE shared call count across both services, proving the reservation + // flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking. + notificationsService = new NotificationsService( + harness.prisma as any, + asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups, + // never hit by these guest-passenger-id-keyed test bookings + emailClient as any, + smsClient as any, + asyncStub(), // pushAdapter + ); }); afterAll(async () => { @@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { beforeEach(() => { smsClient.sendSms.mockClear(); + emailClient.sendEmail.mockClear(); }); /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */ @@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { expect(byToken.schedule.origin.id).toBe(IDS.stationA); }); + it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => { + // Regression for: the customer got TWO conflicting messages for one reservation — + // the reservation-specific /reserve/pay/ SMS from issueBookingFromReservation, + // AND a second, generic booking.created notification pointing at /booking/detail?ref=, + // a page that doesn't work for a traveler with no portal session. + await resetAndSeedCore(harness.prisma); + await harness.prisma.notificationTemplate.upsert({ + where: { code: "booking.created" }, + update: { active: true }, + create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true }, + }); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-5", + ); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS + + // Directly invoke the event handler (the test harness's eventEmitter is a stub, so the + // real 'booking.created' emit from issueBookingFromReservation never reaches it) — this + // is what NotificationsService would have done had it received that event. + await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped + expect(emailClient.sendEmail).not.toHaveBeenCalled(); + + // Control: a normal (non-reservation) booking must still get the generic notification. + const passenger = await harness.prisma.passenger.create({ data: {} }); + const normalBooking = await harness.prisma.booking.create({ + data: { + bookingRef: `WEB-CTRL-${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + status: "PENDING_PAYMENT", + totalMinor: 10_000, + contactPhone: "+251911234567", + source: "WEB", + }, + }); + await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } }); + expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings + }); + it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => { // Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats() // was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and @@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => { ).rejects.toThrow(/already (held|booked)/i); }); + it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const result: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-7", + ); + const payToken = result.booking.payToken; + + const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7"); + expect(cancelResult.cancelled).toBe(true); + expect(cancelResult.bookingRef).toBe(result.booking.bookingRef); + + const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } }); + expect(cancelledBooking?.status).toBe("CANCELLED"); + + // The seat is genuinely free — a member of the public can now hold it. + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "someone-else", seatId: seats[0].id }], + } as any), + ).resolves.toBeTruthy(); + + // The old payment link no longer works. + await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i); + }); + + it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + await expect( + bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"), + ).rejects.toThrow(/no pending reservation/i); + }); + + it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => { + await resetAndSeedCore(harness.prisma); + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + // Seat 0: PASSENGER reservation — still PENDING_PAYMENT. + await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id); + const pending: any = await guestBookingService.issueBookingFromReservation( + seats[0].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any, + "staff-user-6", + ); + + // Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately. + await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id); + const staffResult: any = await guestBookingService.issueBookingFromReservation( + seats[1].id, + baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any, + "staff-user-6", + ); + + const seatMap: any = await seatsService.getSeatMap(schedule.id); + const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []); + const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id); + const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id); + + expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef); + expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT"); + expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock + + expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef); + expect(ticketedSeat.reservationStatus).toBe("CONFIRMED"); + }); + it("requires a phone number for a PASSENGER booking", async () => { await resetAndSeedCore(harness.prisma); const dep = new Date(Date.now() + 3 * 60 * 60_000); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 17a31832a..b2c63e885 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, RefreshCw } from 'lucide-react'; +import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; import ActionButton from '@/components/ui/ActionButton'; import Modal from '@/components/ui/Modal'; @@ -28,6 +28,7 @@ interface Schedule { destinationStation?: { id: string; name: string }; coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; isPackageOnly?: boolean; + liveStatus?: { delayMinutes: number } | null; } interface Train { @@ -210,13 +211,6 @@ export default function SchedulesPage() { }, }); - const recalculateStopsMutation = useMutation({ - mutationFn: (id: string) => apiClient.post(`/schedules/${id}/recalculate-stops`, {}), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); - }, - }); - const deleteScheduleMutation = useMutation({ mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), onSuccess: () => { @@ -461,6 +455,16 @@ export default function SchedulesPage() { {formatDateTime(schedule.arrivalAt)} ), }, + { + key: 'liveStatus.delayMinutes', + label: 'Delay', + sortable: true, + render: (schedule: Schedule) => { + const delay = schedule.liveStatus?.delayMinutes ?? 0; + if (delay <= 0) return On time; + return +{delay} min; + }, + }, { key: 'coachAssignments', label: 'Coaches', @@ -493,6 +497,15 @@ export default function SchedulesPage() { const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const applyDelayMutation = useMutation({ + mutationFn: ({ id, minutes }: { id: string; minutes: number }) => + apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + }); + const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [delayMinutesInput, setDelayMinutesInput] = useState(''); + const [delayError, setDelayError] = useState(null); + const scheduleActions = [ { label: 'Edit', @@ -500,6 +513,17 @@ export default function SchedulesPage() { variant: 'secondary' as const, icon: Edit, }, + { + label: 'Report Delay', + onClick: (schedule: Schedule) => { + setDelayMinutesInput(''); + setDelayError(null); + setDelayPrompt({ isOpen: true, item: schedule }); + }, + variant: 'secondary' as const, + icon: Clock, + hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + }, { label: 'Cancel', onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), @@ -660,6 +684,67 @@ export default function SchedulesPage() { isLoading={cancelScheduleMutation.isPending} /> + setDelayPrompt({ isOpen: false, item: null })} + title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`} + size="sm" + > + {delayPrompt.item && ( +
{ + e.preventDefault(); + const minutes = parseInt(delayMinutesInput, 10); + if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; } + try { + await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes }); + setDelayPrompt({ isOpen: false, item: null }); + } catch (err: any) { + setDelayError(err?.response?.data?.message || 'Failed to apply delay.'); + } + }} + className="space-y-4" + > + {delayError && ( +
{delayError}
+ )} +
+ Current reported delay + {(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? ( + +{delayPrompt.item.liveStatus?.delayMinutes} min + ) : ( + On time + )} +
+
+ + setDelayMinutesInput(e.target.value)} + placeholder="e.g. 60" + className="input" + required + autoFocus + /> +

+ Adds to the current reported delay above and pushes every downstream station's + check-in cutoff back by this many minutes. Use a negative number to correct an + over-reported delay. +

+
+
+ setDelayPrompt({ isOpen: false, item: null })}> + Cancel + + + Apply Delay + +
+ + )} +
+ setDeleteConfirm({ isOpen: false, item: null })} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index 3ce651ac8..092e26309 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -39,7 +39,7 @@ export default function SeatsPage() { phone: '', email: '', }); - const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null); + const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null); const queryClient = useQueryClient(); const { data: schedulesData } = useQuery({ @@ -126,13 +126,19 @@ export default function SeatsPage() { bookingsApi.issueFromReservation(seatId, data), onSuccess: (result: any) => { invalidateSeatData(); - setIssueBookingResult({ payUrl: result?.payUrl }); - if (!result?.payUrl) { - // STAFF booking — nothing further to show the admin, close immediately. - setShowIssueBookingModal(false); - setSelectedSeat(null); - setIssueBookingCoach(null); - } + // Always show the reference — the PASSENGER path also needs the PNR alongside the + // pay link (staff need to know which booking a seat belongs to, whether it's + // awaiting payment or already ticketed), so no longer auto-closing for STAFF. + setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef }); + }, + }); + + // Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat — + // distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved). + const cancelReservationMutation = useMutation({ + mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule), + onSuccess: () => { + invalidateSeatData(); }, }); @@ -222,6 +228,16 @@ export default function SeatsPage() { } }; + // Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already + // released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking + // invalidates its payment link immediately, so warn staff explicitly about that. + const handleCancelReservation = async (seat: any) => { + if (!selectedSchedule) return; + if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) { + await cancelReservationMutation.mutateAsync(seat.id); + } + }; + const handleIssueBooking = (seat: any, coach: any) => { if (activeTab !== 'schedule' || !selectedSchedule) { alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.'); @@ -433,6 +449,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -529,6 +546,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -552,6 +570,7 @@ export default function SeatsPage() { handleBlock={handleBlock} handleRemoveSeat={handleRemoveSeat} handleUnblock={handleUnblock} + handleCancelReservation={handleCancelReservation} handleUndoRemove={handleUndoRemove} handleSetMaintenance={handleSetMaintenance} handleClearMaintenance={handleClearMaintenance} @@ -888,12 +907,25 @@ export default function SeatsPage() { title="Issue Booking" size="md" > - {issueBookingResult?.payUrl ? ( + {issueBookingResult ? (

- Booking created. A payment link has been sent via SMS to the traveler. + {issueBookingResult.payUrl + ? 'Booking created. A payment link has been sent via SMS to the traveler.' + : 'Booking confirmed and ticketed.'}

-
{issueBookingResult.payUrl}
+ {issueBookingResult.bookingRef && ( +
+ +
{issueBookingResult.bookingRef}
+
+ )} + {issueBookingResult.payUrl && ( +
+ +
{issueBookingResult.payUrl}
+
+ )}
{ @@ -1231,6 +1263,7 @@ interface SeatIconProps { handleBlock: (seat: any) => void; handleRemoveSeat: (seat: any) => void; handleUnblock: (seat: any) => void; + handleCancelReservation: (seat: any) => void; handleUndoRemove: (seat: any) => void; handleSetMaintenance: (seat: any) => void; handleClearMaintenance: (seat: any) => void; @@ -1248,6 +1281,7 @@ function SeatIcon({ handleBlock, handleRemoveSeat, handleUnblock, + handleCancelReservation, handleUndoRemove, handleSetMaintenance, handleClearMaintenance, @@ -1285,6 +1319,10 @@ function SeatIcon({ const color = getSeatColor(status); const canBlock = status === 'AVAILABLE'; const canUnblock = status === 'BLOCKED'; + // A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting + // payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so + // it's not reachable via canUnblock anymore; this is the seat's own release path. + const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT'; const canMaintenance = false; const canClearMaintenance = status === 'UNDER_MAINTENANCE'; @@ -1299,7 +1337,7 @@ function SeatIcon({ {isBedCoach ? (
@@ -1307,14 +1345,23 @@ function SeatIcon({ ) : (
)} - {(canBlock || canUnblock || canMaintenance || canClearMaintenance) && ( + {seat.bookingRef && ( + + {seat.bookingRef} + + )} + + {(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
{canBlock && ( <> @@ -1334,6 +1381,15 @@ function SeatIcon({ )} + {canCancelReservation && ( + + )} {canUnblock && ( <>