From b45d180ee41d477048577c187316ab9a5b2eb184 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 6 Jul 2026 15:01:55 +0300 Subject: [PATCH 1/6] fix: ( pnpm ) lock file --- apps/edr-passenger-api/package.json | 3 +- pnpm-lock.yaml | 46 ++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/edr-passenger-api/package.json b/apps/edr-passenger-api/package.json index b3d77d220..dd8058d7d 100644 --- a/apps/edr-passenger-api/package.json +++ b/apps/edr-passenger-api/package.json @@ -46,6 +46,7 @@ "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "dev": "^0.1.5", "dotenv": "^17.4.2", "express": "^4.18.2", "helmet": "^8.0.0", @@ -66,8 +67,8 @@ "@nestjs/schematics": "^11.1.0", "@nestjs/testing": "^11.1.19", "@types/express": "^4.17.21", - "@types/luxon": "^3.7.1", "@types/jest": "^29.5.11", + "@types/luxon": "^3.7.1", "@types/node": "^20.10.6", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e278995a..15ccf0e7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -537,6 +537,9 @@ importers: class-validator: specifier: ^0.14.0 version: 0.14.4 + dev: + specifier: ^0.1.5 + version: 0.1.5 dotenv: specifier: ^17.4.2 version: 17.4.2 @@ -5339,6 +5342,9 @@ packages: binary@0.3.0: resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -6142,6 +6148,10 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev@0.1.5: + resolution: {integrity: sha512-Fix+RToMKpGzEps6m/2HaHQrbp9iGo32hU41Q3LKC+zRy8R03jeu5IAv3S0ZuLXdJy7g8avCayINJMBLXT+5/A==} + hasBin: true + devtools-protocol@0.0.1608973: resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} @@ -6766,6 +6776,9 @@ packages: resolution: {integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==} engines: {node: '>=20'} + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + fill-range@4.0.0: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} engines: {node: '>=0.10.0'} @@ -7329,6 +7342,11 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inotify@1.4.6: + resolution: {integrity: sha512-WW8/uqIA04O3AePQVe/Ms3ZLR0yGamaz8YOEpaXc4WBAGOPZfzu58wWErEPSUYaPyDrJRIeCn6PEIQgC1ZyQ5w==} + engines: {node: '>=0.8'} + os: [linux] + input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: @@ -8595,6 +8613,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -15461,9 +15482,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15584,9 +15605,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -17029,6 +17050,10 @@ snapshots: buffers: 0.1.1 chainsaw: 0.1.0 + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -17837,6 +17862,10 @@ snapshots: detect-node-es@1.1.0: {} + dev@0.1.5: + dependencies: + inotify: 1.4.6 + devtools-protocol@0.0.1608973: {} dezalgo@1.0.4: @@ -18737,6 +18766,8 @@ snapshots: transitivePeerDependencies: - supports-color + file-uri-to-path@1.0.0: {} + fill-range@4.0.0: dependencies: extend-shallow: 2.0.1 @@ -19357,6 +19388,11 @@ snapshots: ini@4.1.1: {} + inotify@1.4.6: + dependencies: + bindings: 1.5.0 + nan: 2.28.0 + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: prop-types: 15.8.1 @@ -20781,6 +20817,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.28.0: {} + nanoid@3.3.12: {} nanomatch@1.2.13: From fe9503638bc15bc60cca98ddde9b8a042a958fe6 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 6 Jul 2026 12:27:58 +0000 Subject: [PATCH 2/6] separte handover edr lastmile and customer last mile flow --- .../1980000000000-AddBookingHandovers.ts | 47 +++++++ .../entities/booking-handover.entity.ts | 46 +++++++ .../modules/warehouses/handover.service.ts | 123 ++++++++++++++++++ .../warehouse-inventory.controller.ts | 8 ++ .../warehouses/warehouse-inventory.service.ts | 77 ++++++++++- .../modules/warehouses/warehouses.module.ts | 4 + .../warehouses/ReceiveInventoryModal.tsx | 2 +- .../warehouses/ReleaseOrderModal.tsx | 61 ++++++++- .../src/services/warehouse.service.ts | 8 ++ 9 files changed, 367 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/handover.service.ts diff --git a/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts new file mode 100644 index 000000000..a47ff6297 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1980000000000-AddBookingHandovers.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Structured import handover records. Replaces the ad-hoc handover notes so a + * booking can carry one handover (single truck) or several (one per truck when + * multiple trucks are used). Timing differs by mile type: + * - SELF_HAUL: generated on first truck arrival, signed before the truck leaves. + * - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery. + */ +export class AddBookingHandovers1980000000000 implements MigrationInterface { + name = 'AddBookingHandovers1980000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.booking_handovers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE, + truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL, + truck_plate varchar(32), + mile_type varchar(20) NOT NULL, + reference varchar(100) NOT NULL, + generated_at timestamptz NOT NULL DEFAULT now(), + signed_at timestamptz, + signed_by_user_id uuid, + delivered_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`, + ); + // At most one live handover per (booking, customer truck). EDR trucks (which + // aren't customer_truck_assignments) and per-booking handovers are de-duped + // in the service, since a NULL truck_assignment_id can't be uniquely indexed. + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck" + ON freight.booking_handovers (booking_id, truck_assignment_id) + WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts new file mode 100644 index 000000000..f5a730ea8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const; +export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number]; + +/** + * One import handover. A booking has a single handover when one truck takes the + * whole booking (`truckAssignmentId` null = per-booking), or one per truck when + * multiple trucks are used. Self-haul handovers are generated on truck arrival + * and signed before the truck leaves; EDR last-mile handovers are generated at + * delivery (after exit). + */ +@Entity({ schema: 'freight', name: 'booking_handovers' }) +@Index(['bookingId']) +export class BookingHandover extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + /** Customer self-haul truck this handover belongs to; null = per-booking. */ + @Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true }) + truckAssignmentId?: string | null; + + /** Denormalised plate for display / EDR trucks (which aren't customer trucks). */ + @Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true }) + truckPlate?: string | null; + + @Column({ name: 'mile_type', type: 'varchar', length: 20 }) + mileType!: HandoverMileType; + + @Column({ name: 'reference', type: 'varchar', length: 100 }) + reference!: string; + + @Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' }) + generatedAt!: Date; + + @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) + signedAt?: Date | null; + + @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true }) + signedByUserId?: string | null; + + /** EDR last-mile: when the goods were delivered to the customer. */ + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts new file mode 100644 index 000000000..52bd318e1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, EntityManager, IsNull } from 'typeorm'; + +import { BookingHandover } from './entities/booking-handover.entity'; + +/** + * Import handover records. A booking has one handover per truck (single truck ⇒ + * one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type: + * - SELF_HAUL: generated when the customer truck arrives, signed before it leaves. + * - EDR_LAST_MILE: generated at delivery (after exit). + */ +@Injectable() +export class HandoverService { + private readonly logger = new Logger(HandoverService.name); + + constructor(private readonly dataSource: DataSource) {} + + list(bookingId: string): Promise { + return this.dataSource.getRepository(BookingHandover).find({ + where: { bookingId }, + order: { generatedAt: 'ASC' }, + }); + } + + /** + * Self-haul: ensure a handover exists for a customer truck that just arrived. + * Idempotent — one per (booking, truck). Runs inside the caller's transaction + * when a manager is supplied. + */ + async ensureForArrivedTruck( + bookingId: string, + opts: { truckAssignmentId?: string | null; truckPlate?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await repo.findOne({ + where: { + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? IsNull(), + }, + }); + if (existing) return existing; + + const reference = await this.generateReference(bookingId, m); + const saved = await repo.save( + repo.create({ + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'SELF_HAUL', + reference, + generatedAt: new Date(), + }), + ); + this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`); + return saved; + } + + /** + * EDR last-mile: generate a handover at delivery (after exit). One per EDR + * truck (by plate) or per booking. Idempotent by (booking, plate). + */ + async ensureAtDelivery( + bookingId: string, + opts: { truckPlate?: string | null; truckAssignmentId?: string | null }, + manager?: EntityManager, + ): Promise { + const m = manager ?? this.dataSource.manager; + const repo = m.getRepository(BookingHandover); + const existing = await repo.findOne({ + where: { + bookingId, + truckPlate: opts.truckPlate ?? IsNull(), + truckAssignmentId: opts.truckAssignmentId ?? IsNull(), + }, + }); + if (existing) return existing; + + const reference = await this.generateReference(bookingId, m); + return repo.save( + repo.create({ + bookingId, + truckAssignmentId: opts.truckAssignmentId ?? null, + truckPlate: opts.truckPlate ?? null, + mileType: 'EDR_LAST_MILE', + reference, + generatedAt: new Date(), + deliveredAt: new Date(), + }), + ); + } + + /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ + async signForBooking(bookingId: string, userId?: string | null): Promise { + await this.dataSource + .getRepository(BookingHandover) + .update( + { bookingId, signedAt: IsNull() }, + { signedAt: new Date(), signedByUserId: userId ?? null }, + ); + } + + /** True when every handover on the booking is signed (and at least one exists). */ + async isFullySigned(bookingId: string): Promise { + const repo = this.dataSource.getRepository(BookingHandover); + const [total, unsigned] = await Promise.all([ + repo.count({ where: { bookingId } }), + repo.count({ where: { bookingId, signedAt: IsNull() } }), + ]); + return total > 0 && unsigned === 0; + } + + private async generateReference(bookingId: string, manager: EntityManager): Promise { + const [booking] = await manager.query( + `SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, ''); + const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } }); + return `HND-${ref}-${String(count + 1).padStart(2, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b30dad1e..aab2fccbb 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseInventoryService } from './warehouse-inventory.service'; +import { HandoverService } from './handover.service'; @ApiTags('warehouse-inventory') @ApiBearerAuth() @@ -23,6 +24,7 @@ export class WarehouseInventoryController { constructor( private readonly inventoryService: WarehouseInventoryService, private readonly scheduling: SchedulingReadFacade, + private readonly handoverService: HandoverService, ) {} @Get() @@ -312,6 +314,12 @@ export class WarehouseInventoryController { return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); } + @Get('bookings/:bookingId/handovers') + @ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' }) + bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.handoverService.list(bookingId); + } + @Post(':id/deliver') @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { 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 c9844051b..b16454f9d 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 @@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; +import { HandoverService } from './handover.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; @@ -342,6 +343,7 @@ export class WarehouseInventoryService { private readonly lastMileService: LastMileService, private readonly notifications: NotificationsService, private readonly signatures: SignaturesService, + private readonly handover: HandoverService, ) {} /** @@ -2080,9 +2082,14 @@ export class WarehouseInventoryService { [item.bookingId], ); const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); - if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { + // Self-haul: the handover must be signed before the exit paper is issued. + // Prefer the structured handover record; fall back to the legacy note. + const handoverSigned = + (await this.handover.isFullySigned(item.bookingId)) || + Boolean(this.extractCustomerDeliveryApproval(item.notes)); + if (usesCustomerTruck && !handoverSigned) { throw new BadRequestException( - 'Customer must approve delivery (sign the handover) before the exit paper can be generated', + 'Customer must sign the handover before the exit paper can be generated', ); } } @@ -2137,6 +2144,16 @@ export class WarehouseInventoryService { AND deleted_at IS NULL`, [item.bookingId], ); + // Self-haul: generate the per-booking handover on first truck arrival + // (idempotent). It must be signed before the truck leaves. + const [selfHaul]: Array<{ ok: number }> = await manager.query( + `SELECT 1 AS ok FROM freight.bookings + WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`, + [item.bookingId], + ); + if (selfHaul) { + await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager); + } } await this.activityLog.record( { @@ -2388,7 +2405,9 @@ export class WarehouseInventoryService { return { filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Generic render — NOT the release-order fallback (would mislabel the GRN + // as a "Gate Clearance / Release Order" when Chromium is unavailable). + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Goods Received Note'), }; } @@ -2462,6 +2481,10 @@ export class WarehouseInventoryService { ); }); + // Sign the structured handover record(s) for this booking (self-haul: before + // the truck leaves). Kept alongside the legacy approval note. + await this.handover.signForBooking(bookingId, userId); + return { bookingId, inventoryId: item.id, @@ -2589,7 +2612,9 @@ export class WarehouseInventoryService { return { filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.releaseDocuments.htmlToPdfBuffer(html), + // Generic render — NOT the release-order fallback (would mislabel the + // handover as a "Gate Clearance / Release Order" when Chromium is down). + buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Import Goods Handover'), }; } @@ -2601,6 +2626,29 @@ export class WarehouseInventoryService { throw new BadRequestException('A release order must be issued before the goods can be delivered'); } + // Self-haul: the customer's own truck delivers — deliver only after the + // handover is signed AND the truck has left the warehouse holding the goods. + if (item.bookingId) { + const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query( + `SELECT customer_truck_assigned_at AS "assignedAt" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + if (sh?.assignedAt) { + if (!(await this.handover.isFullySigned(item.bookingId))) { + throw new BadRequestException('Handover must be signed before delivery'); + } + const [left]: Array<{ n: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS n FROM freight.customer_truck_assignments + WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`, + [item.bookingId], + ); + if (Number(left?.n ?? 0) === 0) { + throw new BadRequestException('Deliver is available only after the customer truck has left'); + } + } + } + const receiverName = dto.receiverName.trim(); const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const weight = Number(item.weight) || 0; @@ -2645,6 +2693,27 @@ export class WarehouseInventoryService { }, manager, ); + + // Handover on delivery. EDR last-mile generates its handover HERE (after + // exit, on delivery). Self-haul handovers were generated on arrival — + // stamp them delivered. + if (item.bookingId) { + const [b]: Array<{ selfHaul: string | null }> = await manager.query( + `SELECT customer_truck_assigned_at AS "selfHaul" + FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + if (b?.selfHaul) { + await manager.query( + `UPDATE freight.booking_handovers + SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW() + WHERE booking_id = $1 AND deleted_at IS NULL`, + [item.bookingId], + ); + } else { + await this.handover.ensureAtDelivery(item.bookingId, {}, manager); + } + } }); return this.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index b871d2a36..5f0ce5749 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { BookingHandover } from './entities/booking-handover.entity'; +import { HandoverService } from './handover.service'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; @@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseInspectionReport, WarehouseAllocationRule, WarehouseFeeRule, + BookingHandover, ]), BillingModule, DocumentsModule, @@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service'; WarehouseSchedulingAdapterService, WarehouseReleaseDocumentService, SchedulingReadFacade, + HandoverService, ], exports: [ WarehousesService, 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 32b0b5a5d..2002f3261 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -2421,7 +2421,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) { loading={busyId === r.id} onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} > - Dispatch + Truck_dispatch )} {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx index b962a7393..0471d51c6 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReleaseOrderModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Info, Scale } from 'lucide-react'; -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; import { useToast } from '@/hooks/use-toast'; @@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { const { toast } = useToast(); const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); + const bookingId = item?.booking?.id; + // Customer self-haul trucks assigned to this booking via the portal. + const { data: customerTrucks = [] } = useQuery({ + queryKey: ['release-customer-trucks', bookingId], + queryFn: () => warehouseService.getCustomerTrucks(bookingId as string), + enabled: opened && Boolean(bookingId), + }); const [reference, setReference] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); @@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea const isEntranceLocked = isExitStep; const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); + + // Registered trucks for THIS booking, from both sources: EDR last-mile + // (truckPrefill) and the customer portal (customer_truck_assignments). + const assignedTruckOptions = [ + ...(truckPrefill?.truckPlateNumber + ? [ + { + value: truckPrefill.truckPlateNumber, + label: `Last-mile · ${truckPrefill.truckPlateNumber}`, + trailerPlate: truckPrefill.trailerPlateNumber ?? '', + driverName: truckPrefill.driverName ?? '', + driverPhone: truckPrefill.driverPhone ?? '', + truckType: truckPrefill.truckType ?? '', + }, + ] + : []), + ...customerTrucks.map((t) => ({ + value: t.plateNumber, + label: `Customer · ${t.plateNumber} — ${t.driverName}`, + trailerPlate: '', + driverName: t.driverName, + driverPhone: '', + truckType: t.truckType, + })), + ]; + const truckSelectOptions = [ + ...assignedTruckOptions, + ...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({ + value: t.value, + label: t.label, + trailerPlate: t.trailerPlate, + driverName: '', + driverPhone: '', + truckType: '', + })), + ]; + // Neither a last-mile truck nor a customer truck has been assigned yet. + const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); @@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea onChange={(e) => setReference(e.currentTarget.value)} readOnly={isEntranceLocked} /> + {noTruckAssigned && ( + }> + Truck is not assigned yet — assign a last-mile or customer truck, or enter the plate manually below. + + )} {isOvernight && !is24h ? ( @@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({ color="grape" label="Run 24 hours a day (never pause overnight)" checked={is24h} - disabled={isExport} onChange={(e) => { const checked = e.currentTarget.checked; setForm((f) => { @@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({ }); }} /> - {!isExport ? ( - - A not-yet-full train pauses at the close hour and resumes the next - morning at the open hour, every day until it fills or departs. - - ) : null} + + {isExport + ? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead." + : "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."} + @@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({ {/* ── Lead time ────────────────────────────────────────────────── */} - - setForm( - (f) => - f && { - ...f, - importWindowLeadDays: v === "" ? "" : Number(v), - }, - ) - } - min={0} - clampBehavior="none" - allowDecimal={false} - /> + {isExport ? ( + + setForm( + (f) => + f && { + ...f, + exportBookingLeadHours: v === "" ? "" : Number(v), + }, + ) + } + min={1} + clampBehavior="none" + allowDecimal={false} + /> + ) : ( + + setForm( + (f) => + f && { + ...f, + importWindowLeadDays: v === "" ? "" : Number(v), + }, + ) + } + min={0} + clampBehavior="none" + allowDecimal={false} + /> + )} + + + )} + + {accepted.length > 0 && ( + <> + + On this train + + + + + + Booking + Customer + Corridor + Status + + + + + {accepted.map((row) => ( + + + + {row.reference ?? row.id.slice(0, 8)} + + + + {row.customer} + + + + + + + {row.status} + + + + + {row.status === "PAID" && ( + + + + )} + {row.status === "IN_TRANSIT" && ( + + + + )} + + + + ))} + +
+
+ + )} + + {candidatesQuery.isError && ( + }> + {parseError(candidatesQuery.error, "Could not load intercity candidates")} + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index fbfd2f998..fce620235 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -324,6 +324,14 @@ export const URL_CONSTANTS = { PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, + INTERCITY_CANDIDATES: (id: string) => + `/train-scheduling/schedules/${id}/intercity-candidates`, + INTERCITY_ACCEPT: (id: string) => + `/train-scheduling/schedules/${id}/intercity/accept`, + INTERCITY_LOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/load`, + INTERCITY_UNLOAD: (id: string, bookingId: string) => + `/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`, IMPORT_LOADING_BOOKINGS: (id: string) => `/train-scheduling/schedules/${id}/import-loading-bookings`, IMPORT_LOADING_STATUS: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..375c12f19 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,55 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; +import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live booking-window pushes for staff. Every phase transition + * the window engine applies invalidates the GL windows carousel and the batch + * board, so both flip the moment the backend does — polling stays only as a + * fallback. + */ +export function useBookingWindowSocket(enabled: boolean = true) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getCookie(AUTH_TOKEN_COOKIE); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "all-booking-windows"], + }); + qc.invalidateQueries({ + queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(), + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 249130e61..7ee0f7896 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -48,6 +48,7 @@ import { } from "@/components/trainScheduling/containerPlacement.util"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; +import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; @@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() { void detailQuery.refetch(); }} /> + {scheduleId ? ( + + ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index bc5b7dcb1..f36ce8394 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() { const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); - const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); + // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the + // API rejects them, so keep them out of the picker entirely. + const activeRoutes = useMemo( + () => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"), + [routesQuery.data], + ); const selectedRoute = activeRoutes.find((r) => r.id === routeId); @@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() { } try { const created = await create.mutateAsync({ - payload: { routeId, scheduleDate, locomotiveIds }, + payload: { + routeId, + scheduleDate: new Date(scheduleDate).toISOString(), + locomotiveIds, + }, }); toast({ title: "Train schedule created" }); showScheduleWarnings(created.warnings); @@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() { { - const raw = e.currentTarget.value; - setScheduleDate(raw ? new Date(raw).toISOString() : ""); - }} + value={scheduleDate} + onChange={(e) => setScheduleDate(e.currentTarget.value)} /> TRAIN_SCHEDULING_INVALIDATIONS, ), + intercityCandidates: endpoint< + { scheduleId: string }, + import("@/types/trainScheduling").IntercityCandidatesResult + >( + "train-scheduling", + "intercity-candidates", + ({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId), + ({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId], + ), + + acceptIntercityBookings: endpoint< + { scheduleId: string; bookingIds: string[] }, + import("@/types/trainScheduling").IntercityAcceptResult + >( + "train-scheduling", + "intercity-accept", + ({ scheduleId, bookingIds }) => + trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + loadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-load", + ({ scheduleId, bookingId }) => + trainSchedulingService.loadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + + unloadIntercityBooking: endpoint< + { scheduleId: string; bookingId: string }, + void + >( + "train-scheduling", + "intercity-unload", + ({ scheduleId, bookingId }) => + trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId), + undefined, + () => TRAIN_SCHEDULING_INVALIDATIONS, + ), + cancelSchedule: endpoint< { id: string; freightType?: FreightType }, TrainScheduleDetail diff --git a/apps/edr-freight-web/backoffice/src/services/routes.service.ts b/apps/edr-freight-web/backoffice/src/services/routes.service.ts index 26c340e83..65de13760 100644 --- a/apps/edr-freight-web/backoffice/src/services/routes.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/routes.service.ts @@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS'; export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; +/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */ +export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC'; + export interface YardRef { id: string; code: string; @@ -23,6 +26,7 @@ export interface RouteMilestone { export interface RouteRecord { id: string; status: RouteStatus; + direction?: RouteDirection; originYardId: string; destinationYardId: string; originYard?: YardRef | null; diff --git a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts index 8c969b06d..3f4c6822f 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainScheduling.service.ts @@ -17,6 +17,8 @@ import type { ImportDjiboutiLoadList, ImportDjiboutiOperation, ImportLoadingBookingsResponse, + IntercityAcceptResult, + IntercityCandidatesResult, LoadingStatus, LocomotiveRecord, PinWagonsPayload, @@ -328,6 +330,46 @@ export const trainSchedulingService = { return unwrap(response.data); }, + getIntercityCandidates: async ( + scheduleId: string, + ): Promise => { + const response = await client.get( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId), + ); + return unwrap(response.data); + }, + + acceptIntercityBookings: async ( + scheduleId: string, + bookingIds: string[], + ): Promise => { + const response = await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId), + { bookingIds }, + ); + return unwrap(response.data); + }, + + loadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId), + {}, + ); + }, + + unloadIntercityBooking: async ( + scheduleId: string, + bookingId: string, + ): Promise => { + await client.post( + URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId), + {}, + ); + }, + dispatchSchedule: async ( scheduleId: string, ): Promise => { diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 4807d00fb..68ad375d8 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -407,6 +407,7 @@ export interface ScheduleWindowRule { windowDurationHours: number | null; reopenDelayMinutes: number | null; importWindowLeadDays: number | null; + exportBookingLeadHours: number | null; /** Live global values (not snapshotted per schedule) — editor prefill baseline. */ docReviewMinutes: number; paymentWindowMinutes: number; @@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload { docReviewMinutes?: number; paymentWindowMinutes?: number; importWindowLeadDays?: number; + exportBookingLeadHours?: number; } export interface TrainScheduleDetail { @@ -746,3 +748,49 @@ export interface CompositionRemovalEntry { removedAt: string; notes: string | null; } + +// ── Intercity ride-along ───────────────────────────────────────────────────── +// Intercity (DOMESTIC) bookings have no train of their own — they ride a +// passing import/export schedule whose route milestones contain the booking's +// origin before its destination. Staff accept them at finalize time against +// the train's remaining wagon/weight/length capacity. + +export interface IntercityCapacity { + wagons: number; + weightTons: number; + lengthMeters: number; +} + +export interface IntercityBookingRow { + id: string; + reference: string | null; + status: string; + freightType: FreightType | null; + isGovernment: boolean; + customer: string; + originYardId: string; + destinationYardId: string; + origin: string; + destination: string; + weightTons: number; + paymentDeadline: string | null; + need: IntercityCapacity | null; +} + +export interface IntercityCandidateRow extends IntercityBookingRow { + fits: boolean; +} + +export interface IntercityCandidatesResult { + scheduleId: string; + routeId: string | null; + remaining: IntercityCapacity | null; + candidates: IntercityCandidateRow[]; + accepted: IntercityBookingRow[]; +} + +export interface IntercityAcceptResult { + accepted: string[]; + rejected: Array<{ bookingId: string; reason: string }>; + remaining: IntercityCapacity; +} diff --git a/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts new file mode 100644 index 000000000..17f6174b3 --- /dev/null +++ b/apps/edr-freight-web/portal/src/features/bookingWindows/useBookingWindowSocket.ts @@ -0,0 +1,60 @@ +import { + BOOKING_WINDOW_WS_EVENTS, + BOOKING_WINDOW_WS_NAMESPACE, + type BookingWindowPhaseEvent, +} from "@edr/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { io } from "socket.io-client"; + +import { API_BASE_URL } from "@/constants/apiConfig"; + +function getAuthToken(): string | undefined { + return document.cookie + .split("; ") + .find((row) => row.startsWith("auth-token=")) + ?.split("=")[1]; +} + +// The socket namespace lives at the server root, not under the `/api` REST +// prefix — strip a trailing `/api` if the base URL carries one. +const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, ""); + +/** + * Subscribes to live booking-window pushes. Every phase transition the window + * engine applies (open, doc review, payment, reopen, done) invalidates the + * cached window lists, so the home-page "Booking Windows" card flips the + * moment the backend does — the 60s poll remains only as a fallback. + */ +export function useBookingWindowSocket(enabled: boolean) { + const qc = useQueryClient(); + + useEffect(() => { + if (!enabled) return; + const token = getAuthToken(); + if (!token) return; + + const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, { + auth: { token }, + transports: ["websocket"], + withCredentials: true, + }); + + socket.on( + BOOKING_WINDOW_WS_EVENTS.PHASE, + (_event: BookingWindowPhaseEvent) => { + qc.invalidateQueries({ + queryKey: ["train-scheduling", "myBookingWindows"], + }); + qc.invalidateQueries({ + queryKey: ["train-scheduling", "contractBookingWindows"], + }); + }, + ); + + return () => { + socket.off(); + socket.disconnect(); + }; + }, [enabled, qc]); +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index dc3f6f7be..5e051cce9 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -1,12 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; +import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { const { user, customer, company } = useAuth(); + // Live booking-window pushes: a phase transition on any lane invalidates the + // windows queries below the moment it happens (poll below is only a fallback). + useBookingWindowSocket(Boolean(user)); + const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); const myInvoices = invoicesQuery.data ?? []; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 268e8627a..16225cd02 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -211,7 +211,10 @@ export default function ContractDetailPage() { }), enabled: !!id, }); - const bookingWindowOpen = hasOpenWindow(bookingWindows); + // Intercity contracts are never window-gated: the shipment rides a passing + // import/export train that staff assign later, so booking is always open. + const bookingWindowOpen = + contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows); // Draw-down capacity per cargo line (GENERAL contracts only). The backend // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index a2bc985c0..d064197d5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -128,8 +128,10 @@ export default function NewShipmentPage() { // Coarse gate: if the customer deep-links here while no booking window is // open, show the same closed-state notice as the contract page instead of the - // form. Still allowed the moment any window isOpenNow. - if (!hasOpenWindow(bookingWindows)) { + // form. Still allowed the moment any window isOpenNow. Intercity contracts + // are never window-gated — the shipment rides a passing train that staff + // pick at finalize time, so booking is always open. + if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) { return ( + } + title="Schedule" + description="Intercity shipments have no fixed day." + /> + }> + Your shipment rides the next import/export train passing through your + corridor. Operations assign it to a train with free capacity — you + will be notified when it is accepted and payment is due. + + + ); + } + return ( = [ { value: "import", @@ -46,7 +48,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{ { value: "intercity", label: "Intercity", - description: "Domestic movement between Ethiopian yards.", + description: + "Domestic movement between Ethiopian yards — rides on passing import/export trains, no customs.", }, { value: "import_ff", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx index 49d04533a..6ce2aed5f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step0-operation-type.tsx @@ -30,7 +30,11 @@ export function Step0OperationType({ }) { const data = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); return (
diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index a4ec5aeb8..e37daea6c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -49,7 +49,11 @@ export function Step1ContractType({ const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => allowedOperations.includes(opt.value), - ).map((opt) => ({ value: opt.value, label: opt.label })); + ).map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: opt.disabled ?? false, + })); const previousContractRef = form.watch("previousContractRef"); const [searchQuery, setSearchQuery] = useState(""); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index ac182113f..1403cf1b5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -19,6 +19,11 @@ export interface ShipmentValidationContext { isHazardous: boolean; isReefer: boolean; unitOfMeasure?: "PER_TON" | "PER_ITEM"; + /** + * Intercity (DOMESTIC) shipments ride a passing import/export train that + * staff pick later, so no shipment day is chosen. Defaults to true. + */ + requiresDate?: boolean; } const containerUnitSchema = z.object({ @@ -54,7 +59,7 @@ const shipmentFormBase = z.object({ export function createShipmentFormSchema(ctx: ShipmentValidationContext) { return shipmentFormBase.superRefine((data, refineCtx) => { - if (!data.scheduledDate.trim()) { + if ((ctx.requiresDate ?? true) && !data.scheduledDate.trim()) { refineCtx.addIssue({ code: "custom", path: ["scheduledDate"], diff --git a/packages/types/src/freight/booking-window-ws.ts b/packages/types/src/freight/booking-window-ws.ts new file mode 100644 index 000000000..6c48f2c4c --- /dev/null +++ b/packages/types/src/freight/booking-window-ws.ts @@ -0,0 +1,41 @@ +/** + * Shared contracts for live booking-window pushes. + * + * The freight API emits one event whenever a schedule's booking-window state + * changes (phase transition, open/close, cycle reopen). Portal home and the + * backoffice GL windows section subscribe and refresh instantly instead of + * waiting for their poll interval. + */ + +/** Booking-window lifecycle phase persisted on a train schedule. */ +export type BookingWindowPhase = + | "PRE_WINDOW" + | "OPEN" + | "DOC_REVIEW" + | "PAYMENT" + | "CLOSED_FOR_DAY" + | "DONE"; + +/** Payload pushed on every booking-window state change. */ +export interface BookingWindowPhaseEvent { + scheduleId: string; + originYardId: string; + destinationYardId: string; + direction: string | null; + phase: BookingWindowPhase; + bookingWindowStatus: string | null; + bookingCycleNo: number; + windowOpensAt: string | null; + windowClosesAt: string | null; + docReviewEndsAt: string | null; + paymentPhaseEndsAt: string | null; + scheduledDepartureDate: string | null; +} + +/** Socket.io event names pushed server → client on the booking-windows namespace. */ +export const BOOKING_WINDOW_WS_EVENTS = { + PHASE: "booking-window:phase", +} as const; + +/** Socket.io namespace the booking-window gateway listens on. */ +export const BOOKING_WINDOW_WS_NAMESPACE = "booking-windows"; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 829ae3217..bf2778575 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -695,8 +695,8 @@ export interface CreateBulkLineDto { export interface CreateBookingUnderContractDto { /** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */ contractRouteId?: string; - /** Binding shipment day. */ - scheduledDate: string; + /** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */ + scheduledDate?: string; containers?: CreateBookingContainerLineDto[]; bulkLines?: CreateBulkLineDto[]; notes?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 7c8a3c4cd..0b5752265 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -8,6 +8,7 @@ export * from "./etrade"; export * from "./contracts"; export * from "./clearance-files.catalog"; export * from "./notifications"; +export * from "./booking-window-ws"; export enum TradeDirection { IMPORT = "IMPORT", @@ -220,6 +221,24 @@ export enum WagonReadiness { export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; +/** + * The only two countries on the EDR line. Yard `country` is constrained to + * these values; route trade direction is derived from the origin/destination + * yard countries (ET→DJ = EXPORT, DJ→ET = IMPORT, same-country = DOMESTIC, + * shown as "Intercity" in UIs and currently disabled for scheduling/contracts). + */ +export enum YardCountry { + ETHIOPIA = "Ethiopia", + DJIBOUTI = "Djibouti", +} + +/** UI label for a schedule/route trade direction (DOMESTIC displays as Intercity). */ +export const TRADE_DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Intercity", +}; + export enum TrainCheckpointKind { Departed = "DEPARTED", Passed = "PASSED", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e278995a..fadeaca56 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15461,9 +15461,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -15584,9 +15584,9 @@ snapshots: dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)