diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 2bee84923..72a5952d7 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -53,23 +53,23 @@ import { } from "./seed/edr-freight.seed"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; import { FreightPositionsSeeder } from "./seed/freight-positions.seeder"; -import { DemoUsersSeeder } from "./seed/demo-users.seeder"; -import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; +// Disabled seeds — imports commented out with their provider/injection/run below. +// import { DemoUsersSeeder } from "./seed/demo-users.seeder"; +// import { FreightStaffUsersSeeder } from "./seed/freight-staff-users.seeder"; import { PaymentModule } from "./modules/payment/payment.module"; -import { PricingDataSeeder } from "./seed/pricing-data.seeder"; +// import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; -import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; -import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; -import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; -import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; -import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; -import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; -import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; -import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; +// import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder"; +// import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder"; +// import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder"; +// import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder"; +// import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder"; +// import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder"; +// import { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder"; +// import { MarshallingDemoTrainsSeeder } from "./seed/marshalling-demo-trains.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; -import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; -import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; -import { EdrTruckFleetSeeder } from "./seed/edr-truck-fleet.seeder"; +// import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; +// import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules @@ -195,22 +195,22 @@ import { LoggerMiddleware } from "./logger.middleware"; providers: [ EdrOrgSeeder, FreightPositionsSeeder, - DemoUsersSeeder, - FreightStaffUsersSeeder, - PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, - DemoFreightDataSeeder, - GovCompaniesSeeder, - EdrTruckFleetSeeder, - IndodeFacilitySeeder, - Batch14TestDataSeeder, - Batch5TestDataSeeder, - Batch7TestDataSeeder, - Batch8TestDataSeeder, - WarehouseDemoSeeder, - ExportDjiboutiInterchangeDemoSeeder, - MarshallingDemoTrainsSeeder, + // Disabled seeds — providers commented out (imports/injection/run too): + // DemoUsersSeeder, + // FreightStaffUsersSeeder, + // PricingDataSeeder, + // DemoFreightDataSeeder, + // GovCompaniesSeeder, + // IndodeFacilitySeeder, + // Batch14TestDataSeeder, + // Batch5TestDataSeeder, + // Batch7TestDataSeeder, + // Batch8TestDataSeeder, + // WarehouseDemoSeeder, + // ExportDjiboutiInterchangeDemoSeeder, + // MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, PaidImportExportMileDemoSeeder, ], @@ -220,53 +220,66 @@ export class AppModule implements OnApplicationBootstrap { private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, - private readonly demoUsersSeeder: DemoUsersSeeder, - private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, - private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, - private readonly indodeFacilitySeeder: IndodeFacilitySeeder, - private readonly batch14TestDataSeeder: Batch14TestDataSeeder, - private readonly batch5TestDataSeeder: Batch5TestDataSeeder, - private readonly batch7TestDataSeeder: Batch7TestDataSeeder, - private readonly batch8TestDataSeeder: Batch8TestDataSeeder, - private readonly warehouseDemoSeeder: WarehouseDemoSeeder, - private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, - private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, - private readonly demoFreightDataSeeder: DemoFreightDataSeeder, - private readonly govCompaniesSeeder: GovCompaniesSeeder, - private readonly edrTruckFleetSeeder: EdrTruckFleetSeeder, + // Disabled seeds — injections commented out (imports/provider/run too): + // private readonly demoUsersSeeder: DemoUsersSeeder, + // private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder, + // private readonly pricingDataSeeder: PricingDataSeeder, + // private readonly indodeFacilitySeeder: IndodeFacilitySeeder, + // private readonly batch14TestDataSeeder: Batch14TestDataSeeder, + // private readonly batch5TestDataSeeder: Batch5TestDataSeeder, + // private readonly batch7TestDataSeeder: Batch7TestDataSeeder, + // private readonly batch8TestDataSeeder: Batch8TestDataSeeder, + // private readonly warehouseDemoSeeder: WarehouseDemoSeeder, + // private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, + // private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, + // private readonly demoFreightDataSeeder: DemoFreightDataSeeder, + // private readonly govCompaniesSeeder: GovCompaniesSeeder, ) { } async onApplicationBootstrap() { + // ── Enabled: permissions + file-upload settings (+ dropdown settings) only ── + // Everything else below is intentionally disabled. Seeders stay registered + // as providers and injected; only their .run() calls are commented out, so + // re-enabling any of them is a one-line uncomment. + + // 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 + // 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.freightPositionsSeeder.run(); - await this.demoUsersSeeder.run(); - await this.freightStaffUsersSeeder.run(); - await this.pricingDataSeeder.run(); + + // File upload settings — keep enabled. await this.fileUploadSettingsSeeder.run(); - await this.indodeFacilitySeeder.run(); - await this.batch14TestDataSeeder.run(); - await this.batch5TestDataSeeder.run(); - await this.batch7TestDataSeeder.run(); - await this.batch8TestDataSeeder.run(); - await this.warehouseDemoSeeder.run(); - await this.exportDjiboutiInterchangeDemoSeeder.run(); - await this.marshallingDemoTrainsSeeder.run(); - // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. - // Each block self-guards on an empty-table check, so this is safe every boot. - // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, - // FileUploadSettingsSeeder) are intentionally disabled — they stay - // registered as providers but are not run. Re-inject + call .run() to enable. - // demoFreightDataSeeder now seeds ONLY the 4 staff users (wagons + approval - // rules are disabled inside the seeder). Kept running for the staff users. - await this.demoFreightDataSeeder.run(); - // Government entities (with importer/exporter profiles) that government - // bookings bill to. Idempotent — keyed by fixed IDs. - await this.govCompaniesSeeder.run(); - await this.edrTruckFleetSeeder.run(); + + // Dropdown settings are not seeded on boot; run them with + // `pnpm seed:dropdown-settings` (src/scripts/seed-dropdown-settings.ts). + + // ── Disabled: demo / test / reference data seeds ── + // Uncomment a line to re-enable that seed. + // await this.demoUsersSeeder.run(); + // await this.freightStaffUsersSeeder.run(); + // await this.pricingDataSeeder.run(); + // await this.indodeFacilitySeeder.run(); + // await this.batch14TestDataSeeder.run(); + // await this.batch5TestDataSeeder.run(); + // await this.batch7TestDataSeeder.run(); + // await this.batch8TestDataSeeder.run(); + // await this.warehouseDemoSeeder.run(); + // await this.exportDjiboutiInterchangeDemoSeeder.run(); + // await this.marshallingDemoTrainsSeeder.run(); + // demoFreightDataSeeder seeds ONLY the 4 staff users (wagons + approval + // rules are already disabled inside the seeder). + // await this.demoFreightDataSeeder.run(); + // Government entities (importer/exporter profiles) that government bookings + // bill to. Idempotent — keyed by fixed IDs. + // await this.govCompaniesSeeder.run(); } configure(consumer: MiddlewareConsumer) { diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d3344aa5a..9769a7f18 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -34,6 +34,10 @@ export const WagonTransferRequest = () => export const WagonTransferFulfill = () => BookingStaff(FREIGHT_PERMS.wagons.transferFulfill); +/** Admin: read every staffer's wagon-transfer history (not just one's own). */ +export const WagonTransferHistoryAll = () => + BookingStaff(FREIGHT_PERMS.wagons.transferHistoryAll); + /** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts new file mode 100644 index 000000000..4e4c5f5fc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2130000000000-AddHandoverSignerName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * The person who signs off a handover must record their full name (a signature + * is optional, especially for self-haul). Stored per handover record. + */ +export class AddHandoverSignerName2130000000000 implements MigrationInterface { + name = "AddHandoverSignerName2130000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + ADD COLUMN IF NOT EXISTS signer_name varchar(160) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.booking_handovers + DROP COLUMN IF EXISTS signer_name + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts new file mode 100644 index 000000000..5e13a6e3d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2140000000000-CreateAccrualAcks.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Accrual alert acknowledgements: ops can mark an in-warehouse item's fee + * accrual as reviewed (optionally snoozed until a date) so it stops nudging and + * drops down the accrual dashboard. One row per inventory item. + */ +export class CreateAccrualAcks2140000000000 implements MigrationInterface { + name = "CreateAccrualAcks2140000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.warehouse_accrual_acks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + inventory_id uuid NOT NULL UNIQUE, + acknowledged_by uuid, + acknowledged_at timestamptz NOT NULL DEFAULT now(), + snooze_until timestamptz, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_accrual_acks`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts new file mode 100644 index 000000000..7fecfccbb --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2170000000000-ScheduleWagonAdjustmentLogs.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Consist adjustments from a schedule: staff can trim free wagons off a built + * train when their tare pushes gross weight over the locomotives' pull limit + * (incl. overage tolerance), or couple extra yard wagons on while weight and + * length headroom remain. Each add/remove is logged here so the schedule keeps + * an auditable history; the built train itself is updated in place. + * + * Plain columns (no FKs) so the history survives wagon/train deletion. + * + * NOTE: the shared dev DB has no applied migration history, so this is also + * hand-applied there. IF NOT EXISTS keeps that idempotent. + */ +export class ScheduleWagonAdjustmentLogs2170000000000 implements MigrationInterface { + name = 'ScheduleWagonAdjustmentLogs2170000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.schedule_wagon_adjustment_logs ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + train_schedule_id uuid NOT NULL, + train_id uuid NOT NULL, + action varchar(10) NOT NULL, + wagon_id uuid NOT NULL, + wagon_number varchar(50) NOT NULL, + adjusted_by_user_id uuid, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT "PK_schedule_wagon_adjustment_logs" PRIMARY KEY (id) + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_schedule_id" + ON freight.schedule_wagon_adjustment_logs (train_schedule_id); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_swal_train_id" + ON freight.schedule_wagon_adjustment_logs (train_id); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_id";`); + await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_swal_train_schedule_id";`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.schedule_wagon_adjustment_logs;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts new file mode 100644 index 000000000..3f10fa874 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2180000000000-LinkWagonMovementToTransferRequest.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Link each physical wagon move back to the transfer request that drove it, so + * the history can show "Request S→K, 3× NX70 → wagons W101, W102, W103". + * Nullable — legacy moves and non-request manual corrections carry no request. + * Also indexes `moved_by_user_id` for the per-user history queries. + */ +export class LinkWagonMovementToTransferRequest2180000000000 + implements MigrationInterface +{ + name = 'LinkWagonMovementToTransferRequest2180000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + ADD COLUMN IF NOT EXISTS transfer_request_id uuid NULL + `); + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_wm_transfer_request' + ) THEN + ALTER TABLE freight.wagon_movements + ADD CONSTRAINT fk_wm_transfer_request + FOREIGN KEY (transfer_request_id) + REFERENCES freight.wagon_transfer_requests (id) ON DELETE SET NULL; + END IF; + END $$; + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_transfer_request + ON freight.wagon_movements (transfer_request_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_wm_moved_by + ON freight.wagon_movements (moved_by_user_id) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_moved_by`); + await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_wm_transfer_request`); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP CONSTRAINT IF EXISTS fk_wm_transfer_request + `); + await queryRunner.query(` + ALTER TABLE freight.wagon_movements + DROP COLUMN IF EXISTS transfer_request_id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts new file mode 100644 index 000000000..485d29452 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/schedule-wagon-adjustment-log.entity.ts @@ -0,0 +1,38 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index } from 'typeorm'; + +export const WAGON_ADJUSTMENT_ACTIONS = ['ADD', 'REMOVE'] as const; +export type WagonAdjustmentAction = (typeof WAGON_ADJUSTMENT_ACTIONS)[number]; + +/** + * History row for a consist adjustment made from a schedule: staff coupled a + * wagon onto (ADD) or detached one from (REMOVE) the schedule's built train — + * e.g. trimming free wagons whose tare pushed gross weight over the + * locomotives' pull limit. Plain columns (no FK relations) so the history + * survives the wagon or train being deleted later. + */ +@Entity({ schema: 'freight', name: 'schedule_wagon_adjustment_logs' }) +@Index(['trainScheduleId']) +@Index(['trainId']) +export class ScheduleWagonAdjustmentLog extends BaseEntity { + @Column({ name: 'train_schedule_id', type: 'uuid' }) + trainScheduleId!: string; + + @Column({ name: 'train_id', type: 'uuid' }) + trainId!: string; + + @Column({ name: 'action', type: 'varchar', length: 10 }) + action!: WagonAdjustmentAction; + + @Column({ name: 'wagon_id', type: 'uuid' }) + wagonId!: string; + + @Column({ name: 'wagon_number', type: 'varchar', length: 50 }) + wagonNumber!: string; + + @Column({ name: 'adjusted_by_user_id', type: 'uuid', nullable: true }) + adjustedByUserId!: string | null; + + @Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' }) + occurredAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index a9711c1e5..e51d4ad23 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -208,6 +208,8 @@ export interface BatchBoardScheduleDetail { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: BatchBoardSchedule["train"]; locomotive: BatchBoardSchedule["locomotive"]; capacity: BatchBoardSchedule["capacity"]; counts: BatchBoardSchedule["counts"]; @@ -235,6 +237,12 @@ export interface BatchBoardSchedule { docReviewEndsAt: string | null; paymentPhaseEndsAt: string | null; bookingCycleNo: number; + /** Built train (Train Builder) behind this departure, when scheduled by train. */ + train: { + id: string; + code: string; + trainName: string | null; + } | null; locomotive: { code: string; name: string | null; @@ -877,7 +885,7 @@ export class BookingBatchService implements OnModuleInit { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true }, + trainSet: { locomotive: true, train: true }, originStation: true, destinationStation: true, // Yards supply the route's display name for `routeName` below; @@ -1128,6 +1136,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, @@ -1247,6 +1262,13 @@ export class BookingBatchService implements OnModuleInit { ? s.paymentPhaseEndsAt.toISOString() : null, bookingCycleNo: s.bookingCycleNo ?? 0, + train: s.trainSet?.train + ? { + id: s.trainSet.train.id, + code: s.trainSet.train.code, + trainName: s.trainSet.train.trainName ?? null, + } + : null, locomotive: loco ? { code: loco.code, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts new file mode 100644 index 000000000..e031bf9f0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/adjust-schedule-consist.dto.ts @@ -0,0 +1,26 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsArray, IsOptional, IsUUID } from 'class-validator'; + +export class AdjustScheduleConsistDto { + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + "AVAILABLE wagons from the train's current yard to couple onto the built train (blocked when they push gross weight or length past the locomotive limits incl. tolerance).", + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + addWagonIds?: string[]; + + @ApiPropertyOptional({ + type: [String], + format: 'uuid', + description: + 'Free (unloaded) wagons to detach permanently from the built train — e.g. trimming tare when gross weight exceeds the pull limit.', + }) + @IsOptional() + @IsArray() + @IsUUID('all', { each: true }) + removeWagonIds?: string[]; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index f8cc7e3d1..c6e22589f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -39,6 +39,7 @@ import { UploadImportDjiboutiDocumentDto, } from "./dto/import-djibouti-operation.dto"; import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; +import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto"; import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto"; import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; @@ -166,6 +167,34 @@ export class TrainSchedulingController { ); } + @Get("schedules/:id/consist") + @TrainSchedulingView() + @ApiOperation({ + summary: + "Built-train consist snapshot for a schedule: gross weight/length vs locomotive limits (incl. tolerance), trimmable + addable wagons, adjustment history", + }) + getScheduleConsist(@Param("id", ParseUUIDPipe) id: string) { + return this.trainSchedulingService.getScheduleConsist(id); + } + + @Post("schedules/:id/adjust-consist") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", + }) + adjustScheduleConsist( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: AdjustScheduleConsistDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.adjustScheduleConsist( + id, + dto, + resolveAuthUserId(user), + ); + } + @Get("bookable-schedules") // No staff guard: customers hit this while creating a booking to find OPEN // same-route schedules. Do not attach train_scheduling permissions here. diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 874516548..04583c777 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -25,6 +25,7 @@ import { FindOptionsWhere, ILike, In, + IsNull, Not, QueryFailedError, Raw, @@ -48,6 +49,7 @@ import { Train } from '../trains/entities/train.entity'; import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; @@ -61,6 +63,7 @@ import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-book import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; import { Wagon } from '../wagons/entities/wagon.entity'; +import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto'; import { AssignBookingsDto } from './dto/assign-bookings.dto'; import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; @@ -282,6 +285,8 @@ interface BookingWindowRow { origin_code: string | null; destination_label: string | null; destination_code: string | null; + /** Full ordered corridor (origin → milestones → destination) from the schedule's route. */ + route_stations: string[] | null; } @Injectable() @@ -1329,9 +1334,29 @@ export class TrainSchedulingService { limitLoco.maxPullWeightTons + (Number(limitLoco.overageToleranceTons) || 0); const lengthCapWithOverage = limitLoco.maxTrainLengthMeters + (Number(limitLoco.overageToleranceMeters) || 0); - if (!dto.forceAssign && weightCapWithOverage < totalWeightTons) { + // The locomotives pull GROSS weight: the customers' cargo plus wagon tare. + // A built train hauls EVERY coupled wagon's tare — empty ones included — + // so train-bound schedules count the full consist, not just planned slots. + const consistWagons = schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).find({ + where: { trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + const planTareTons = consistWagons + ? roundTons( + consistWagons.reduce( + (sum, wagon) => sum + Number(wagon.wagonType?.tareWeightTons ?? 0), + 0, + ), + ) + : roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); + const grossWeightTons = roundTons(totalWeightTons + planTareTons); + if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( - `Train set locomotives cannot pull ${totalWeightTons}T`, + `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`, ); } if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { @@ -4605,14 +4630,8 @@ export class TrainSchedulingService { name: link.locomotive!.name ?? null, })), wagonCount: wagons.length, - maxGrossTons: roundTons( - wagons.reduce( - (sum, w) => - sum + - (Number(w.wagonType?.tareWeightTons) || 0) + - (Number(w.wagonType?.capacityTons) || 0), - 0, - ), + totalTareTons: roundTons( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), ), totalLengthMeters: roundTons( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), @@ -4624,6 +4643,294 @@ export class TrainSchedulingService { }); } + /** + * Consist snapshot for the adjust-consist UI: the built train's wagons with + * loaded/removable flags, gross weight (cargo + FULL consist tare) and length + * against the locomotive limits incl. overage tolerance, addable yard wagons, + * and the adjustment history. + */ + async getScheduleConsist(scheduleId: string) { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + const builtTrain = schedule.trainSet?.train; + if (!builtTrain) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: builtTrain.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const addableWagons = await this.dataSource.getRepository(Wagon).find({ + where: { + trainId: IsNull(), + status: WagonStatus.Available, + currentYardId: builtTrain.currentYardId ?? undefined, + }, + relations: { wagonType: true }, + order: { wagonNumber: 'ASC' }, + }); + const adjustments = await this.dataSource + .getRepository(ScheduleWagonAdjustmentLog) + .find({ where: { trainScheduleId: scheduleId }, order: { occurredAt: 'DESC' }, take: 30 }); + + // Slots with cargo aboard — their physical wagons are "loaded" and can + // never be trimmed. + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const maxPullWeightTons = roundTons(Number(limits?.maxPullWeightTons ?? 0)); + const overageToleranceTons = roundTons(Number(limits?.overageToleranceTons) || 0); + const maxTrainLengthMeters = roundTons(Number(limits?.maxTrainLengthMeters ?? 0)); + const overageToleranceMeters = roundTons(Number(limits?.overageToleranceMeters) || 0); + + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const consistTareTons = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.tareWeightTons ?? 0), 0), + ); + const consistLengthMeters = roundTons( + wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0), + ); + + const mapWagon = (wagon: Wagon) => ({ + id: wagon.id, + wagonNumber: wagon.wagonNumber, + sequenceNumber: wagon.sequenceNumber, + wagonType: wagon.wagonType + ? { + id: wagon.wagonType.id, + code: wagon.wagonType.code, + tareWeightTons: roundTons(Number(wagon.wagonType.tareWeightTons ?? 0)), + capacityTons: roundTons(Number(wagon.wagonType.capacityTons ?? 0)), + lengthMeters: roundTons(Number(wagon.wagonType.lengthMeters ?? 0)), + } + : null, + }); + + return { + schedule: { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status }, + train: { + id: builtTrain.id, + code: builtTrain.code, + trainName: builtTrain.trainName ?? null, + currentYardId: builtTrain.currentYardId ?? null, + }, + limits: { + maxPullWeightTons, + overageToleranceTons, + pullCapTons: roundTons(maxPullWeightTons + overageToleranceTons), + maxTrainLengthMeters, + overageToleranceMeters, + lengthCapMeters: roundTons(maxTrainLengthMeters + overageToleranceMeters), + }, + totals: { + wagonCount: wagons.length, + cargoTons, + consistTareTons, + grossTons: roundTons(cargoTons + consistTareTons), + consistLengthMeters, + }, + wagons: wagons.map((wagon) => ({ + ...mapWagon(wagon), + loaded: loadedWagonIds.has(wagon.id), + // Free = not pinned to any run; only free wagons can be trimmed. + removable: wagon.currentTrainScheduleId == null && !loadedWagonIds.has(wagon.id), + })), + addableWagons: addableWagons.map(mapWagon), + adjustments: adjustments.map((log) => ({ + id: log.id, + action: log.action, + wagonId: log.wagonId, + wagonNumber: log.wagonNumber, + adjustedByUserId: log.adjustedByUserId, + occurredAt: log.occurredAt, + })), + editable: ['DRAFT', 'SCHEDULED'].includes(schedule.status), + }; + } + + /** + * Permanently adjust the built train's consist from a schedule: trim free + * wagons (their tare no longer rides — the usual fix when gross weight beats + * the pull limit) and/or couple extra AVAILABLE yard wagons while weight and + * length headroom remain (limits incl. overage tolerance). The built train + * updates in place, the schedule's wagon cap follows, and every change is + * logged for the schedule's history. + */ + async adjustScheduleConsist( + scheduleId: string, + dto: AdjustScheduleConsistDto, + userId?: string | null, + ) { + const addWagonIds = [...new Set(dto.addWagonIds ?? [])]; + const removeWagonIds = [...new Set(dto.removeWagonIds ?? [])]; + if (!addWagonIds.length && !removeWagonIds.length) { + throw new BadRequestException('Nothing to adjust — pass wagons to add and/or remove'); + } + const overlap = addWagonIds.filter((id) => removeWagonIds.includes(id)); + if (overlap.length) { + throw new BadRequestException('A wagon cannot be added and removed in the same adjustment'); + } + + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + 'The consist is frozen once the train is dispatched — adjust before departure', + ); + } + const builtTrainRef = schedule.trainSet?.train; + if (!builtTrainRef) { + throw new BadRequestException( + 'This schedule was not created from a built train — its consist cannot be adjusted here', + ); + } + const loadedWagonIds = new Set( + (schedule.trainSet?.wagons ?? []) + .filter((slot) => slot.physicalWagonId && (slot.allocations?.length ?? 0) > 0) + .map((slot) => slot.physicalWagonId as string), + ); + const limits = minLocomotiveLimits(this.locomotivesOfTrainSet(schedule.trainSet)); + const pullCapTons = roundTons( + Number(limits?.maxPullWeightTons ?? 0) + (Number(limits?.overageToleranceTons) || 0), + ); + const lengthCapMeters = roundTons( + Number(limits?.maxTrainLengthMeters ?? 0) + (Number(limits?.overageToleranceMeters) || 0), + ); + + await this.dataSource.transaction(async (manager) => { + const train = await manager.getRepository(Train).findOne({ + where: { id: builtTrainRef.id }, + lock: { mode: 'pessimistic_write' }, + }); + if (!train) throw new NotFoundException(`Train ${builtTrainRef.id} not found`); + + const consist = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + order: { sequenceNumber: 'ASC' }, + }); + const consistById = new Map(consist.map((w) => [w.id, w])); + + // --- validate removals: must be coupled and free (no cargo, no pin) --- + const removed: Wagon[] = []; + for (const wagonId of removeWagonIds) { + const wagon = consistById.get(wagonId); + if (!wagon) { + throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); + } + if (loadedWagonIds.has(wagon.id) || wagon.currentTrainScheduleId != null) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is loaded/pinned on a schedule and cannot be trimmed`, + ); + } + removed.push(wagon); + } + + // --- validate additions: AVAILABLE, loose, standing in the train's yard --- + const added: Wagon[] = []; + for (const wagonId of addWagonIds) { + const wagon = await manager.getRepository(Wagon).findOne({ + where: { id: wagonId }, + relations: { wagonType: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (!wagon) throw new NotFoundException(`Wagon ${wagonId} not found`); + if (wagon.trainId) { + throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on a train`); + } + if (wagon.status !== WagonStatus.Available) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is not available (${wagon.status})`, + ); + } + if (wagon.currentYardId !== train.currentYardId) { + throw new BadRequestException( + `Wagon ${wagon.wagonNumber} is not in the train's yard — only wagons in the same yard can be coupled`, + ); + } + added.push(wagon); + } + + // --- headroom check (only additions can push the train over a cap) --- + const removedIds = new Set(removed.map((w) => w.id)); + const finalConsist = [...consist.filter((w) => !removedIds.has(w.id)), ...added]; + const tareOf = (w: Wagon) => Number(w.wagonType?.tareWeightTons ?? 0); + const lengthOf = (w: Wagon) => Number(w.wagonType?.lengthMeters ?? 0); + const finalTareTons = roundTons(finalConsist.reduce((s, w) => s + tareOf(w), 0)); + const finalLengthMeters = roundTons(finalConsist.reduce((s, w) => s + lengthOf(w), 0)); + const cargoTons = roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)); + const finalGrossTons = roundTons(cargoTons + finalTareTons); + if (added.length && pullCapTons > 0 && finalGrossTons > pullCapTons) { + throw new BadRequestException( + `Adding these wagons puts gross weight at ${finalGrossTons}T (${cargoTons}T cargo + ${finalTareTons}T tare), over the locomotives' ${pullCapTons}T limit incl. tolerance`, + ); + } + if (added.length && lengthCapMeters > 0 && finalLengthMeters > lengthCapMeters) { + throw new BadRequestException( + `Adding these wagons puts consist length at ${finalLengthMeters}m, over the locomotives' ${lengthCapMeters}m limit incl. tolerance`, + ); + } + + // --- apply: detach trims, couple additions, compact the sequence --- + for (const wagon of removed) { + await manager.getRepository(Wagon).update(wagon.id, { + trainId: null, + sequenceNumber: null, + status: WagonStatus.Available, + }); + } + const remaining = consist.filter((w) => !removedIds.has(w.id)); + for (let i = 0; i < remaining.length; i++) { + if (remaining[i].sequenceNumber !== i + 1) { + await manager.getRepository(Wagon).update(remaining[i].id, { sequenceNumber: i + 1 }); + } + } + let sequence = remaining.length; + for (const wagon of added) { + sequence += 1; + await manager.getRepository(Wagon).update(wagon.id, { + trainId: train.id, + sequenceNumber: sequence, + status: WagonStatus.Assigned, + }); + } + + // The schedule is full when every consist wagon is allocated. + await manager + .getRepository(TrainSchedule) + .update(scheduleId, { maxWagons: finalConsist.length }); + + const logRepo = manager.getRepository(ScheduleWagonAdjustmentLog); + const now = new Date(); + await logRepo.save( + [ + ...removed.map((wagon) => ({ action: 'REMOVE' as const, wagon })), + ...added.map((wagon) => ({ action: 'ADD' as const, wagon })), + ].map(({ action, wagon }) => + logRepo.create({ + trainScheduleId: scheduleId, + trainId: train.id, + action, + wagonId: wagon.id, + wagonNumber: wagon.wagonNumber, + adjustedByUserId: userId ?? null, + occurredAt: now, + }), + ), + ); + }); + + return this.getScheduleConsist(scheduleId); + } + /** * Re-derive a built train's lifecycle status from its schedules after one of * them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED → @@ -4725,7 +5032,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts LEFT JOIN freight.contract_routes cr ON cr.deleted_at IS NULL @@ -4777,7 +5088,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts JOIN freight.contract_routes cr ON cr.contract_id = $1 @@ -4823,7 +5138,11 @@ export class TrainSchedulingService { ts.booking_cycle_no, ts.scheduled_departure_date, oy.label AS origin_label, oy.code AS origin_code, - dy.label AS destination_label, dy.code AS destination_code + dy.label AS destination_label, dy.code AS destination_code, + (SELECT array_agg(COALESCE(rmy.label, rmy.code) ORDER BY rm.sequence_no) + FROM freight.route_milestones rm + JOIN freight.yards rmy ON rmy.id = rm.yard_id + WHERE rm.route_id = ts.route_id) AS route_stations FROM freight.train_schedules ts LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id @@ -4845,6 +5164,17 @@ export class TrainSchedulingService { } private mapBookingWindowRow(r: BookingWindowRow) { + const origin = r.origin_label ?? r.origin_code ?? null; + const destination = r.destination_label ?? r.destination_code ?? null; + // Full corridor from the route's milestones (origin → stops → destination). + // Falls back to the schedule's origin/destination when no milestones exist. + const milestoneStops = (r.route_stations ?? []).filter( + (s): s is string => Boolean(s), + ); + const routeStations = + milestoneStops.length >= 2 + ? milestoneStops + : [origin, destination].filter((s): s is string => Boolean(s)); return { scheduleId: r.schedule_id, reference: r.reference ?? null, @@ -4860,8 +5190,9 @@ export class TrainSchedulingService { bookingWindowStatus: r.booking_window_status, bookingCycleNo: r.booking_cycle_no, departureDate: r.scheduled_departure_date, - origin: r.origin_label ?? r.origin_code ?? null, - destination: r.destination_label ?? r.destination_code ?? null, + origin, + destination, + routeStations, }; } diff --git a/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts new file mode 100644 index 000000000..428651288 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/update-train-yard.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsUUID } from 'class-validator'; + +export class UpdateTrainYardDto { + @ApiProperty({ + format: 'uuid', + description: + 'Yard the train now sits in. The coupled locomotives and wagons are relocated with it.', + }) + @IsUUID() + currentYardId!: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 7281aca31..7ed3e4c42 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -7,6 +7,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Patch, Post, Put, Query, @@ -19,6 +20,7 @@ import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; +import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @@ -57,6 +59,15 @@ export class TrainBuilderController { return this.trainBuilderService.setLocomotives(id, dto); } + @Patch(':id/yard') + @FleetManage() + @ApiOperation({ + summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', + }) + setYard(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTrainYardDto) { + return this.trainBuilderService.setYard(id, dto.currentYardId); + } + @Post(':id/wagons') @FleetManage() @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index c28873234..16ff29d38 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -1,4 +1,4 @@ -import { Freight, WagonStatus } from '@edr/types'; +import { Freight, WagonMovementKind, WagonStatus } from '@edr/types'; import { BadRequestException, ConflictException, @@ -10,6 +10,8 @@ import { DataSource, EntityManager, ILike, In } from 'typeorm'; import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { minLocomotiveLimits } from '../train-scheduling/train-capacity.util'; +import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -202,7 +204,6 @@ export class TrainBuilderService { const totalLengthMeters = round( wagons.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ?? 0), 0), ); - const maxGrossTons = round(totalTareTons + totalCapacityTons); const maxPullWeightTons = round(limits?.maxPullWeightTons ?? 0); const maxTrainLengthMeters = round(limits?.maxTrainLengthMeters ?? 0); @@ -221,14 +222,17 @@ export class TrainBuilderService { totals: { wagonCount: wagons.length, totalTareTons, + // Informational only — building never checks against full capacity; + // the real gross check (cargo + tare vs haul limit) runs at allocation. totalCapacityTons, - maxGrossTons, totalLengthMeters, maxPullWeightTons, maxTrainLengthMeters, - // Fully loaded gross vs. what the weakest locomotive can haul. - weightUtilizationPct: maxPullWeightTons - ? round((maxGrossTons / maxPullWeightTons) * 100) + // Cargo the locomotives can still haul once pulling the empty consist. + payloadCapacityTons: round(Math.max(0, maxPullWeightTons - totalTareTons)), + // Share of the haul limit consumed by the empty wagons alone. + tareUtilizationPct: maxPullWeightTons + ? round((totalTareTons / maxPullWeightTons) * 100) : null, lengthUtilizationPct: maxTrainLengthMeters ? round((totalLengthMeters / maxTrainLengthMeters) * 100) @@ -269,6 +273,54 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Relocate the train to another yard. The consist moves as one unit: every + * coupled locomotive and wagon follows to the new yard (so their current + * yards always match the train's), and each wagon gets a movement-ledger row. + * Blocked while the train is out on a dispatched run. + */ + async setYard(id: string, currentYardId: string) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + if (train.currentYardId === currentYardId) return; + const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + + await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); + + const links = await manager + .getRepository(TrainLocomotive) + .find({ where: { trainId: train.id } }); + if (links.length) { + await manager + .getRepository(Locomotive) + .update( + { id: In(links.map((link) => link.locomotiveId)) }, + { currentYardId: yard.id }, + ); + } + + const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const now = new Date(); + for (const wagon of wagons) { + if (wagon.currentYardId === yard.id) continue; + await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); + // Ledger row keeps the wagon's yard history auditable (mirrors the + // manual-relocation path in the wagons service). + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + kind: WagonMovementKind.Manual, + occurredAt: now, + }), + ); + } + }); + return this.getComposition(id); + } + /** Append AVAILABLE wagons from the train's own yard to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { @@ -361,12 +413,8 @@ export class TrainBuilderService { .map((link) => link.locomotive) .filter((loco): loco is Locomotive => Boolean(loco)); const wagons = train.wagons ?? []; - const maxGrossTons = round( - wagons.reduce( - (sum, w) => - sum + (Number(w.wagonType?.tareWeightTons) || 0) + (Number(w.wagonType?.capacityTons) || 0), - 0, - ), + const totalTareTons = round( + wagons.reduce((sum, w) => sum + (Number(w.wagonType?.tareWeightTons) || 0), 0), ); return { id: train.id, @@ -379,7 +427,7 @@ export class TrainBuilderService { : null, locomotives: locomotives.map((loco) => ({ id: loco.id, code: loco.code, name: loco.name ?? null })), wagonCount: wagons.length, - maxGrossTons, + totalTareTons, totalLengthMeters: round( wagons.reduce((sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), 0), ), @@ -464,9 +512,13 @@ export class TrainBuilderService { startCount: number, ): Promise { const uniqueIds = [...new Set(wagonIds)]; - let sequence = startCount; + const wagonRepo = manager.getRepository(Wagon); + + // First pass: lock + validate every wagon so the length gate below sees + // the full incoming set before any row is written. + const toAttach: Wagon[] = []; for (const wagonId of uniqueIds) { - const wagon = await manager.getRepository(Wagon).findOne({ + const wagon = await wagonRepo.findOne({ where: { id: wagonId }, lock: { mode: 'pessimistic_write' }, }); @@ -483,8 +535,16 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, ); } + toAttach.push(wagon); + } + if (!toAttach.length) return; + + await this.assertConsistLengthWithinLimit(manager, train, toAttach); + + let sequence = startCount; + for (const wagon of toAttach) { sequence += 1; - await manager.getRepository(Wagon).update(wagon.id, { + await wagonRepo.update(wagon.id, { trainId: train.id, sequenceNumber: sequence, status: WagonStatus.Assigned, @@ -492,6 +552,58 @@ export class TrainBuilderService { } } + /** + * The consist (already-attached wagons + the incoming ones) must fit the + * train's locomotive length limit — the weakest locomotive of the set caps + * the train, mirroring how scheduling derives capacity. + */ + private async assertConsistLengthWithinLimit( + manager: EntityManager, + train: Train, + incoming: Wagon[], + ): Promise { + const links = await manager.getRepository(TrainLocomotive).find({ + where: { trainId: train.id }, + relations: { locomotive: true }, + }); + const limits = minLocomotiveLimits( + links + .map((link) => link.locomotive) + .filter((loco): loco is Locomotive => Boolean(loco)), + ); + const maxLengthMeters = Number(limits?.maxTrainLengthMeters ?? 0); + if (!Number.isFinite(maxLengthMeters) || maxLengthMeters <= 0) return; + + const existing = await manager.getRepository(Wagon).find({ + where: { trainId: train.id }, + relations: { wagonType: true }, + }); + const currentLength = existing.reduce( + (sum, w) => sum + (Number(w.wagonType?.lengthMeters) || 0), + 0, + ); + + const typeIds = [...new Set(incoming.map((w) => w.wagonTypeId).filter(Boolean))]; + const types = typeIds.length + ? await manager.getRepository(WagonType).find({ where: { id: In(typeIds) } }) + : []; + const lengthByType = new Map(types.map((t) => [t.id, Number(t.lengthMeters) || 0])); + const addedLength = incoming.reduce( + (sum, w) => sum + (lengthByType.get(w.wagonTypeId) ?? 0), + 0, + ); + + const totalLength = currentLength + addedLength; + if (totalLength > maxLengthMeters) { + throw new BadRequestException( + `Cannot attach wagons — train length would be ${round(totalLength)} m ` + + `(current ${round(currentLength)} m + ${round(addedLength)} m added), ` + + `over the locomotive limit of ${round(maxLengthMeters)} m. ` + + 'Remove wagons from the consist or use locomotives with a higher length limit.', + ); + } + } + /** Compact wagon sequence numbers back to 1..n after a removal. */ private async resequenceWagons(manager: EntityManager, trainId: string): Promise { const wagons = await manager.getRepository(Wagon).find({ diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts index 7c5ed092c..a36e5deea 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-movement.entity.ts @@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { Wagon } from './wagon.entity'; +import { WagonTransferRequest } from './wagon-transfer-request.entity'; /** * Ledger of every physical wagon relocation between yards — one row per move. @@ -51,6 +52,14 @@ export class WagonMovement extends BaseEntity { @Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true }) movedByUserId?: string | null; + /** The transfer request this move fulfilled, when it came from one. */ + @Column({ name: 'transfer_request_id', type: 'uuid', nullable: true }) + transferRequestId?: string | null; + + @ManyToOne(() => WagonTransferRequest, { nullable: true }) + @JoinColumn({ name: 'transfer_request_id' }) + transferRequest?: WagonTransferRequest | null; + @Column({ name: 'occurred_at', type: 'timestamptz' }) occurredAt!: Date; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts index 07557f431..12fdaf27c 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -16,6 +16,7 @@ import { FleetManage, FleetView, WagonTransferFulfill, + WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; @@ -50,6 +51,30 @@ export class WagonTransferRequestsController { return this.service.listRequests(status); } + // NOTE: the two `history` routes MUST stay above `@Get(':id')` — Express + // matches in declaration order, so `/history` would otherwise be captured by + // the `:id` param route (and rejected by ParseUUIDPipe). + @Get('history') + @ApiOperation({ + summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)", + }) + myHistory(@CurrentUser() user: TCurrentUser) { + // Never fall through to the all-staff view: getHistory(undefined) means + // "everyone", so a missing caller id must return empty, not leak scope. + if (!user?.id) return { requests: [], movements: [] }; + return this.service.getHistory(user.id); + } + + @Get('history/all') + @WagonTransferHistoryAll() + @ApiQuery({ name: 'userId', required: false }) + @ApiOperation({ + summary: "Admin: any/all staff's transfer history (optional ?userId filter)", + }) + allHistory(@Query('userId') userId?: string) { + return this.service.getHistory(userId); + } + @Get(':id') @ApiOperation({ summary: 'Get one transfer request' }) findOne(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 64b408b6a..bf69d767d 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -6,14 +6,24 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, IsNull, Not, Repository } from 'typeorm'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { WagonsService } from './wagons.service'; +/** Bundled per-user activity: requests they touched + wagons they moved. */ +export interface TransferHistory { + requests: WagonTransferRequest[]; + movements: WagonMovement[]; +} + +/** How many ledger rows the history returns at most (newest first). */ +const HISTORY_LIMIT = 500; + const REQUEST_RELATIONS = { fromYard: true, toYard: true, @@ -33,6 +43,8 @@ export class WagonTransferRequestsService { private readonly requestRepo: Repository, @InjectRepository(Wagon) private readonly wagonRepo: Repository, + @InjectRepository(WagonMovement) + private readonly movementRepo: Repository, private readonly wagonsService: WagonsService, ) {} @@ -125,10 +137,12 @@ export class WagonTransferRequestsService { ); } - // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows). + // Reuse the audited bulk-transfer path (writes wagon_movements ledger rows, + // each stamped with this request's id so history can link them back). await this.wagonsService.bulkTransfer( { wagonIds, toYardId: request.toYardId }, userId, + { transferRequestId: request.id }, ); request.status = WagonTransferRequestStatus.Fulfilled; @@ -138,6 +152,38 @@ export class WagonTransferRequestsService { return this.findById(id); } + /** + * Per-user transfer history: the requests a user filed OR fulfilled, plus the + * individual wagons they physically moved (linked back to their request when + * one drove the move). Pass a `userId` to scope to one staffer; pass + * `undefined` for the admin all-staff view. Scope is decided by the CALLER + * (the controller passes the caller's id unless they hold the history-all + * permission) — this method trusts its argument. + */ + async getHistory(userId?: string | null): Promise { + const requests = await this.requestRepo.find({ + where: userId + ? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }] + : {}, + relations: REQUEST_RELATIONS, + order: { createdAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + const movements = await this.movementRepo.find({ + // Own view: moves I made. All view: every user-attributed move (skip the + // system-written loaded/reposition legs that carry no mover). + where: userId + ? { movedByUserId: userId } + : { movedByUserId: Not(IsNull()) }, + relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true }, + order: { occurredAt: 'DESC' }, + take: HISTORY_LIMIT, + }); + + return { requests, movements }; + } + /** Withdraw a still-PENDING request. */ async cancelRequest(id: string): Promise { const request = await this.findById(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts index 4bb1afd33..107a31e7e 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.module.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Wagon } from './entities/wagon.entity'; +import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { Train } from '../trains/entities/train.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -10,7 +11,15 @@ import { WagonsService } from './wagons.service'; import { WagonTransferRequestsService } from './wagon-transfer-requests.service'; @Module({ - imports: [TypeOrmModule.forFeature([Wagon, WagonTransferRequest, Train, Yard])], + imports: [ + TypeOrmModule.forFeature([ + Wagon, + WagonMovement, + WagonTransferRequest, + Train, + Yard, + ]), + ], controllers: [ WagonsController, TrainWagonsReorderController, diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index ad39fbf7a..55dc177c9 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -180,6 +180,7 @@ export class WagonsService { async bulkTransfer( dto: BulkTransferWagonsDto, userId?: string | null, + opts?: { transferRequestId?: string | null }, ): Promise<{ moved: number }> { const { wagonIds, toYardId } = dto; if (!wagonIds.length) return { moved: 0 }; @@ -215,6 +216,7 @@ export class WagonsService { toYardId, kind: WagonMovementKind.Manual, movedByUserId: userId ?? null, + transferRequestId: opts?.transferRequestId ?? null, occurredAt: new Date(), }), ); diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts new file mode 100644 index 000000000..6b3697f03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/acknowledge-accrual.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; + +/** Acknowledge (optionally snooze) an item's fee-accrual alert. */ +export class AcknowledgeAccrualDto { + @ApiPropertyOptional({ minimum: 1, maximum: 90, description: 'Days to suppress alerts; omit = indefinitely.' }) + @IsOptional() + @IsInt() + @Min(1) + @Max(90) + snoozeDays?: number; + + @ApiPropertyOptional({ description: 'Optional reason / note.' }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts new file mode 100644 index 000000000..2ef499201 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/approve-delivery.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** The customer approving a handover must record their full name (signature optional). */ +export class ApproveDeliveryDto { + @ApiProperty({ description: 'Full name of the person approving delivery.' }) + @IsString() + @IsNotEmpty() + @MaxLength(160) + signerName!: string; +} 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 index f5a730ea8..a0a7ad70f 100644 --- 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 @@ -37,6 +37,10 @@ export class BookingHandover extends BaseEntity { @Column({ name: 'signed_at', type: 'timestamptz', nullable: true }) signedAt?: Date | null; + /** Full name of the person who signed off the handover (required at sign time). */ + @Column({ name: 'signer_name', type: 'varchar', length: 160, nullable: true }) + signerName?: string | null; + @Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true }) signedByUserId?: string | 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 index 48ab3ac48..d50b27150 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -183,12 +183,20 @@ export class HandoverService { } /** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */ - async signForBooking(bookingId: string, userId?: string | null): Promise { + async signForBooking( + bookingId: string, + userId?: string | null, + signerName?: string | null, + ): Promise { await this.dataSource .getRepository(BookingHandover) .update( { bookingId, signedAt: IsNull() }, - { signedAt: new Date(), signedByUserId: userId ?? null }, + { + signedAt: new Date(), + signedByUserId: userId ?? null, + signerName: signerName?.trim() || 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 14a3375c7..92aa36b85 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 @@ -1,7 +1,10 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { ExchangeService } from '@edr/api-common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource } from 'typeorm'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { FeeRuleBasis, FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; @@ -26,6 +29,35 @@ interface ItemAttributes { zoneId: string | null; } +export type AccrualAlert = 'OK' | 'WARNING' | 'CHARGING'; + +export interface AccrualDashboardRow { + inventoryId: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + currency: string; + accruedAmount: number; + freeDaysLeft: number | null; + charging: boolean; + alert: AccrualAlert; + /** Reviewed by ops — suppressed from alerts (snoozed until snoozeUntil, or indefinitely). */ + acknowledged: boolean; + snoozeUntil: string | null; + breakdown: Array<{ + type: FeeRuleType; + amount: number; + freeDays: number; + elapsedDays: number; + chargeableDays: number; + }>; +} + export interface FeePreview { ruleType: FeeRuleType; /** Double-handling charge basis (PER_CONTAINER | PER_TON | PER_MACHINERY); null otherwise. */ @@ -70,12 +102,82 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000; @Injectable() export class WarehouseFeeService { + private readonly logger = new Logger(WarehouseFeeService.name); + constructor( private readonly dataSource: DataSource, private readonly feeRuleRepository: WarehouseFeeRuleRepository, private readonly exchangeService: ExchangeService, + private readonly inbox: NotificationInboxService, ) {} + /** + * Daily accrual alerts: for every in-warehouse item that is charging or within + * its last free days, send the customer an in-app notification with the + * outstanding accrued amount so they can collect before (more) charges hit. + */ + @Cron(CronExpression.EVERY_DAY_AT_6AM, { name: 'warehouse-accrual-alert' }) + async sendAccrualAlerts(): Promise { + try { + const alerts = (await this.accrualDashboard()).filter( + (r) => r.alert !== 'OK' && !r.acknowledged, + ); + if (!alerts.length) return; + this.logger.log(`Accrual alerts: ${alerts.length} item(s) charging or nearing charges`); + + // Per-customer: notify each company about its own items. + for (const row of alerts.filter((r) => r.companyId)) { + const ref = row.bookingReference ?? row.inventoryId.slice(0, 8); + const amount = `${row.accruedAmount.toFixed(2)} ${row.currency}`; + const body = row.charging + ? `Storage/demurrage is now charging on booking ${ref} — ${amount} accrued. Collect the cargo to stop further charges.` + : `Booking ${ref} has ${row.freeDaysLeft ?? 0} free day(s) left before storage/demurrage charges start (${amount} accrued so far).`; + try { + await this.inbox.notify({ + recipients: { companyId: row.companyId! }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: row.charging ? 'Storage charges accruing' : 'Free days ending soon', + body, + link: row.bookingId ? `/bookings/${row.bookingId}` : undefined, + data: { + inventoryId: row.inventoryId, + bookingId: row.bookingId, + alert: row.alert, + accruedAmount: row.accruedAmount, + action: 'ACCRUAL_ALERT', + }, + }); + } catch (err) { + this.logger.warn( + `Accrual alert failed for ${row.inventoryId}: ${(err as Error).message}`, + ); + } + } + + // Ops staff: one digest covering every alerting item. + const charging = alerts.filter((r) => r.charging).length; + const nearing = alerts.length - charging; + const currency = alerts[0]?.currency ?? 'USD'; + const total = alerts.reduce((sum, r) => sum + r.accruedAmount, 0); + try { + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Warehouse fee accruals need attention', + body: `${charging} item(s) charging, ${nearing} nearing the free-day limit — ${total.toFixed(2)} ${currency} accruing. Review the accrual dashboard.`, + link: '/dashboard/warehouse-fee-invoices', + data: { charging, nearing, totalAccrued: Math.round(total * 100) / 100, action: 'ACCRUAL_ALERT_DIGEST' }, + }); + } catch (err) { + this.logger.warn(`Accrual staff digest failed: ${(err as Error).message}`); + } + } catch (err) { + this.logger.warn(`Accrual alert tick failed: ${(err as Error).message}`); + } + } + // ── Rule CRUD ────────────────────────────────────────────────────────────── listRules(): Promise { return this.feeRuleRepository.findAll({ order: { ruleType: 'ASC', priority: 'ASC' } }); @@ -416,6 +518,138 @@ export class WarehouseFeeService { }; } + /** + * Live accrual dashboard: for every item still in the warehouse, the fees + * accruing right now (storage + demurrage + double-handling), how many free + * days remain, and an alert level so staff can act before charges land. + */ + async accrualDashboard(billingCurrency = 'USD'): Promise { + const items: Array<{ + id: string; + status: string; + bookingId: string | null; + companyId: string | null; + bookingReference: string | null; + customerName: string | null; + warehouseCode: string | null; + zoneCode: string | null; + receivedAt: string | null; + }> = await this.dataSource.query( + `SELECT inv.id, + inv.status, + b.id AS "bookingId", + b.company_id AS "companyId", + b.reference AS "bookingReference", + c.name AS "customerName", + w.code AS "warehouseCode", + z.code AS "zoneCode", + inv.created_at AS "receivedAt" + FROM freight.warehouse_inventory inv + LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = b.company_id + LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id + LEFT JOIN freight.warehouse_zones z ON z.id = inv.zone_id + WHERE inv.deleted_at IS NULL + AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + ORDER BY inv.created_at ASC`, + ); + + const ackRows: Array<{ inventoryId: string; snoozeUntil: string | null }> = + await this.dataSource.query( + `SELECT inventory_id AS "inventoryId", snooze_until AS "snoozeUntil" + FROM freight.warehouse_accrual_acks`, + ); + const now = new Date(); + const acks = new Map(ackRows.map((a) => [a.inventoryId, a.snoozeUntil])); + + const rows = await Promise.all( + items.map(async (it): Promise => { + const previews = (await this.previewForInventory(it.id, billingCurrency)).filter( + (p) => p.ruleId, + ); + const accruedAmount = + Math.round(previews.reduce((sum, p) => sum + (p.amount ?? 0), 0) * 100) / 100; + const charging = previews.some((p) => p.chargeableDays > 0); + const freeDaysLeftVals = previews + .filter((p) => p.endIsOpen) + .map((p) => Math.max(0, p.freeDays - p.elapsedDays)); + const freeDaysLeft = freeDaysLeftVals.length ? Math.min(...freeDaysLeftVals) : null; + const alert: AccrualAlert = charging + ? 'CHARGING' + : freeDaysLeft != null && freeDaysLeft <= 2 + ? 'WARNING' + : 'OK'; + return { + inventoryId: it.id, + status: it.status, + bookingId: it.bookingId, + companyId: it.companyId, + bookingReference: it.bookingReference, + customerName: it.customerName, + warehouseCode: it.warehouseCode, + zoneCode: it.zoneCode, + receivedAt: it.receivedAt, + currency: billingCurrency, + accruedAmount, + freeDaysLeft, + charging, + alert, + acknowledged: + acks.has(it.id) && + (acks.get(it.id) == null || new Date(acks.get(it.id) as string) > now), + snoozeUntil: acks.get(it.id) ?? null, + breakdown: previews.map((p) => ({ + type: p.ruleType, + amount: p.amount, + freeDays: p.freeDays, + elapsedDays: p.elapsedDays, + chargeableDays: p.chargeableDays, + })), + }; + }), + ); + + const rank = (a: AccrualAlert) => (a === 'CHARGING' ? 0 : a === 'WARNING' ? 1 : 2); + // Acknowledged items sink to the bottom; among the rest, worst alert first. + return rows.sort( + (a, b) => + Number(a.acknowledged) - Number(b.acknowledged) || + rank(a.alert) - rank(b.alert) || + b.accruedAmount - a.accruedAmount, + ); + } + + /** Mark an item's accrual reviewed. `snoozeDays` > 0 suppresses alerts until then; omitted = indefinitely. */ + async acknowledgeAccrual( + inventoryId: string, + opts: { snoozeDays?: number; note?: string; userId?: string } = {}, + ): Promise { + const snoozeUntil = + opts.snoozeDays && opts.snoozeDays > 0 + ? new Date(Date.now() + opts.snoozeDays * 24 * 60 * 60 * 1000) + : null; + await this.dataSource.query( + `INSERT INTO freight.warehouse_accrual_acks + (inventory_id, acknowledged_by, acknowledged_at, snooze_until, note, updated_at) + VALUES ($1, $2, now(), $3, $4, now()) + ON CONFLICT (inventory_id) DO UPDATE + SET acknowledged_by = EXCLUDED.acknowledged_by, + acknowledged_at = now(), + snooze_until = EXCLUDED.snooze_until, + note = EXCLUDED.note, + updated_at = now()`, + [inventoryId, opts.userId ?? null, snoozeUntil, opts.note?.trim() || null], + ); + } + + /** Remove an acknowledgement so the item re-surfaces for alerts. */ + async unacknowledgeAccrual(inventoryId: string): Promise { + await this.dataSource.query( + `DELETE FROM freight.warehouse_accrual_acks WHERE inventory_id = $1`, + [inventoryId], + ); + } + /** Preview demurrage + storage fees for an inventory item using the most specific active rules. */ async previewForInventory(inventoryId: string, billingCurrency = 'USD'): Promise { const item = await this.loadItem(inventoryId); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts index 533b0c6ae..7bd56e593 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -12,6 +12,8 @@ import { import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; import { WarehouseInspectionService } from './warehouse-inspection.service'; @@ -19,10 +21,12 @@ import { WarehouseInspectionService } from './warehouse-inspection.service'; @ApiTags('warehouse-inspection') @ApiBearerAuth() @Controller() +@BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.view) export class WarehouseInspectionController { constructor(private readonly inspectionService: WarehouseInspectionService) {} @Post('warehouse-inventory/:inventoryId/inspection-reports') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.create) @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) create( @Param('inventoryId', ParseUUIDPipe) inventoryId: string, @@ -46,12 +50,14 @@ export class WarehouseInspectionController { } @Patch('warehouse-inspection-reports/:id') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @ApiOperation({ summary: 'Update an inspection report' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { return this.inspectionService.update(id, dto); } @Post('warehouse-inspection-reports/:id/attachments') + @BookingStaff(FREIGHT_PERMS.warehouseInspectionReports.update) @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: 'Upload inspection images / documents' }) 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 21f2663b6..8b14e6cdc 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 @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Reques import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -11,6 +13,7 @@ import { LoadInventoryDto } from './dto/load-inventory.dto'; import { MoveInventoryDto } from './dto/move-inventory.dto'; import { StoreInventoryDto } from './dto/store-inventory.dto'; import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto'; +import { ApproveDeliveryDto } from './dto/approve-delivery.dto'; import { ReleaseOrderDto } from './dto/release-order.dto'; import { ReserveInventoryDto } from './dto/reserve-inventory.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto'; @@ -29,48 +32,63 @@ export class WarehouseInventoryController { ) {} @Get() + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List warehouse inventory' }) findAll(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findAll(filter); } @Get('ready-for-loading') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List inventory ready for loading' }) findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) { return this.inventoryService.findReadyForLoading(filter); } @Get('inquiry') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Locate any item inside the warehouse' }) inquiry(@Query() filter: InquiryWarehouseInventoryDto) { return this.inventoryService.inquiry(filter); } @Get('arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived bookings awaiting unload / inspection' }) arrivalQueue() { return this.inventoryService.arrivalQueue(); } + @Get('ops-stats') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ summary: 'At-a-glance warehouse ops counters for the KPI strip' }) + opsStats() { + return this.inventoryService.opsStats(); + } + @Get('zone-occupancy') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' }) zoneOccupancy(@Query('yardId') yardId?: string) { return this.inventoryService.zoneOccupancy(yardId); } @Post('auto-unload-arrived') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' }) autoUnloadArrived() { return this.inventoryService.autoUnloadArrived(); } @Post('auto-load-ready') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Auto-load READY_FOR_LOADING inventory with PAID bookings' }) autoLoadReady() { return this.inventoryService.autoLoadReady(); } @Get('eligible-bookings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' }) eligibleBookings(@Query('direction') direction?: string) { const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined; @@ -78,6 +96,7 @@ export class WarehouseInventoryController { } @Post('receive-bulk') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' }) receiveBulk(@Body() dto: BulkReceiveDto) { return this.inventoryService.bulkReceive(dto); @@ -85,36 +104,42 @@ export class WarehouseInventoryController { @Get('ready-to-load-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' }) readyToLoadExport() { return this.inventoryService.readyToLoadExport(); } @Get('received-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' }) receivedExport() { return this.inventoryService.receivedExport(); } @Get('loaded-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' }) loadedExport() { return this.inventoryService.loadedExport(); } @Get('loadable-trains') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'EXPORT trains (pre-dispatch) with inventory waiting to be loaded' }) loadableTrains() { return this.inventoryService.loadableTrains(); } @Get('train/:scheduleId/loadable-items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Container/cargo inventory assigned to a train, with allocated wagons' }) trainLoadableItems(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.inventoryService.trainLoadableItems(scheduleId); } @Post('train/:scheduleId/load') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load selected inventory items onto their allocated wagons for a train' }) loadItemsOntoTrain( @Param('scheduleId', ParseUUIDPipe) scheduleId: string, @@ -124,18 +149,21 @@ export class WarehouseInventoryController { } @Post('bulk-dispatch-export') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' }) bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) { return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy); } @Post('bulk-mark-inspected') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.inspect) @ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' }) bulkMarkInspected(@Body() dto: BulkInspectDto) { return this.inventoryService.bulkMarkInspected(dto); } @Post('bookings/:bookingId/unload') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload a single arrived booking into a location' }) unloadBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, @@ -145,24 +173,28 @@ export class WarehouseInventoryController { } @Post(':id/gate-clearance') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.gatePass) @ApiOperation({ summary: 'Final terminal release / gate clearance (blocked while fees unpaid)' }) gateClearance(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.gateClearance(id, performedBy); } @Get('import/arrive-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' }) importArriveQueue() { return this.scheduling.importArriveQueue(); } @Get('import/trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' }) importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.scheduling.importTrainDetail(scheduleId); } @Post('import/auto-unload-arrived-bookings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' }) autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; @@ -179,12 +211,14 @@ export class WarehouseInventoryController { } @Get('import/unloaded-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' }) importUnloadedQueue() { return this.inventoryService.importUnloadedQueue(); } @Get('export/djibouti-arrival-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Arrived EXPORT train schedules at Djibouti-side ports, ready for unloading' }) exportDjiboutiArrivalQueue( @Query('scheduleId') scheduleId?: string, @@ -203,102 +237,119 @@ export class WarehouseInventoryController { } @Get('export/djibouti-trains/:scheduleId/items') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Assigned export bookings/items for an arrived Djibouti-side train' }) exportDjiboutiTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) { return this.scheduling.exportDjiboutiTrainDetail(scheduleId); } @Post('export/auto-unload-at-djibouti') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.unload) @ApiOperation({ summary: 'Unload all eligible export items assigned to an arrived Djibouti-side train' }) autoUnloadExportAtDjibouti(@Body() dto: { scheduleId: string; performedBy?: string }) { return this.inventoryService.autoUnloadExportAtDjibouti(dto.scheduleId, dto.performedBy); } @Get('import/pickup-ready-queue') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' }) importPickupReadyQueue() { return this.inventoryService.importPickupReadyQueue(); } @Get('loadable-wagons') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' }) loadableWagons() { return this.scheduling.listLoadableWagons(); } @Get('booking/:bookingId/schedule') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' }) bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) { return this.scheduling.getBookingSchedule(bookingId); } @Post('receive') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.receive) @ApiOperation({ summary: 'Receive inventory at a warehouse location' }) receive(@Body() dto: ReceiveWarehouseInventoryDto) { return this.inventoryService.receive(dto); } @Post('reserve') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' }) reserve(@Body() dto: ReserveInventoryDto) { return this.inventoryService.reserve(dto); } @Get(':id/movements') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory movement history' }) movements(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findMovements(id); } @Get(':id/activity') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Inventory activity log' }) activity(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findActivity(id); } @Get(':id/loadings') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Loading records for an inventory item' }) loadings(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.findLoadingsByInventory(id); } @Post(':id/move') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' }) move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) { return this.inventoryService.move(id, dto); } @Post(':id/store') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark received inventory as STORED (optional explicit warehouse/yard/zone)' }) store(@Param('id', ParseUUIDPipe) id: string, @Body() dto: StoreInventoryDto) { return this.inventoryService.store(id, dto.performedBy, dto); } @Post(':id/ready-for-loading') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' }) readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.readyForLoading(id, performedBy); } @Post(':id/load') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.load) @ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) { return this.inventoryService.load(id, dto); } @Post(':id/ready-for-pickup') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.move) @ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' }) readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.readyForPickup(id, performedBy); } @Post(':id/release') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.release) @ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' }) release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) { return this.inventoryService.release(id, dto); } @Get(':id/release-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View warehouse release / exit paper PDF' }) async releaseDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.releaseDocument(id); @@ -309,6 +360,7 @@ export class WarehouseInventoryController { } @Get('customer-truck-exit-paper/:assignmentId') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' }) async truckExitPaper( @Param('assignmentId', ParseUUIDPipe) assignmentId: string, @@ -322,6 +374,7 @@ export class WarehouseInventoryController { } @Get(':id/grn-document') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) @ApiOperation({ summary: 'View goods received note PDF' }) async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { const { filename, buffer } = await this.inventoryService.grnDocument(id); @@ -342,12 +395,17 @@ export class WarehouseInventoryController { } @Post('bookings/:bookingId/approve-delivery') - @ApiOperation({ summary: "Approve delivery using the current customer's saved signature" }) + @ApiOperation({ summary: "Approve delivery — customer records their full name (signature optional)" }) approveDeliveryForBooking( @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Body() dto: ApproveDeliveryDto, @Request() req: { user?: { id?: string; sub?: string } }, ) { - return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); + return this.inventoryService.approveDeliveryForBooking( + bookingId, + req.user?.id ?? req.user?.sub, + dto.signerName, + ); } @Get('bookings/:bookingId/handovers') @@ -362,6 +420,26 @@ export class WarehouseInventoryController { return this.handoverService.requestSignature(bookingId); } + @Get('bookings/:bookingId/grn-document') + @ApiOperation({ summary: 'View GRN PDF for a booking (customer portal)' }) + async bookingGrnDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.grnDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Get('bookings/:bookingId/release-document') + @ApiOperation({ summary: 'View gate-clearance / release-order PDF for a booking (customer portal)' }) + async bookingReleaseDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { + const { filename, buffer } = await this.inventoryService.releaseDocumentForBooking(bookingId); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `inline; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Get('bookings/:bookingId/handover-document') @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { @@ -385,12 +463,14 @@ export class WarehouseInventoryController { } @Post(':id/deliver') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { return this.inventoryService.deliver(id, dto); } @Patch(':id/dispatch') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.dispatch) @ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' }) dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) { return this.inventoryService.dispatch(id, performedBy); 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 7c8edcce0..1c1be66ec 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 @@ -397,6 +397,45 @@ export class WarehouseInventoryService { * but has no customer truck assigned yet, nudge the customer to assign one — with * a deep-link to the booking's truck-assignment card. Fire-and-forget. */ + /** + * At-a-glance warehouse ops counters for the KPI strip: + * - receivedToday: items received today + * - pendingInspection: RECEIVED items not yet inspected + * - trucksOnSite: customer trucks arrived but not departed + * - itemsAging: in-warehouse items older than 7 days (demurrage risk) + */ + async opsStats(): Promise<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> { + const [row]: Array<{ + receivedToday: number; + pendingInspection: number; + trucksOnSite: number; + itemsAging: number; + }> = await this.dataSource.query( + `SELECT + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND created_at::date = CURRENT_DATE) AS "receivedToday", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL AND status = 'RECEIVED' AND inspection_status IS NULL) AS "pendingInspection", + (SELECT count(*)::int FROM freight.customer_truck_assignments + WHERE deleted_at IS NULL AND arrived_at IS NOT NULL AND departed_at IS NULL) AS "trucksOnSite", + (SELECT count(*)::int FROM freight.warehouse_inventory + WHERE deleted_at IS NULL + AND status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING', 'READY_FOR_PICKUP', 'RESERVED') + AND created_at < now() - interval '7 days') AS "itemsAging"`, + ); + return { + receivedToday: row?.receivedToday ?? 0, + pendingInspection: row?.pendingInspection ?? 0, + trucksOnSite: row?.trucksOnSite ?? 0, + itemsAging: row?.itemsAging ?? 0, + }; + } + /** * Live occupancy per zone: rated capacity vs the weight/items currently held * (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard @@ -454,14 +493,17 @@ export class WarehouseInventoryService { return rows.map((r) => { const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null; + // Zone capacity_weight is in TONNES; inventory weight is in KG — normalise + // used weight to tonnes before comparing so weight occupancy is correct. + const usedWeightTons = r.usedWeight / 1000; const byWeight = - capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null; + capWeight && capWeight > 0 ? (usedWeightTons / capWeight) * 100 : null; const byItems = r.capacityContainers && r.capacityContainers > 0 ? (r.usedItems / r.capacityContainers) * 100 : null; - // Prefer container-count occupancy (unit-consistent). Weight capacity is - // tonnes while inventory weight is kg, so weight% is only a rough fallback. + // Container zones use item-count occupancy; bulk zones (no container cap) + // fall back to the now unit-correct weight occupancy. const pct = byItems ?? byWeight; return { id: r.id, @@ -3132,16 +3174,20 @@ export class WarehouseInventoryService { async approveDeliveryForBooking( bookingId: string, userId?: string, + signerName?: string, ): Promise<{ bookingId: string; inventoryId: string; approvedAt: string; signerDisplayName: string }> { if (!userId) { throw new BadRequestException('Authentication is required to approve delivery'); } - - const signature = await this.signatures.getForUser(userId); - if (!signature?.signatureImageUrl) { - throw new BadRequestException('Please save your signature before approving delivery'); + const name = signerName?.trim(); + if (!name) { + throw new BadRequestException('Please enter your full name to approve delivery'); } + // A saved signature is applied when available; otherwise the typed full name + // is the record of who approved (self-haul customers may have no signature). + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [item]: Array<{ id: string; warehouseId: string | null; @@ -3176,8 +3222,8 @@ export class WarehouseInventoryService { const approvedAt = new Date(); const approval = { approvedAt: approvedAt.toISOString(), - signerDisplayName: signature.signerDisplayName, - signatureImageUrl: signature.signatureImageUrl, + signerDisplayName: name, + signatureImageUrl: signature?.signatureImageUrl ?? null, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -3192,8 +3238,8 @@ export class WarehouseInventoryService { activityType: 'INVENTORY_RELEASED', inventoryId: item.id, warehouseId: item.warehouseId, - description: `Customer approved delivery as ${signature.signerDisplayName}`, - performedBy: signature.signerDisplayName, + description: `Customer approved delivery as ${name}`, + performedBy: name, }, manager, ); @@ -3201,13 +3247,13 @@ 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); + await this.handover.signForBooking(bookingId, userId, name); return { bookingId, inventoryId: item.id, approvedAt: approval.approvedAt, - signerDisplayName: signature.signerDisplayName, + signerDisplayName: name, }; } @@ -3226,6 +3272,31 @@ export class WarehouseInventoryService { return this.handoverDocument(inv.id); } + /** Resolve the primary warehouse-inventory item for a booking (most recent). */ + private async primaryInventoryIdForBooking(bookingId: string): Promise { + const [inv]: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC + LIMIT 1`, + [bookingId], + ); + if (!inv) { + throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); + } + return inv.id; + } + + /** Booking-scoped GRN document (customer portal). */ + async grnDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.grnDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + + /** Booking-scoped gate-clearance / release document (customer portal). */ + async releaseDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); + } + async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index a1c5db837..635ca1a10 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import type { Response } from 'express'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -13,12 +15,14 @@ export class WarehouseInvoiceController { constructor(private readonly invoiceService: WarehouseInvoiceService) {} @Post('warehouse-inventory/:id/generate-fee-invoice') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a warehouse fee invoice from Batch 5 fee calculation' }) generate(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GenerateInvoiceDto) { return this.invoiceService.generateForInventory(id, dto); } @Post('last-mile/:id/generate-truck-detention-invoice') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.generate) @ApiOperation({ summary: 'Generate a truck-detention invoice for a last-mile leg (per truck per day)' }) generateTruckDetention( @Param('id', ParseUUIDPipe) id: string, @@ -28,6 +32,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-inventory/:id/fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List fee invoices for an inventory item' }) listForInventory(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.listForInventory(id); @@ -40,6 +45,7 @@ export class WarehouseInvoiceController { } @Get('warehouse-fee-invoices') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.view) @ApiOperation({ summary: 'List / filter warehouse fee invoices' }) findAll( @Query('status') status?: string, @@ -86,12 +92,14 @@ export class WarehouseInvoiceController { } @Patch('warehouse-fee-invoices/:id/cancel') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.cancel) @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.invoiceService.cancel(id); } @Post('warehouse-fee-invoices/:id/pay') + @BookingStaff(FREIGHT_PERMS.warehouseFeeInvoices.pay) @ApiOperation({ summary: 'Record a payment against a warehouse fee invoice' }) pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { return this.invoiceService.pay(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts index aab8e18b2..32860082e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-loadings.controller.ts @@ -1,11 +1,14 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { WarehouseInventoryService } from './warehouse-inventory.service'; @ApiTags('warehouse-loadings') @ApiBearerAuth() @Controller('warehouse-loadings') +@BookingStaff(FREIGHT_PERMS.warehouseInventory.view) export class WarehouseLoadingsController { constructor(private readonly inventoryService: WarehouseInventoryService) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts index d35587d9e..7a5c53d38 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-rules.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AllocationPreviewDto, CreateAllocationRuleDto, UpdateAllocationRuleDto, } from './dto/allocation-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; +import { AcknowledgeAccrualDto } from './dto/acknowledge-accrual.dto'; import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeService } from './warehouse-fee.service'; @@ -21,18 +24,21 @@ export class WarehouseRulesController { // ── Allocation rules ─────────────────────────────────────────────────────── @Get('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'List warehouse allocation rules' }) listAllocationRules() { return this.allocationService.listRules(); } @Post('warehouse-allocation-rules') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.create) @ApiOperation({ summary: 'Create a warehouse allocation rule' }) createAllocationRule(@Body() dto: CreateAllocationRuleDto) { return this.allocationService.createRule(dto); } @Patch('warehouse-allocation-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.update) @ApiOperation({ summary: 'Update a warehouse allocation rule' }) updateAllocationRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateAllocationRuleDto) { return this.allocationService.updateRule(id, dto); @@ -40,12 +46,14 @@ export class WarehouseRulesController { @Delete('warehouse-allocation-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.delete) @ApiOperation({ summary: 'Delete a warehouse allocation rule' }) deleteAllocationRule(@Param('id', ParseUUIDPipe) id: string) { return this.allocationService.deleteRule(id); } @Post('warehouse-allocation/preview') + @BookingStaff(FREIGHT_PERMS.warehouseAllocationRules.view) @ApiOperation({ summary: 'Preview the yard/warehouse/zone a booking would be allocated to' }) previewAllocation(@Body() dto: AllocationPreviewDto) { return this.allocationService.resolveLocation(dto); @@ -53,18 +61,21 @@ export class WarehouseRulesController { // ── Fee rules ──────────────────────────────────────────────────────────────── @Get('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'List storage / demurrage fee rules' }) listFeeRules() { return this.feeService.listRules(); } @Post('warehouse-fee-rules') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.create) @ApiOperation({ summary: 'Create a storage / demurrage fee rule' }) createFeeRule(@Body() dto: CreateFeeRuleDto) { return this.feeService.createRule(dto); } @Patch('warehouse-fee-rules/:id') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) @ApiOperation({ summary: 'Update a fee rule' }) updateFeeRule(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateFeeRuleDto) { return this.feeService.updateRule(id, dto); @@ -72,12 +83,42 @@ export class WarehouseRulesController { @Delete('warehouse-fee-rules/:id') @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.delete) @ApiOperation({ summary: 'Delete a fee rule' }) deleteFeeRule(@Param('id', ParseUUIDPipe) id: string) { return this.feeService.deleteRule(id); } + @Get('warehouse-fees/accrual-dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) + @ApiOperation({ summary: 'Live per-item fee accrual (storage/demurrage) with alerts' }) + accrualDashboard(@Query('billingCurrency') billingCurrency?: string) { + return this.feeService.accrualDashboard(billingCurrency); + } + + @Post('warehouse-fees/accrual/:inventoryId/acknowledge') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) + @ApiOperation({ summary: 'Acknowledge / snooze an item fee-accrual alert' }) + acknowledgeAccrual( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: AcknowledgeAccrualDto, + ) { + return this.feeService.acknowledgeAccrual(inventoryId, { + snoozeDays: dto.snoozeDays, + note: dto.note, + }); + } + + @Delete('warehouse-fees/accrual/:inventoryId/acknowledge') + @HttpCode(204) + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.update) + @ApiOperation({ summary: 'Remove an accrual acknowledgement (re-surface for alerts)' }) + unacknowledgeAccrual(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.feeService.unacknowledgeAccrual(inventoryId); + } + @Get('warehouse-inventory/:id/fee-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview demurrage + storage fees for an inventory item' }) feePreview( @Param('id', ParseUUIDPipe) id: string, @@ -87,6 +128,7 @@ export class WarehouseRulesController { } @Get('last-mile/:id/truck-detention-preview') + @BookingStaff(FREIGHT_PERMS.warehouseFeeRules.view) @ApiOperation({ summary: 'Preview truck detention for a last-mile leg (per truck per day after grace)' }) truckDetentionPreview( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index c14cea7a4..fdfbc36be 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; import { WarehouseYardsService } from './warehouse-yards.service'; @@ -9,6 +11,7 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() @Controller('warehouse-yards') +@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -28,18 +31,21 @@ export class WarehouseYardsController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseYards.update) @ApiOperation({ summary: 'Update warehouse yard' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) { return this.yardsService.update(id, dto); } @Get(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.view) @ApiOperation({ summary: 'List zones within a yard' }) listZones(@Param('yardId', ParseUUIDPipe) yardId: string) { return this.zonesService.findByYard(yardId); } @Post(':yardId/zones') + @BookingStaff(FREIGHT_PERMS.warehouseZones.create) @ApiOperation({ summary: 'Create a zone within a yard' }) createZone( @Param('yardId', ParseUUIDPipe) yardId: string, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts index 7d51feac3..b0371cbcc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.controller.ts @@ -1,12 +1,15 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-zones') @ApiBearerAuth() @Controller('warehouse-zones') +@BookingStaff(FREIGHT_PERMS.warehouseZones.view) export class WarehouseZonesController { constructor(private readonly zonesService: WarehouseZonesService) {} @@ -23,6 +26,7 @@ export class WarehouseZonesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouseZones.update) @ApiOperation({ summary: 'Update warehouse zone' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) { return this.zonesService.update(id, dto); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts index bb7702603..63c40de94 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.controller.ts @@ -1,6 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseDto } from './dto/create-warehouse.dto'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { FilterWarehouseDto } from './dto/filter-warehouse.dto'; @@ -12,6 +14,7 @@ import { WarehousesService } from './warehouses.service'; @ApiTags('warehouses') @ApiBearerAuth() @Controller('warehouses') +@BookingStaff(FREIGHT_PERMS.warehouses.view) export class WarehousesController { constructor( private readonly warehousesService: WarehousesService, @@ -26,12 +29,14 @@ export class WarehousesController { } @Get('dashboard') + @BookingStaff(FREIGHT_PERMS.warehouseDashboard.view) @ApiOperation({ summary: 'Warehouse dashboard metrics' }) dashboard() { return this.dashboardService.getDashboard(); } @Post() + @BookingStaff(FREIGHT_PERMS.warehouses.create) @ApiOperation({ summary: 'Create warehouse' }) create(@Body() dto: CreateWarehouseDto) { return this.warehousesService.create(dto); @@ -44,18 +49,21 @@ export class WarehousesController { } @Patch(':id') + @BookingStaff(FREIGHT_PERMS.warehouses.update) @ApiOperation({ summary: 'Update warehouse' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) { return this.warehousesService.update(id, dto); } @Get(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.view) @ApiOperation({ summary: 'List yards within a warehouse' }) listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) { return this.yardsService.findByWarehouse(warehouseId); } @Post(':warehouseId/yards') + @BookingStaff(FREIGHT_PERMS.warehouseYards.create) @ApiOperation({ summary: 'Create a yard within a warehouse' }) createYard( @Param('warehouseId', ParseUUIDPipe) warehouseId: string, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 7c3fe0700..6e4ec81b1 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -177,6 +177,7 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1b00001-0001-4000-8000-000000000004', 'edr_freight_app:wagons:delete', 'Delete wagon'), perm('e1b00001-0001-4000-8000-000000000005', 'edr_freight_app:wagons:transfer_request', 'Request wagon transfer'), perm('e1b00001-0001-4000-8000-000000000006', 'edr_freight_app:wagons:transfer_fulfill', 'Fulfil wagon transfer (OCC)'), + perm('e1b00001-0001-4000-8000-000000000007', 'edr_freight_app:wagons:transfer_history_all', "View all staff's transfer history"), perm('e1c00001-0001-4000-8000-000000000001', 'edr_freight_app:trains:view', 'View trains'), perm('e1c00001-0001-4000-8000-000000000002', 'edr_freight_app:trains:create', 'Create train'), perm('e1c00001-0001-4000-8000-000000000003', 'edr_freight_app:trains:update', 'Update train'), @@ -446,6 +447,9 @@ export const FREIGHT_PERMS = { // executes the move). Distinct keys so OCC can hold fulfil without request. transferRequest: 'edr_freight_app:wagons:transfer_request', transferFulfill: 'edr_freight_app:wagons:transfer_fulfill', + // Admin: read every staffer's transfer history. Without it, a user only sees + // their own (the /history endpoint uses the caller id, backend-enforced). + transferHistoryAll: 'edr_freight_app:wagons:transfer_history_all', }, trains: { view: 'edr_freight_app:trains:view', diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 223b357c1..0cd00d63b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -49,6 +49,7 @@ import { api } from "@/services/api"; import { PageContainer } from "@/components/page"; import { PageHeader } from "@/components/page/PageHeader"; import { contractsService } from "@/services/contracts.service"; +import { bookingsService } from "@/services/bookings.service"; import { useContractCapacity, useContractDetail, @@ -180,6 +181,9 @@ export default function GlCreateBookingForm() { }>(); const [searchParams] = useSearchParams(); const requestIdParam = searchParams.get("requestId"); + // Rebook: copy an EXPIRED booking's cargo into a fresh booking on the same + // contract (GL only picks a new schedule). Set by the clearance Rebook action. + const copyFromParam = searchParams.get("copyFrom"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); @@ -205,6 +209,13 @@ export default function GlCreateBookingForm() { enabled: Boolean(requestId), }); + // The expired booking a Rebook is copying from (its cargo seeds the form). + const { data: copyFromBooking } = useQuery({ + queryKey: ["rebook-copy-from", copyFromParam], + queryFn: () => bookingsService.getById(copyFromParam!), + enabled: Boolean(copyFromParam), + }); + // Same window-gating the customer sees: booking is only allowed while a // window is OPEN for one of the contract's routes. Intercity contracts are // never window-gated — the shipment rides a passing train staff pick later. @@ -363,6 +374,28 @@ export default function GlCreateBookingForm() { if (bookingRequest.notes) setNotes(bookingRequest.notes); }, [bookingRequest, prefilled]); + // Rebook seed: copy the source booking's container lines once. (Bulk weight / + // item count isn't on the booking payload yet, so bulk rebooks fall through to + // the normal contract seed and GL re-enters the quantity.) + useEffect(() => { + if (!copyFromBooking || prefilled) return; + const lines = copyFromBooking.bookingContainers ?? []; + if (!lines.length) return; + setPrefilled(true); + setContainerLines( + lines.map((c) => { + const qty = Math.max(1, c.quantity); + return { + containerSize: String(c.containerType?.sizeFt ?? ""), + quantity: String(qty), + hazardousQuantity: "0", + reeferQuantity: "0", + units: Array.from({ length: qty }, emptyUnit), + }; + }), + ); + }, [copyFromBooking, prefilled]); + // Seed one shipment line per contracted size exactly once — same seeding the // portal form does. Subsequent renders reuse the lines. useEffect(() => { diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx new file mode 100644 index 000000000..0e4bddbc3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ChangeYardModal.tsx @@ -0,0 +1,103 @@ +import { Alert, Button, Group, Modal, Select, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { MapPin } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { api } from "@/services/api"; +import type { TrainComposition } from "@/services/trainBuilder.service"; +import { useToast } from "@/hooks/use-toast"; + +const parseError = (error: unknown, fallback: string) => { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +}; + +/** + * Relocate the train to another yard. The consist moves as one unit — every + * coupled locomotive and wagon follows, so their current yards always match + * the train's. + */ +export default function ChangeYardModal({ composition, opened, onClose }: ChangeYardModalProps) { + const { toast } = useToast(); + const [yardId, setYardId] = useState(""); + + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const setYard = useMutation(api.trainBuilder.setYard.mutationOptions()); + + useEffect(() => { + if (opened) setYardId(composition?.currentYard?.id ?? ""); + }, [opened, composition]); + + const handleSave = async () => { + if (!composition || !yardId) return; + try { + await setYard.mutateAsync({ id: composition.id, currentYardId: yardId }); + toast({ title: "Train relocated" }); + onClose(); + } catch (err) { + toast({ + title: "Relocation failed", + description: parseError(err, "Could not change the yard"), + variant: "destructive", + }); + } + }; + + const memberCount = + (composition?.locomotives.length ?? 0) + (composition?.totals.wagonCount ?? 0); + + return ( + Change yard — train {composition?.code}} + radius="lg" + centered + > + + }> + The whole consist moves with the train: {composition?.locomotives.length ?? 0}{" "} + locomotive{(composition?.locomotives.length ?? 0) === 1 ? "" : "s"} and{" "} + {composition?.totals.wagonCount ?? 0} wagon + {(composition?.totals.wagonCount ?? 0) === 1 ? "" : "s"} ({memberCount} vehicles) + are relocated so their current yard always matches the train's. Wagon moves are + recorded in the movement ledger. + +