diff --git a/.gitignore b/.gitignore index 21edddcd0..cadb36cea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,13 @@ e2e/**/cypress/downloads/ # e2e launcher state (ports of the running stack) e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3ca5f6cb1..9270dd0a1 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -12,7 +12,7 @@ import { ensurePostgresSchemas, APPLICATION_SEARCH_PATH, } from "./config/ensure-postgres-schemas"; -import { IamModule, DataSeeder } from "@tria-plc/iamapi-common"; +import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; import appConfig from "./config/app.config"; @@ -77,8 +77,8 @@ import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-l import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { VerifaydaModule } from './modules/verifayda/verifayda.module'; -import { FleetHistoryModule } from './modules/fleet-history/fleet-history.module'; +import { VerifaydaModule } from "./modules/verifayda/verifayda.module"; +import { FleetHistoryModule } from "./modules/fleet-history/fleet-history.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; import { ContainersModule } from "./modules/container-management/containers.module"; import { CargoesModule } from "./modules/cargoes/cargoes.module"; @@ -104,7 +104,13 @@ import { LoggerMiddleware } from "./logger.middleware"; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [appConfig, databaseConfig, telebirrConfig, rabbitmqConfig, faydaConfig], + load: [ + appConfig, + databaseConfig, + telebirrConfig, + rabbitmqConfig, + faydaConfig, + ], }), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), @@ -223,7 +229,7 @@ import { LoggerMiddleware } from "./logger.middleware"; }) export class AppModule implements OnApplicationBootstrap { constructor( - private readonly seeder: DataSeeder, + // private readonly seeder: DataSeeder, private readonly edrOrgSeeder: EdrOrgSeeder, private readonly freightPositionsSeeder: FreightPositionsSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, @@ -258,7 +264,7 @@ export class AppModule implements OnApplicationBootstrap { // freightPositionsSeeder → seeds Position + PositionPermission rows // (depends on edrOrgSeeder, must run after) await this.freightPermissionKeyMigrationSeeder.run(); - await this.seeder.run(); + // await this.seeder.run(); await this.edrOrgSeeder.run(); await this.freightPositionsSeeder.run(); diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d478da8f3..a412bf990 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -29,9 +29,22 @@ export const TrainSchedulingView = () => export const TrainSchedulingManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.manage); -export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); +/** + * Fleet guards take an optional granular per-resource key (locomotives:create, + * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain + * valid as a one-of fallback so existing role grants keep working. + */ +export const FleetView = (granular?: string) => + BookingStaff( + granular ? [granular, FREIGHT_PERMS.fleet.view] : FREIGHT_PERMS.fleet.view, + ); -export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); +export const FleetManage = (granular?: string) => + BookingStaff( + granular + ? [granular, FREIGHT_PERMS.fleet.manage] + : FREIGHT_PERMS.fleet.manage, + ); /** Requester creates a wagon-transfer request (count-only, no wagon picks). */ export const WagonTransferRequest = () => diff --git a/apps/edr-freight-api/src/common/freight-permission.util.spec.ts b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts new file mode 100644 index 000000000..d0d01535a --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.util.spec.ts @@ -0,0 +1,51 @@ +import { + assertCanApproveContractStep, + canEditContractStep, +} from './freight-permission.util'; +import { FREIGHT_PERMS } from '../seed/freight-permissions.registry'; + +// The document-edit gate (canEditContractStep) must be STRICT: only the approver +// whose turn it is may edit. This is the fix for a previous approver keeping the +// "Edit contract articles" button after acting, because the approve gate lets +// through anyone holding any contract-approve permission. +describe('canEditContractStep (strict per-step edit gate)', () => { + const director = { + employee: { position: { positionType: { key: '-marketing-director-' } } }, + }; + // A line staff who already approved their own step but still holds a + // contract-approve permission — the exact actor that leaked edit rights. + const officerWithApprovePerm = { + employee: { + position: { + positionType: { key: '-marketing-officer-' }, + permissions: [{ key: FREIGHT_PERMS.contracts.approveLineStaff }], + }, + }, + }; + const superAdmin = { roles: [{ key: 'super_admin' }] }; + + it('lets the step’s own approver edit', () => { + expect(canEditContractStep(director, '-marketing-director-')).toBe(true); + }); + + it('lets an approval admin edit any step', () => { + expect(canEditContractStep(superAdmin, '-marketing-director-')).toBe(true); + }); + + it('does NOT let a different approver edit just because they hold an approve permission', () => { + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); + + it('stays intentionally stricter than the approve gate (which keeps the blanket fallback)', () => { + // The approve gate passes the officer via the any-permission blanket… + expect(() => + assertCanApproveContractStep(officerWithApprovePerm, '-marketing-director-'), + ).not.toThrow(); + // …but the edit gate does not — that divergence IS the fix. + expect(canEditContractStep(officerWithApprovePerm, '-marketing-director-')).toBe( + false, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index ced6e3c46..429c910d3 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -201,6 +201,34 @@ export function assertCanApproveContractStep( ); } +/** + * Strict "is it exactly this caller's turn?" test — mirrors the backoffice + * `canApproveContractStep`. Same passes as {@link assertCanApproveContractStep} + * EXCEPT the blanket "holds any contract-approve permission" fallback is + * dropped: a line-staff holding `approveLineStaff` must NOT read as the director + * for a director step. Used to gate contract-document editing so approval hands + * edit rights to the NEXT approver only — a previous approver who already acted + * (but still holds an approve permission) loses the edit button, as required. + * + * (Kept separate from the approve/reject gate, which keeps the blanket fallback + * so delegates whose token omits a position type can still action their step.) + */ +export function canEditContractStep( + user: TCurrentUser | MeLikeUser | null | undefined, + requiredRole: string, +): boolean { + if (isFreightApprovalAdmin(user)) return true; + + const positionTypes = collectPositionTypeKeys(user); + if (positionTypes.includes(requiredRole)) return true; + + const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? []; + if (aliases.some((alias) => positionTypes.includes(alias))) return true; + + const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole]; + return Boolean(legacyPermission && hasFreightPermission(user, legacyPermission)); +} + export function assertCanApproveBookingStep( user: TCurrentUser | MeLikeUser | null | undefined, requiredRole: string, diff --git a/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts new file mode 100644 index 000000000..61431c5bc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2480000000000-AddMaintenanceDueNotifiedAt.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Dedup stamp for the km/date-due maintenance alert — without it the daily + * cron would re-notify every day a schedule stays due. + */ +export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface { + name = 'AddMaintenanceDueNotifiedAt2480000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules + ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..f4f125eaf --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * KM-based maintenance scheduling: per-vehicle service intervals (by km + * and/or days) driving the maintenance due engine. Raw schema-qualified SQL — + * the builder API resolved bare table names against the default schema and + * failed on boot ("Table maintenance_intervals does not exist"). + */ +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + name = 'AddMaintenanceIntervals2800000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.maintenance_intervals ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL REFERENCES freight.vehicles(id) ON DELETE CASCADE, + maintenance_type varchar NOT NULL, + interval_km numeric(14,2), + interval_days integer, + description text, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.maintenance_intervals;`); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts new file mode 100644 index 000000000..679846484 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist the signer's saved-signature image on the handover record, so the + * signed handover document can render the actual signature (not just the + * typed name) — parity with the booking-contract signing flow. + */ +export class AddSignatureToHandover2800000000001 implements MigrationInterface { + name = 'AddSignatureToHandover2800000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts new file mode 100644 index 000000000..17902ff0d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the customs clearance service fee to a direction + route. + * + * The fee was a single global flat rate; the business sells it per lane — + * "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now + * carry trade_direction + the yard pair, and contract pricing matches on them + * strictly (no route-less fallback). + * + * Existing route-less clearance rates cannot be backfilled (no way to know + * which lane each was meant for) — retired exactly like the base-freight + * retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for + * snapshot history. + */ +export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface { + name = 'CustomsClearanceRouteScope2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired (their lanes were never recorded); down only + // restores the pre-customs constraint shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts new file mode 100644 index 000000000..26c512cfc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the empty-container return surcharge to a direction + route + + * container type, like base freight (import-only for now — the box only goes + * back to the port on imports). + * + * Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired + * (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance + * were, kept readable for snapshot history. Route-scoped replacements must be + * re-entered; a booking that asks for return with no matching rate hard-blocks. + */ +export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface { + name = 'ReturnSurchargeRouteScope2830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'RETURN_SURCHARGE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired; down only restores the customs-era shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 32aa1a880..e233a4fe6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -165,8 +165,16 @@ export class BookingPricingService { const usdAmount = mod.calculatedAmount; const rate = rateById.get(mod.rateId); - const unit = rate?.rateUnit ?? 'FLAT'; - const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + // Derived/route-matched charges (import overweight, empty-container + // return) carry their own unit price + billing unit — bill and display + // those, not whatever the referenced rate row says. + const isDerived = mod.unitPriceUsd != null; + const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; + const unitUsd = isDerived + ? Number(mod.unitPriceUsd) + : rate + ? Number(rate.rateValue) + : usdAmount; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise // derive from total ÷ unit price (the live unit price — a count, not a @@ -182,11 +190,11 @@ export class BookingPricingService { // H15: bill the frozen contract surcharge rate (already in the booking // currency) when this code has a snapshot; else keep the live amount. - const frozen = this.frozenRateByCode( - frozenRates, - mod.surchargeCode, - paymentCurrency, - ); + // Derived charges skip the snapshot — import overweight prices off the + // route's container freight, never a frozen OVERWEIGHT_PER_TON value. + const frozen = isDerived + ? null + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -369,6 +377,8 @@ export class BookingPricingService { isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ac5b3c3cd..c93c61f13 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -436,18 +436,26 @@ export class BookingsController { } @Get(':id/customer-truck-assignment/freight-order') - @ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' }) + @ApiOperation({ + summary: + 'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).', + }) async customerTruckFreightOrder( @Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser, @Res() res: Response, + @Query('copies') copies?: string, ) { const booking = await this.bookingsService.findById(id); if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); } + const extraCopyIndexes = (copies ?? '') + .split(',') + .map((n) => Number(n.trim())) + .filter((n) => Number.isInteger(n) && n >= 1 && n <= 8); const { filename, buffer } = - await this.bookingsService.customerTruckFreightOrderCopies(id); + await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(buffer); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e95cd65c9..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -142,8 +142,21 @@ export class BookingsService { return this.findById(bookingId); } + /** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */ + static readonly FREIGHT_ORDER_EXTRA_COPIES = [ + 'Original 1 (for Issuing Carrier)', + 'Original 2 (for Consignee)', + 'Original 3 (for Shipper)', + 'Copy 4 (Delivery Receipt)', + 'Copy 5 (Extra Copy)', + 'Copy 6 (Extra Copy)', + 'Copy 7 (Extra Copy)', + 'Copy 8 (for Agent)', + ] as const; + async customerTruckFreightOrderCopies( bookingId: string, + extraCopyIndexes: number[] = [], ): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); if (!booking.customerTruckAssignedAt) { @@ -171,7 +184,12 @@ export class BookingsService { [bookingId], ); - const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks); + // The 2 gate copies are ALWAYS printed; the waybill-style copies are + // whatever the customer ticked (indexes into the fixed catalog). + const extraCopies = [...new Set(extraCopyIndexes)] + .map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1]) + .filter(Boolean); + const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies); // Chromium when available; otherwise the styled tabular fallback (never the // generic text dump — the freight order is an outward-facing gate document). const buffer = await this.pdfRender.htmlToPdfBuffer(html, { @@ -268,6 +286,7 @@ export class BookingsService { arrivedAt: string | null; containers: string | null; }>, + extraCopies: string[] = [], ): string { const esc = (v: unknown) => this.escapeHtml(String(v ?? '-')); const assignedAt = booking.customerTruckAssignedAt @@ -386,6 +405,7 @@ export class BookingsService { ${copy('Copy 1: Port Operations Copy')} ${copy('Copy 2: Gate Security & Carrier Copy')} + ${extraCopies.map((label) => copy(label)).join('')} `; } @@ -422,6 +442,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -467,6 +489,8 @@ export class BookingsService { isGovernment: dto.isGovernment ?? false, allowConsolidation, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId ?? null, + destinationYardId: dto.destinationYardId ?? null, totalWagons, bulkTons: dto.freightType === 'BULK' ? Number(dto.bulkTons ?? 0) : 0, containers, @@ -788,6 +812,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -998,6 +1024,8 @@ export class BookingsService { isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + originYardId: dto.originYardId ?? existing.originYardId, + destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index 7f3f06ec2..ac3adb7e8 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -19,12 +20,12 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') -@FleetView() +@FleetView(FREIGHT_PERMS.cargoes.view) export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.create) @ApiOperation({ summary: 'Create a new cargo' }) create(@Body() dto: CreateCargoDto) { return this.cargoesService.create(dto); @@ -43,35 +44,35 @@ export class CargoesController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Update a cargo' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { return this.cargoesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.delete) @ApiOperation({ summary: 'Delete a cargo' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.remove(id); } @Post(':id/load') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Load cargo into a container' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { return this.cargoesService.loadCargo(id, dto); } @Post(':id/unload') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Unload cargo from container' }) unload(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.unloadCargo(id); } @Post(':id/deliver') - @FleetManage() + @FleetManage(FREIGHT_PERMS.cargoes.update) @ApiOperation({ summary: 'Mark cargo as delivered' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { return this.cargoesService.deliverCargo(id, dto); diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index b107e8935..579b9ee26 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -10,18 +10,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") -@FleetView() +@FleetView(FREIGHT_PERMS.consignments.view) export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.consignments.create) @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index 1a0cdb14f..0a5e6bb0f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -18,12 +19,12 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') -@FleetView() +@FleetView(FREIGHT_PERMS.containers.view) export class ContainersController { constructor(private readonly containersService: ContainersService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.create) @ApiOperation({ summary: 'Create a new container' }) create(@Body() dto: CreateContainerDto) { return this.containersService.create(dto); @@ -42,28 +43,28 @@ export class ContainersController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Update a container' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { return this.containersService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.delete) @ApiOperation({ summary: 'Delete a container' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.remove(id); } @Post(':id/assign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Assign container to a wagon' }) assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { return this.containersService.assignToWagon(id, dto); } @Post(':id/unassign-wagon') - @FleetManage() + @FleetManage(FREIGHT_PERMS.containers.update) @ApiOperation({ summary: 'Unassign container from wagon' }) unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.unassignFromWagon(id); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a4360447d..96357c8c4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -289,6 +289,7 @@ export class ContractBookingService { tradeDirection: contract.tradeDirection, freightType, cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'), isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), cargoTotalWeightVgm: this.resolveBulkTons(dto), @@ -738,6 +739,7 @@ export class ContractBookingService { } await this.bookingsRepository.update(booking.id, { cargoTypeId: this.resolveCargoTypeId(contract, dto), + cargoFreeText: dto.cargoFreeText?.trim() || null, cargoTotalWeightVgm: this.resolveBulkTons(dto), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 235e1051f..d40d5dd6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -194,17 +194,49 @@ export class ContractPricingService { contract.freightType === 'CONTAINER' && contract.equipmentReturn === 'WITH_RETURN' ) { - const withReturn = liveRates.find( - (r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD', - ); - if (withReturn && Number(withReturn.rateValue) > 0) { - lineItems.push({ - code: 'RETURN_SURCHARGE', - label: 'Empty container return', - unit: toContractUnit(withReturn.rateUnit), - unitPrice: convert(Number(withReturn.rateValue)), - conditionalOn: 'with_return', + // Return is sold per direction + route + container type (import-only) — + // one display line per contract size that has a configured rate. A size + // with no rate shows nothing here and hard-blocks at booking time. + // ponytail: bookings bill the live route rate, not a frozen snapshot. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'RETURN_SURCHARGE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (onLeg.length > 0) { + const sizes = (contract.cargoScope ?? []) + .map((c) => c.containerSize) + .filter((s): s is string => !!s); + const { items: containerTypes } = await this.containerTypesService.findAll({ + isActive: true, + pageSize: 100, }); + for (const size of sizes) { + const sizeFt = size === '40ft' ? 40 : 20; + const matchedIds = new Set( + containerTypes.filter((ct) => ct.sizeFt === sizeFt).map((ct) => ct.id), + ); + const rate = + onLeg.find((r) => r.containerTypeId && matchedIds.has(r.containerTypeId)) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate || Number(rate.rateValue) <= 0) continue; + lineItems.push({ + code: 'RETURN_SURCHARGE', + label: `Empty container return (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + conditionalOn: 'with_return', + }); + } } } @@ -213,12 +245,24 @@ export class ContractPricingService { // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. // A customs contract may not proceed without a configured live rate. if (contract.customsClearingEnabled) { - const clearance = liveRates.find( - (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', - ); + // The fee is sold per direction + route — strict, no route-less fallback. + // ponytail: multi-route contracts bill the first lane's fee; per-lane fees need per-route snapshots. + const route = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + )[0]; + const clearance = route + ? liveRates.find( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : undefined; if (!clearance || Number(clearance.rateValue) <= 0) { throw new UnprocessableEntityException( - 'No customs clearance service fee is configured. Ask the rates team to set a live CUSTOMS_CLEARANCE rate before submitting customs contracts.', + 'No customs clearance service fee is configured for this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this origin → destination.', ); } lineItems.push({ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ba4fe442f..a8e2f5b62 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -18,7 +18,15 @@ import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { ContractViewModel } from '../../contracts/contract-view-model.builder'; import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; -import { assertCanApproveContractStep } from '../../common/freight-permission.util'; +import { + assertCanApproveContractStep, + assertFreightPermission, + canEditContractStep, +} from '../../common/freight-permission.util'; +import { + FREIGHT_PERMS, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { ContractDocumentHistoryService } from './contract-document-history.service'; import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service'; import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; @@ -236,8 +244,15 @@ export class ContractTransitionService { actorId: string, validityDays: number, documentSnapshot?: ContractDocumentSnapshotInput | null, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + // The route guard passes on either arm; the contract's freight type decides + // which one is actually required (accept bulk ≠ accept container). + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); if (!Number.isInteger(validityDays) || validityDays < 1) { @@ -431,12 +446,11 @@ export class ContractTransitionService { if (!next) return false; if (!user) return false; - try { - assertCanApproveContractStep(user, next.requiredRole); - return true; - } catch { - return false; - } + // Strict match: ONLY the approver whose turn it is (the next pending step's + // role) may edit. Using the looser approve gate here let any approver who + // held a contract-approve permission keep the edit button after acting — + // approval must hand edit rights to the next approver, not share them. + return canEditContractStep(user, next.requiredRole); } /** The role that currently holds editing rights, for UI messaging. */ @@ -533,8 +547,13 @@ export class ContractTransitionService { contractId: string, note: string, actorId: string, + user?: TCurrentUser | null, ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.requestChanges, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED']); await this.contractsRepository.createReviewNote( @@ -552,8 +571,17 @@ export class ContractTransitionService { return updated; } - async reject(contractId: string, reason: string, actorId: string): Promise { + async reject( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { const contract = await this.contractsService.findById(contractId); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.reject, contract.freightType), + ); assertContractStatus(contract, ['SUBMITTED', 'PENDING_APPROVAL']); await this.contractsRepository.createReviewNote( diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 266a00044..7e4c10d6f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -34,7 +34,11 @@ import { import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { ContractDocumentHistoryService } from './contract-document-history.service'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + FREIGHT_PERMS, + bothFreightTypes, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, @@ -184,7 +188,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { - assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), + ); } return this.contractsService.create(dto, files ?? [], user?.id); } @@ -337,23 +344,25 @@ export class ContractsController { } @Post(':id/staff/accept') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + // One-of guard; the service then requires the arm matching the contract's freight type. + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, dto.documentSnapshot, + user, ); } @Get(':id/document/draft') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', @@ -377,7 +386,7 @@ export class ContractsController { } @Put(':id/document/articles') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -396,29 +405,35 @@ export class ContractsController { } @Post(':id/staff/request-changes') - @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), + user, ); } @Post(':id/staff/reject') - @BookingStaff(FREIGHT_PERMS.contracts.reject) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + return this.transitionService.reject( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); } @Post(':id/approval-steps/:stepId/approve') diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 3b5a30ca0..93a0e9e76 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -182,6 +182,13 @@ export class CreateBookingUnderContractDto { @Type(() => CreateBulkLineDto) bulkLines?: CreateBulkLineDto[]; + @ApiPropertyOptional({ + description: 'What the containers carry — captured per booking (container freight).', + }) + @IsOptional() + @IsString() + cargoFreeText?: string; + @ApiPropertyOptional() @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index c907af717..77d8b0df2 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -1,7 +1,8 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FleetManage, StaffReference } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; @@ -9,39 +10,43 @@ import { LocomotivesService } from './locomotives.service'; @ApiTags('locomotives') @ApiBearerAuth() +// No class-level guard: reads are login-only reference data (any staff can +// fetch a locomotive for a cross-flow view without the fleet:view that drives +// the Fleet sidebar). Every mutation carries its own @FleetManage(). @Controller('locomotives') -@FleetView() export class LocomotivesController { constructor(private readonly locomotivesService: LocomotivesService) {} @Get() + @StaffReference() @ApiOperation({ summary: 'List locomotives' }) findAll(@Query() filter: FilterLocomotivesDto) { return this.locomotivesService.findAll(filter); } @Get(':id') + @StaffReference() @ApiOperation({ summary: 'Get a locomotive by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.locomotivesService.findById(id); } @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.create) @ApiOperation({ summary: 'Create a locomotive' }) create(@Body() dto: CreateLocomotiveDto) { return this.locomotivesService.create(dto); } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.update) @ApiOperation({ summary: 'Update a locomotive' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { return this.locomotivesService.update(id, dto); } @Post(':id/decommission') - @FleetManage() + @FleetManage(FREIGHT_PERMS.locomotives.delete) @ApiOperation({ summary: 'Decommission a locomotive' }) decommission(@Param('id', ParseUUIDPipe) id: string) { return this.locomotivesService.decommission(id); diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts index d3e70acff..129262ef0 100644 --- a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -8,6 +8,11 @@ export class CreateMaintenanceScheduleDto { @IsEnum(MaintenanceType) maintenanceType!: MaintenanceType; + /** What is serviced — matched against the interval for auto-scheduling. */ + @IsOptional() + @IsString() + serviceItem?: string; + @IsString() description!: string; @@ -81,7 +86,37 @@ export class UpdateMaintenanceScheduleDto { @IsNumber() actualCost?: number; + /** Odometer at completion — drives KM-based auto-scheduling of the next service. */ + @IsOptional() + @IsNumber() + odometerReading?: number; + @IsOptional() @IsString() notes?: string; } + +export class UpsertMaintenanceIntervalDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Distinguishes intervals of the same type. */ + @IsOptional() + @IsString() + serviceItem?: string; + + @IsOptional() + @IsNumber() + intervalKm?: number; + + @IsOptional() + @IsNumber() + intervalDays?: number; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts new file mode 100644 index 000000000..640c3e26e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -0,0 +1,46 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceType } from './maintenance-schedule.entity'; + +/** + * Maintenance interval configuration. Defines how often a vehicle needs a + * given service. Identity is (vehicle, maintenanceType, serviceItem) — a + * vehicle carries several intervals of the same coarse type with different + * items (oil every 10k km, tires every 50k km, both PREVENTIVE). Uniqueness + * is enforced by a COALESCE expression index in the migration (nullable + * service_item), not a TypeORM @Unique. + */ +@Entity({ name: 'maintenance_intervals', schema: 'freight' }) +@Index(['vehicleId', 'maintenanceType']) +export class MaintenanceInterval extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + /** What is serviced — "oil change", "tires", … Null = generic for the type. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ + @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + intervalKm?: number | null; + + /** Maintenance interval in days. E.g., 365 for annual inspection. */ + @Column({ name: 'interval_days', type: 'integer', nullable: true }) + intervalDays?: number | null; + + /** Human-readable description. E.g., "Oil and filter change". */ + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + /** Is this interval active? Can be disabled without deleting historical data. */ + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts index a4d4d60a0..5a0cc074a 100644 --- a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -30,6 +30,10 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'maintenance_type', type: 'varchar' }) maintenanceType!: MaintenanceType; + /** What is serviced — matches the interval's service_item for auto-scheduling. */ + @Column({ name: 'service_item', type: 'varchar', length: 120, nullable: true }) + serviceItem?: string | null; + @Column({ name: 'description' }) description!: string; @@ -62,4 +66,8 @@ export class MaintenanceSchedule extends BaseEntity { @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) nextDueDate?: Date; + + /** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */ + @Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true }) + dueNotifiedAt?: Date; } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts new file mode 100644 index 000000000..1b020455c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-auto-next.spec.ts @@ -0,0 +1,99 @@ +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceStatus } from './entities/maintenance-schedule.entity'; + +/** + * KM-based auto-scheduling: completing a maintenance with an odometer reading + * creates the next SCHEDULED item at completedKm + intervalKm, matched on the + * schedule's (type, serviceItem) interval. Re-completing must not duplicate. + */ +function makeService(opts: { + before: Record | null; + after: Record | null; + interval: Record | null; +}) { + const saved: Array> = []; + const service = Object.create(MaintenanceService.prototype) as Record; + service.scheduleRepository = { + findOneBy: jest + .fn() + .mockResolvedValueOnce(opts.before) + .mockResolvedValueOnce(opts.after), + update: jest.fn(), + create: jest.fn((v: Record) => v), + save: jest.fn(async (v: Record) => { + saved.push(v); + return v; + }), + }; + service.intervalRepository = { + getByVehicleAndType: jest.fn().mockResolvedValue(opts.interval), + }; + service.dataSource = { + getRepository: jest.fn().mockReturnValue({ update: jest.fn() }), + }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, saved }; +} + +const base = { + id: 's-1', + vehicleId: 'v-1', + maintenanceType: 'PREVENTIVE', + serviceItem: 'oil change', + description: 'Oil and filter', +}; + +describe('MaintenanceService auto-next scheduling', () => { + it('completing at 50,000 km with a 10,000 km interval schedules the next at 60,000', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.SCHEDULED }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null, description: 'Oil and filter' }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0]).toMatchObject({ + vehicleId: 'v-1', + serviceItem: 'oil change', + nextDueKm: 60000, + status: MaintenanceStatus.SCHEDULED, + }); + }); + + it('re-completing an already COMPLETED schedule does not duplicate the next one', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 50000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: null }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 50000, + }); + + expect(saved).toHaveLength(0); + }); + + it('a km + days interval produces ONE next schedule carrying both thresholds', async () => { + const { service, saved } = makeService({ + before: { ...base, status: MaintenanceStatus.IN_PROGRESS }, + after: { ...base, status: MaintenanceStatus.COMPLETED, odometerReading: 20000 }, + interval: { serviceItem: 'oil change', intervalKm: '10000.00', intervalDays: 180 }, + }); + + await service.updateMaintenanceSchedule('s-1', { + status: MaintenanceStatus.COMPLETED, + odometerReading: 20000, + }); + + expect(saved).toHaveLength(1); + expect(saved[0].nextDueKm).toBe(30000); + expect(saved[0].nextDueDate).toBeInstanceOf(Date); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts new file mode 100644 index 000000000..7aac5e863 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-due-alert.spec.ts @@ -0,0 +1,76 @@ +import { NotificationAudience } from '@edr/types'; + +import { MaintenanceService } from './maintenance.service'; + +/** + * The daily due-alert: a SCHEDULED item that crossed its km or date threshold + * gets one BACKOFFICE notification, then is stamped so it isn't repeated. + */ +function makeService(due: Array>) { + const update = jest.fn(); + const notify = jest.fn(); + const service = Object.create(MaintenanceService.prototype) as Record; + service.maintenanceRepository = { getUnnotifiedDue: jest.fn().mockResolvedValue(due) }; + service.scheduleRepository = { update }; + service.inbox = { notify }; + service.logger = { error: jest.fn() }; + return { service: service as unknown as MaintenanceService, update, notify }; +} + +describe('MaintenanceService.sendDueAlerts', () => { + it('reports the km reason when the km threshold was crossed', async () => { + const { service, notify, update } = makeService([ + { + id: 'sched-1', + vehicleId: 'v-1', + plateNumber: 'ET-9875', + maintenanceType: 'PREVENTIVE', + description: 'Oil change', + nextDueKm: 50000, + nextDueDate: null, + currentKm: 50200, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + audience: NotificationAudience.BACKOFFICE, + title: 'Maintenance due — ET-9875', + body: expect.stringContaining('driven 50200 km (due at 50000 km)'), + }), + ); + expect(update).toHaveBeenCalledWith('sched-1', { dueNotifiedAt: expect.any(Date) }); + }); + + it('reports the date reason when only the due date has passed', async () => { + const { service, notify } = makeService([ + { + id: 'sched-2', + vehicleId: 'v-2', + plateNumber: 'AA-8642', + maintenanceType: 'INSPECTION', + description: 'Annual inspection', + nextDueKm: null, + nextDueDate: new Date('2026-01-01'), + currentKm: 1000, + }, + ]); + + await service.sendDueAlerts(); + + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining('due 1/1/2026') }), + ); + }); + + it('does nothing when nothing is due', async () => { + const { service, notify, update } = makeService([]); + + await service.sendDueAlerts(); + + expect(notify).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts new file mode 100644 index 000000000..d884cdf55 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { IsNull, Repository } from 'typeorm'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; +import { MaintenanceType } from './entities/maintenance-schedule.entity'; + +@Injectable() +export class MaintenanceIntervalRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceInterval) + private readonly intervalRepository: Repository, + ) { + super(intervalRepository); + } + + /** + * Resolve the interval for a completed service. Prefers the exact + * (type, serviceItem) match; a completion without an item falls back to the + * type's item-less interval only, so "oil" completions never consume the + * "tires" interval. + */ + async getByVehicleAndType( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + ): Promise { + return this.intervalRepository.findOne({ + where: { + vehicleId, + maintenanceType, + isActive: true, + serviceItem: serviceItem?.trim() ? serviceItem.trim() : IsNull(), + }, + }); + } + + async getActiveIntervals(vehicleId: string): Promise { + return this.intervalRepository.find({ + where: { vehicleId, isActive: true }, + order: { maintenanceType: 'ASC', serviceItem: 'ASC' }, + }); + } + + async upsertInterval( + vehicleId: string, + maintenanceType: MaintenanceType, + serviceItem?: string | null, + intervalKm?: number | null, + intervalDays?: number | null, + description?: string | null, + ): Promise { + const item = serviceItem?.trim() || null; + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item); + + if (existing) { + await this.intervalRepository.update(existing.id, { + intervalKm: intervalKm ?? existing.intervalKm, + intervalDays: intervalDays ?? existing.intervalDays, + description: description ?? existing.description, + }); + const updated = await this.intervalRepository.findOneBy({ id: existing.id }); + return updated!; + } + + return this.intervalRepository.save( + this.intervalRepository.create({ + vehicleId, + maintenanceType, + serviceItem: item, + intervalKm, + intervalDays, + description, + isActive: true, + }), + ); + } + + /** Soft-disable: history keeps pointing at it, auto-scheduling stops. */ + async deactivate(id: string): Promise { + await this.intervalRepository.update(id, { isActive: false }); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts index 4ad8026f2..32e5dfba6 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -4,7 +4,12 @@ import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; import { CreateWorkOrderDto, UpdateWorkOrderDto, @@ -44,6 +49,34 @@ export class MaintenanceController { return this.maintenanceService.updateMaintenanceSchedule(id, dto); } + @Get('due-board') + @BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view]) + @ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' }) + async getDueBoard() { + return this.maintenanceService.getDueBoard(); + } + + @Post('intervals') + @BookingStaff(FREIGHT_PERMS.maintenance.create) + @ApiOperation({ summary: 'Define/adjust a service interval (e.g. oil change every 10,000 km)' }) + async upsertInterval(@Body() dto: UpsertMaintenanceIntervalDto) { + return this.maintenanceService.upsertInterval(dto); + } + + @Get('intervals/:vehicleId') + @BookingStaff(FREIGHT_PERMS.maintenance.view) + @ApiOperation({ summary: "A vehicle's active service intervals" }) + async getIntervals(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getIntervals(vehicleId); + } + + @Delete('intervals/:id') + @BookingStaff(FREIGHT_PERMS.maintenance.delete) + @ApiOperation({ summary: 'Deactivate a service interval (stops auto-scheduling)' }) + async deactivateInterval(@Param('id') id: string) { + return this.maintenanceService.deactivateInterval(id); + } + @Get('upcoming/:vehicleId') @BookingStaff(FREIGHT_PERMS.maintenance.view) @ApiOperation({ summary: 'Get upcoming maintenance' }) diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index 8f4fe1d0b..b64b77bae 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -2,25 +2,37 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { WorkOrder } from './entities/work-order.entity'; import { Part } from './entities/part.entity'; import { Warranty } from './entities/warranty.entity'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; import { WorkOrderRepository } from './work-order.repository'; import { PartRepository } from './part.repository'; import { WarrantyRepository } from './warranty.repository'; import { MaintenanceController } from './maintenance.controller'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; @Module({ imports: [ - TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + TypeOrmModule.forFeature([ + MaintenanceSchedule, + MaintenanceCost, + MaintenanceInterval, + WorkOrder, + Part, + Warranty, + ]), + NotificationInboxModule, ], providers: [ MaintenanceService, MaintenanceDepthService, MaintenanceRepository, + MaintenanceIntervalRepository, WorkOrderRepository, PartRepository, WarrantyRepository, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 9e8cf972e..b417e72cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -47,4 +47,109 @@ export class MaintenanceRepository extends BaseRepository { .getRawOne(); return result?.total || 0; } + + /** + * Fleet-wide "next due" board: one row per vehicle with a SCHEDULED + * maintenance item, driven by time AND km — whichever is soonest. Current km + * is the vehicle's latest fuel-up odometer reading (how mileage is actually + * captured today), falling back to vehicle.actual_distance_km when the + * vehicle has no fuel purchase on file yet. + */ + async getDueBoard(): Promise< + Array<{ + scheduleId: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + serviceItem: string | null; + description: string; + scheduledDate: Date; + nextDueDate: Date | null; + nextDueKm: number | null; + currentKm: number | null; + kmRemaining: number | null; + daysRemaining: number | null; + overdue: boolean; + }> + > { + // Every SCHEDULED item, not one per vehicle — a truck legitimately holds + // several (oil vs tires intervals differ). + return this.scheduleRepository.manager.query(` + SELECT + s.id AS "scheduleId", + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.service_item AS "serviceItem", + s.description, + s.scheduled_date AS "scheduledDate", + s.next_due_date AS "nextDueDate", + s.next_due_km AS "nextDueKm", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm", + CASE WHEN s.next_due_km IS NOT NULL + THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0) + ELSE NULL END AS "kmRemaining", + CASE WHEN s.next_due_date IS NOT NULL + THEN EXTRACT(DAY FROM s.next_due_date - now()) + ELSE NULL END AS "daysRemaining", + ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) AS overdue + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL + ORDER BY s.vehicle_id, s.scheduled_date ASC + `); + } + + /** + * SCHEDULED items that have crossed their km or date due-point and have not + * yet been notified. Backs the daily km/date maintenance alert. + */ + async getUnnotifiedDue(): Promise< + Array<{ + id: string; + vehicleId: string; + plateNumber: string; + maintenanceType: string; + description: string; + nextDueKm: number | null; + nextDueDate: Date | null; + currentKm: number | null; + }> + > { + return this.scheduleRepository.manager.query(` + SELECT + s.id, + s.vehicle_id AS "vehicleId", + v.plate_number AS "plateNumber", + s.maintenance_type AS "maintenanceType", + s.description, + s.next_due_km AS "nextDueKm", + s.next_due_date AS "nextDueDate", + COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm" + FROM freight.maintenance_schedules s + JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL + LEFT JOIN LATERAL ( + SELECT MAX(odometer_reading) AS max_odometer + FROM freight.fuel_purchases fp2 + WHERE fp2.vehicle_id = s.vehicle_id + ) fp ON true + WHERE s.status = 'SCHEDULED' + AND s.deleted_at IS NULL + AND s.due_notified_at IS NULL + AND ( + (s.next_due_date IS NOT NULL AND s.next_due_date <= now()) + OR (s.next_due_km IS NOT NULL + AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km) + ) + `); + } } diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 8ef4800b6..dd9da55a8 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -1,16 +1,28 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; -import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; +import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; -import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; +import { + CreateMaintenanceScheduleDto, + CreateMaintenanceCostDto, + UpdateMaintenanceScheduleDto, + UpsertMaintenanceIntervalDto, +} from './dto/create-maintenance.dto'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; @Injectable() export class MaintenanceService { + private readonly logger = new Logger(MaintenanceService.name); + constructor( private readonly maintenanceRepository: MaintenanceRepository, + private readonly intervalRepository: MaintenanceIntervalRepository, @InjectRepository(MaintenanceSchedule) private readonly scheduleRepository: Repository, @InjectRepository(MaintenanceCost) @@ -18,8 +30,44 @@ export class MaintenanceService { // Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we // reach it through the global DataSource rather than @InjectRepository. private readonly dataSource: DataSource, + private readonly inbox: NotificationInboxService, ) {} + /** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */ + async getDueBoard() { + return this.maintenanceRepository.getDueBoard(); + } + + /** + * Daily check: a vehicle's driven km (latest fuel-up odometer reading, since + * that's the only place mileage is actually recorded) or its due date has + * reached a SCHEDULED item's threshold → alert backoffice once. + */ + @Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' }) + async sendDueAlerts(): Promise { + try { + const due = await this.maintenanceRepository.getUnnotifiedDue(); + for (const item of due) { + const reason = + item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm + ? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)` + : `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`; + await this.inbox.notify({ + recipients: { allBackoffice: true }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.GENERIC, + title: `Maintenance due — ${item.plateNumber}`, + body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`, + link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`, + data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' }, + }); + await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() }); + } + } catch (err) { + this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack); + } + } + /** * Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle * under maintenance is taken out of service (MAINTENANCE + BUSY); once the @@ -64,6 +112,10 @@ export class MaintenanceService { id: string, dto: UpdateMaintenanceScheduleDto, ): Promise { + // Status BEFORE the write: completing an already-COMPLETED schedule again + // must not auto-create a second "next" schedule. + const before = await this.scheduleRepository.findOneBy({ id }); + await this.scheduleRepository.update(id, { ...dto, completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, @@ -78,6 +130,15 @@ export class MaintenanceService { ) { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); + + // First transition into COMPLETED with an odometer → auto-schedule next. + if ( + dto.status === MaintenanceStatus.COMPLETED && + before?.status !== MaintenanceStatus.COMPLETED && + updated.odometerReading != null + ) { + await this.scheduleNextMaintenance(updated); + } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { // Maintenance started → keep the vehicle out of service. await this.setVehicleMaintenanceState(updated.vehicleId, true); @@ -87,6 +148,83 @@ export class MaintenanceService { return updated!; } + /** Define/adjust how often a vehicle needs a service ("oil change every 10,000 km"). */ + async upsertInterval(dto: UpsertMaintenanceIntervalDto) { + return this.intervalRepository.upsertInterval( + dto.vehicleId, + dto.maintenanceType, + dto.serviceItem ?? null, + dto.intervalKm ?? null, + dto.intervalDays ?? null, + dto.description ?? null, + ); + } + + async getIntervals(vehicleId: string) { + return this.intervalRepository.getActiveIntervals(vehicleId); + } + + async deactivateInterval(id: string): Promise<{ id: string; deactivated: boolean }> { + await this.intervalRepository.deactivate(id); + return { id, deactivated: true }; + } + + /** + * Auto-schedule the next service after a completion: matched on the + * completed schedule's (type, serviceItem) interval; one SCHEDULED row + * carrying BOTH thresholds when the interval defines km and days — + * whichever is crossed first makes it due. + */ + private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { + try { + const interval = await this.intervalRepository.getByVehicleAndType( + completed.vehicleId, + completed.maintenanceType as MaintenanceType, + completed.serviceItem, + ); + + if (!interval) return; // No interval defined, skip auto-scheduling + + const now = new Date(); + const completedKm = Number(completed.odometerReading ?? 0); + const intervalKm = Number(interval.intervalKm ?? 0); + const intervalDays = Number(interval.intervalDays ?? 0); + if (intervalKm <= 0 && intervalDays <= 0) return; + + const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; + const nextDueDate = + intervalDays > 0 + ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) + : undefined; + + const label = interval.serviceItem ? `${interval.serviceItem}: ` : ''; + const due = [ + nextDueKm != null ? `${nextDueKm} km` : null, + nextDueDate != null ? nextDueDate.toISOString().slice(0, 10) : null, + ] + .filter(Boolean) + .join(' / '); + + await this.scheduleRepository.save( + this.scheduleRepository.create({ + vehicleId: completed.vehicleId, + maintenanceType: completed.maintenanceType, + serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, + description: `${label}${interval.description || completed.description} (next due: ${due})`, + scheduledDate: now, + nextDueKm, + nextDueDate, + status: MaintenanceStatus.SCHEDULED, + }), + ); + } catch (err) { + this.logger.error( + `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + async getUpcomingMaintenance(vehicleId: string) { return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); } diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index 8c25d67b3..cf2314156 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; @@ -10,7 +11,7 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') -@FleetView() +@FleetView(FREIGHT_PERMS.routes.view) export class RoutesController { constructor(private readonly routesService: RoutesService) {} @@ -27,21 +28,21 @@ export class RoutesController { } @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.create) @ApiOperation({ summary: 'Create route' }) create(@Body() dto: CreateRouteDto) { return this.routesService.create(dto); } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.update) @ApiOperation({ summary: 'Update route' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { return this.routesService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.routes.delete) @ApiOperation({ summary: 'Deactivate route' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.routesService.deactivate(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 30241ddaa..d22bd84a7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(50) rateType?: string; + + @ApiPropertyOptional({ + description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").', + }) + @IsOptional() + @IsString() + @MaxLength(100) + appliesTo?: string; + + @ApiPropertyOptional({ + description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").', + }) + @IsOptional() + @IsString() + @MaxLength(200) + trigger?: string; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 48a948784..bdec72e46 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository { if (query.rateType) { qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType }); } + // Category tabs on the admin page: comma-separated appliesTo / trigger + // lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE). + const csv = (v?: string) => + (v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const appliesTo = csv(query.appliesTo); + if (appliesTo.length > 0) { + qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo }); + } + const triggers = csv(query.trigger); + if (triggers.length > 0) { + qb.andWhere('rate.trigger IN (:...triggers)', { triggers }); + } if (query.search) { qb.andWhere( '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 85c7db4a0..8a5ee8e31 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -76,3 +76,191 @@ describe('RuleEngineService — requested service without a configured surcharge expect(result.hardBlocked[0]).toContain('reefer'); }); }); + +describe('RuleEngineService — overweight surcharge by trade direction', () => { + const baseImportRate: Rate = { + id: 'rate-import-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + const configuredOverweight: Rate = { + id: 'rate-ow', + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateValue: 10, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { + findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection, + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + }); + + it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => { + const result = await service.evaluate(overweightInput('IMPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + // 1000 / (2 × 20) = 25 USD/t on 5 excess tons. + expect(ow[0].unitPriceUsd).toBe(25); + expect(ow[0].calculatedAmount).toBe(125); + expect(ow[0].triggerValue).toBe(5); + expect(ow[0].rateId).toBe(baseImportRate.id); + }); + + it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => { + const result = await service.evaluate(overweightInput('EXPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + expect(ow[0].rateId).toBe(configuredOverweight.id); + // 5 excess tons × the configured 10 USD/t. + expect(ow[0].calculatedAmount).toBe(50); + expect(ow[0].unitPriceUsd).toBeUndefined(); + }); + + it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => { + const result = await service.evaluate({ + ...overweightInput('IMPORT'), + destinationYardId: 'yard-elsewhere', + }); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(0); + }); +}); + +describe('RuleEngineService — empty-container return per route + container type', () => { + const returnRate20: Rate = { + id: 'rate-return-20', + rateType: 'RETURN_SURCHARGE', + trigger: 'WITH_RETURN', + rateValue: 20, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + const returnInput = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { + containerTypeId: 'ct-20', + quantity: 4, + vgmPerUnitTons: 10, + totalVgmTons: 40, + returnQuantity: 2, + }, + ], + ...overrides, + }); + + it('bills the route + type matched rate on the opted-in count', async () => { + const result = await service.evaluate(returnInput({})); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(result.hardBlocked).toHaveLength(0); + expect(ret).toHaveLength(1); + expect(ret[0].rateId).toBe(returnRate20.id); + expect(ret[0].triggerValue).toBe(2); + expect(ret[0].calculatedAmount).toBe(40); + expect(ret[0].billingUnit).toBe('PER_CONTAINER'); + }); + + it('hard-blocks when the booking route has no matching return rate', async () => { + const result = await service.evaluate( + returnInput({ destinationYardId: 'yard-elsewhere' }), + ); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + expect( + result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'), + ).toHaveLength(0); + }); + + it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => { + const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' })); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + }); + + it('legacy booking-level flag bills every container at its type rate', async () => { + const result = await service.evaluate( + returnInput({ + withReturn: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, + ], + }), + ); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(ret).toHaveLength(1); + expect(ret[0].triggerValue).toBe(4); + expect(ret[0].calculatedAmount).toBe(80); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3a4c4b778..1c053a820 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -67,6 +67,12 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The booking's rail leg. Import overweight derives its per-ton price from + * this route's own container freight rate, so the engine needs the yards. + */ + originYardId?: string | null; + destinationYardId?: string | null; /** * Booking's cargo type needs EDR-provided lashing/securing (cargoType * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the @@ -91,6 +97,15 @@ export interface AppliedCargoModifier { triggerValue: number | null; calculatedAmount: number; currency: string; + /** + * Effective per-unit USD price when it differs from the rate row's own value + * — set by derived charges (import overweight: base freight ÷ 2×limit) so + * the breakdown shows the real per-ton figure, not the base container price. + * Any modifier carrying it also bypasses frozen contract snapshots. + */ + unitPriceUsd?: number | null; + /** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */ + billingUnit?: string; } export interface ContainerWeightResult { @@ -165,12 +180,16 @@ export class RuleEngineService { ...(await this.capacityViolations(input.containers, input.tradeDirection)), ); + // Per-container-line weight limit (maxVgmTons), index-aligned with + // containerWeightResults — the derived import overweight divides by it. + const lineMaxVgmTons: Array = []; for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, input.tradeDirection, ); const rule = rules[0]; + lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; @@ -273,13 +292,6 @@ export class RuleEngineService { input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), label: 'refrigerated (reefer) cargo', }, - { - trigger: 'WITH_RETURN', - wanted: - truthy(input.withReturn) || - input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), - label: 'empty-container return', - }, ]; for (const svc of requestedServices) { if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { @@ -292,6 +304,14 @@ export class RuleEngineService { } for (const rate of surchargeRates) { + // Import overweight never bills the configured rate — its per-ton price + // derives from the route's base container freight (see below). + if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') { + continue; + } + // Empty-container return is sold per route + container type — billed by + // the route-matched block below, never by this route-agnostic loop. + if (rate.trigger === 'WITH_RETURN') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -384,6 +404,21 @@ export class RuleEngineService { }); } + if (input.tradeDirection === 'IMPORT') { + appliedModifiers.push( + ...this.derivedImportOverweight( + input, + containerWeightResults, + lineMaxVgmTons, + liveRates, + ), + ); + } + + const withReturn = this.withReturnCharges(input, liveRates); + appliedModifiers.push(...withReturn.modifiers); + hardBlocked.push(...withReturn.blocked); + return { priorityScore, appliedModifiers, @@ -394,6 +429,132 @@ export class RuleEngineService { }; } + /** + * Import overweight — derived, never configured. Each overweight container + * line bills its excess tons at (its own base import freight on the booking's + * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → + * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. + * Note: derives from the LIVE route rate even for frozen-rate contract + * bookings — the frozen snapshot has no route-scoped container price to + * divide. + */ + private derivedImportOverweight( + input: BookingEvaluationInput, + weightResults: ContainerWeightResult[], + lineMaxVgmTons: Array, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + if (!input.originYardId || !input.destinationYardId) return modifiers; + + for (let i = 0; i < weightResults.length; i++) { + const wr = weightResults[i]; + const excess = Number(wr?.overweightExcessTons ?? 0); + const maxVgm = Number(lineMaxVgmTons[i] ?? 0); + if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue; + + // Same precedence as base freight pricing: the rate scoped to this + // container type wins over the route's catch-all rate. + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CONTAINER_IMPORT' && + r.currency === 'USD' && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + const base = + onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + // No base rate → the base-freight line hard-blocks this booking anyway. + if (!base) continue; + + const perTon = Number(base.rateValue) / (2 * maxVgm); + const amount = excess * perTon; + if (!(amount > 0)) continue; + + modifiers.push({ + rateId: base.id, + surchargeCode: 'OVERWEIGHT_PER_TON', + triggerValue: excess, + calculatedAmount: amount, + currency: base.currency, + unitPriceUsd: perTon, + billingUnit: 'PER_TON', + }); + } + return modifiers; + } + + /** + * Empty-container return — sold per direction + route + container type, like + * base freight. Each container line that opted in (returnQuantity, or every + * container when only the legacy booking-level flag is set) bills the + * route-matched WITH_RETURN rate for its own container type; a line with no + * matching rate hard-blocks the booking instead of shipping the service for + * free. Rates are import-only for now, so an export booking that asks for + * return blocks too. + * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one + * RETURN_SURCHARGE snapshot code can't hold per-size route prices. + */ + private withReturnCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { + const modifiers: AppliedCargoModifier[] = []; + const blocked: string[] = []; + const bookingLevel = truthy(input.withReturn); + const wanted = + bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0); + if (!wanted) return { modifiers, blocked }; + + const onLeg = liveRates.filter( + (r) => + r.trigger === 'WITH_RETURN' && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + + for (const container of input.containers) { + const qty = + Number(container.returnQuantity ?? 0) > 0 + ? Number(container.returnQuantity) + : bookingLevel + ? Number(container.quantity || 0) + : 0; + if (!(qty > 0)) continue; + + const rate = + onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate) { + blocked.push( + 'No empty-container return rate is configured for this container ' + + 'type on this route (return is import-only) — remove the return ' + + 'option or ask EDR to configure its rate for this origin → destination.', + ); + continue; + } + + const rateValue = Number(rate.rateValue); + const amount = rate.rateUnit === 'FLAT' ? rateValue : qty * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: qty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + } + + // Same block deduplicated — several lines missing the rate is one problem. + return { modifiers, blocked: [...new Set(blocked)] }; + } + /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 488865f38..203cabaa2 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -93,6 +93,19 @@ export class RatesService { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); } + /** + * Rates sold per direction + route. Base freight always; customs clearance + * and empty-container return are the surcharges that are too — their fee + * depends on the lane (and, for returns, the container type). + */ + private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + return ( + this.isBaseFreight(appliesTo, trigger) || + trigger === 'CUSTOMS_CLEARANCE' || + trigger === 'WITH_RETURN' + ); + } + /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and @@ -126,7 +139,7 @@ export class RatesService { destinationYardId?: string | null; }): Promise { const { appliesTo, trigger, tradeDirection } = input; - if (!this.isBaseFreight(appliesTo, trigger)) { + if (!this.isRouteScoped(appliesTo, trigger)) { return { originYardId: null, destinationYardId: null }; } @@ -134,7 +147,7 @@ export class RatesService { const destinationYardId = input.destinationYardId ?? null; if (!originYardId || !destinationYardId) { throw new BadRequestException( - 'Base freight rates are priced per leg — pick both an origin and a destination yard.', + 'This rate is priced per leg — pick both an origin and a destination yard.', ); } if (originYardId === destinationYardId) { @@ -179,6 +192,25 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind } = input; const { containerTypeId, cargoTypeId } = input; + if (trigger === 'CUSTOMS_CLEARANCE') { + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + ); + } + return; + } + if (trigger === 'WITH_RETURN') { + // Returning the empty box only exists on imports (the box goes back to + // the port) — export return rates are rejected until the business sells + // that. + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'An empty container return rate is import-only for now.', + ); + } + return; + } if (!this.isBaseFreight(appliesTo, trigger)) return; if (appliesTo === 'INTERCITY') { @@ -247,13 +279,24 @@ export class RatesService { const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. + // Exceptions: customs clearance keeps a direction, and empty-container + // return keeps direction + container type — both are sold per lane. const isSurcharge = trigger !== 'ALWAYS'; - const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); + const containerTypeId = + trigger === 'WITH_RETURN' + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — // its yard pair already says where it runs. const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + ? (dto.tradeDirection ?? null) + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : (dto.tradeDirection ?? null); const intercityKind = dto.intercityKind ?? null; this.assertScopeCoherent({ @@ -376,7 +419,8 @@ export class RatesService { if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.trigger) updates.trigger = trigger; - const containerTypeId = isSurcharge + const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN'; + const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId @@ -387,11 +431,15 @@ export class RatesService { ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' - ? null - : dto.tradeDirection !== undefined + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' + ? dto.tradeDirection !== undefined ? dto.tradeDirection - : existing.tradeDirection; + : existing.tradeDirection + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 08b906e84..294fb93f1 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -50,8 +50,11 @@ export class TrainSchedulesRepository extends BaseRepository { company: true, originYard: true, destinationYard: true, - bookingContainers: { containerType: true }, - cargoType: true, + // wagonTypes feed grossBookingWeightTons the REAL tare of the + // wagon type the booking rides — without them it falls back to + // default tares and the workspace gross drifts from the validator. + bookingContainers: { containerType: { wagonTypes: true } }, + cargoType: { wagonTypes: true }, }, }, }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index bafc7a13e..b308a5921 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1187,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { reserved: Booking[]; maxWagons?: number; routeStops?: string[]; + yardCountries?: Record; }) => { const schedule = { id: scheduleId, @@ -1218,10 +1219,21 @@ describe('BookingBatchService — built-train wagon capacity', () => { find: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(undefined), }; + const yardRepo = { + find: jest + .fn() + .mockResolvedValue( + Object.entries(opts.yardCountries ?? {}).map(([id, country]) => ({ + id, + country, + })), + ), + }; const dataSource = { getRepository: jest.fn((entity: { name?: string }) => { if (entity?.name === 'Wagon') return wagonRepo; if (entity?.name === 'RouteMilestone') return milestoneRepo; + if (entity?.name === 'Yard') return yardRepo; return genericRepo; }), transaction: jest.fn(), @@ -1264,11 +1276,11 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); - it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => { - // Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor - // left the pass-through edges reading "free" in the per-edge budget, so the - // full train's window cycled OPEN forever and the day pool never expired. - // A wagon is committed for the whole trip — leg-free edges are not capacity. + it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => { + // Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real + // capacity on the edges they don't ride: a domestic corridor with cargo + // only on m1→m2 still boards bookings on the free first/last edges, so the + // window must stay open for them. const { service } = buildService({ physicalWagons: 2, routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'], @@ -1277,9 +1289,48 @@ describe('BookingBatchService — built-train wagon capacity', () => { reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }), ], }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + + it('is FULL for the trade direction once the border edge is sold out, even with home legs free', async () => { + // Export b→c holds every wagon of the border crossing: no further export + // can board anywhere (they all must ride that edge), so the window closes — + // while intercity keeps booking the free a→b leg through the per-leg budget. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-b', dest: 'yard-dj' }), + reservedBooking('b2', { origin: 'yard-b', dest: 'yard-dj' }), + ], + }); await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); + it('is NOT full while the border edge still has room, even with a home leg sold out', async () => { + // Intercity rode a→b on both wagons; the border edge b→dj is still free, + // so exports can still board — the window stays open. + const { service } = buildService({ + physicalWagons: 2, + routeStops: ['yard-a', 'yard-b', 'yard-dj'], + yardCountries: { + 'yard-a': 'ETHIOPIA', + 'yard-b': 'ETHIOPIA', + 'yard-dj': 'DJIBOUTI', + }, + reserved: [ + reservedBooking('b1', { origin: 'yard-a', dest: 'yard-b' }), + reservedBooking('b2', { origin: 'yard-a', dest: 'yard-b' }), + ], + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); + }); + it('reports over-allocation when the consist is trimmed below committed bookings', async () => { const { service } = buildService({ physicalWagons: 1, 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 03a125aef..591812880 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 @@ -24,9 +24,9 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingPricingService } from '../bookings/booking-pricing.service'; -import { Locomotive } from '../locomotives/entities/locomotive.entity'; import { formatRouteLabel } from '../routes/entities/route.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; @@ -60,11 +60,14 @@ import { DEFAULT_WAGONS_PER_BOOKING, } from "./booking-batch.constants"; import { + LocomotiveLimits, WagonTypeDimensions, + bookingCargoTons, bookingGrossWeightTons, deriveTrainCapacityFromLocomotive, sizePartialOfferWagons, trainHardCaps, + trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, } from './train-capacity.util'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; @@ -677,7 +680,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -825,7 +828,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -895,7 +898,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( candidate.id, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) continue; const limits = await this.capacityLimits(locomotive); const budget = await this.remainingBudget(schedule, limits, wagonDims); @@ -937,7 +940,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph( target.scheduleId, ); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return false; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); @@ -1034,7 +1037,7 @@ export class BookingBatchService implements OnModuleInit { } const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) { throw new ConflictException( "Export train is no longer available for reservation", @@ -1351,7 +1354,7 @@ export class BookingBatchService implements OnModuleInit { }; }); - const loco = s.trainSet?.locomotive ?? null; + const loco = trainSetLocomotiveLimits(s.trainSet); // The board renders ONE booking window — the schedule's own frozen window // (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings @@ -1411,10 +1414,12 @@ export class BookingBatchService implements OnModuleInit { trainName: s.trainSet.train.trainName ?? null, } : null, + // Identity from the primary (legacy) locomotive; limit figures from the + // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, + code: s.trainSet?.locomotive?.code ?? '', + name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } @@ -1464,7 +1469,7 @@ export class BookingBatchService implements OnModuleInit { weightTons: number; lengthMeters: number; }>, - loco: Locomotive | null, + loco: LocomotiveLimits | null, maxWagons: number | null, ): BatchBoardSchedule["capacity"] { const allocated = items.filter((i) => i.state === "ALLOCATED"); @@ -1499,7 +1504,7 @@ export class BookingBatchService implements OnModuleInit { s: TrainSchedule, items: BatchBoardBooking[], ): BatchBoardSchedule { - const loco = s.trainSet?.locomotive ?? null; + const loco = trainSetLocomotiveLimits(s.trainSet); return { scheduleId: s.id, @@ -1531,10 +1536,12 @@ export class BookingBatchService implements OnModuleInit { trainName: s.trainSet.train.trainName ?? null, } : null, + // Identity from the primary (legacy) locomotive; limit figures from the + // whole set's effective minimum — what the fill engine actually spends. locomotive: loco ? { - code: loco.code, - name: loco.name ?? null, + code: s.trainSet?.locomotive?.code ?? '', + name: s.trainSet?.locomotive?.name ?? null, maxPullWeightTons: Number(loco.maxPullWeightTons), maxTrainLengthMeters: Number(loco.maxTrainLengthMeters), } @@ -1600,7 +1607,7 @@ export class BookingBatchService implements OnModuleInit { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule || !this.isFillable(schedule)) return 0; - const locomotive = schedule.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (!schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${scheduleId} has no locomotive/train set — skipped.`, @@ -1827,7 +1834,7 @@ export class BookingBatchService implements OnModuleInit { for (const id of scheduleIds) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !schedule.trainSetId || !locomotive) { this.logger.warn( `Schedule ${id} has no locomotive/train set — skipped.`, @@ -2469,7 +2476,7 @@ export class BookingBatchService implements OnModuleInit { } | null> { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); - const locomotive = schedule?.trainSet?.locomotive; + const locomotive = trainSetLocomotiveLimits(schedule?.trainSet); if (!schedule || !locomotive) return null; const wagonDims = await this.loadWagonDims(); const limits = await this.capacityLimits(locomotive); @@ -3114,8 +3121,7 @@ export class BookingBatchService implements OnModuleInit { const containers = (b: Booking): number => (b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0); const totalContainers = containers(primary) + containers(partner); - const cargoTons = - Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0); + const cargoTons = bookingCargoTons(primary) + bookingCargoTons(partner); // Consolidation shares TEU slots, never rated payload: the pair still needs // enough wagons to carry its combined cargo, so the weight axis bounds the @@ -3222,7 +3228,7 @@ export class BookingBatchService implements OnModuleInit { const byLength = containerWagonsForLines(booking.bookingContainers ?? []); const capacityTons = this.dimsFor(booking, wagonDims).capacityTons; - const cargoTons = Number(booking.cargoTotalWeightVgm ?? 0); + const cargoTons = bookingCargoTons(booking); const byWeight = cargoTons > 0 && capacityTons > 0 ? Math.ceil(cargoTons / capacityTons) : 0; @@ -3243,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit { return { wagons, weightTons: bookingGrossWeightTons( - Number(booking.cargoTotalWeightVgm ?? 0), + bookingCargoTons(booking), wagons, dims.tareWeightTons, ), @@ -3270,7 +3276,7 @@ export class BookingBatchService implements OnModuleInit { * caps deliberately do not apply here (a mis-set global row once capped * every train at 14m and no export booking could board). */ - private async capacityLimits(locomotive: Locomotive): Promise { + private async capacityLimits(locomotive: LocomotiveLimits): Promise { const wagonTypes = await this.loadWagonTypeDimensions(); const derived = deriveTrainCapacityFromLocomotive( { @@ -3304,7 +3310,7 @@ export class BookingBatchService implements OnModuleInit { */ private async syncScheduleMaxWagons( schedule: TrainSchedule, - locomotive: Locomotive, + locomotive: LocomotiveLimits, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); const maxWagons = @@ -3605,14 +3611,14 @@ export class BookingBatchService implements OnModuleInit { } /** - * Built train: FULL when every physical wagon slot is taken — the consist is - * the capacity, weight/length were settled at build time. - * No built train: FULL on ANY capacity axis — out of wagon slots, or out of - * pull weight / train length for even one more loaded wagon. The old - * slot-only check let a weight-bound train (PW2: weight binds at 37 wagons = - * 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever - * instead of finalizing — 7 phantom slots kept it "not full" while nothing - * could board. + * FULL is DIRECTIONAL: the schedule's trade direction is full when the + * border-crossing edge (which every export/import must ride) can't take one + * more minimal wagon on any axis — slots for built trains (the consist is + * the capacity, weight/length settled at build), all three axes otherwise + * (PW2: weight binds at 37 wagons = 3522.4T of 3500+90T, slots bind at 44). + * Home-side legs may still run empty; intercity ride-alongs keep filling + * them via the per-leg budget and never consult this flag. Domestic routes + * (no border) are full only when every edge is closed. */ async isScheduleFull(scheduleId: string): Promise { const schedule = @@ -3663,48 +3669,69 @@ export class BookingBatchService implements OnModuleInit { /** See {@link isScheduleFull} — same check for callers that already hold the full graph. */ private async isTrainFull(schedule: TrainSchedule): Promise { - // Built train: the physical consist is the only capacity axis, and a wagon - // is committed to its booking for the WHOLE trip — wagon allocation has no - // leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for - // the Doraleh→Negad edge it merely passes through. Count commitments - // train-wide, not per corridor edge: the per-edge budget read "free slots" - // on pass-through legs of a sold-out consist, so the window of a full train - // cycled OPEN forever instead of concluding DONE (and the day pool's - // leftover bookings were never expired). - const physicalWagons = await this.builtTrainWagonCount(schedule); - if (physicalWagons != null) { - return (await this.committedWagons(schedule)) >= physicalWagons; - } - if ((await this.remainingWagons(schedule)) <= 0) return true; - const locomotive = schedule.trainSet?.locomotive; - if (!locomotive) return false; // no weight/length limits to bind against + // "Full" means full FOR THE TRAIN'S TRADE DIRECTION. Every export and + // every import must cross the ET↔DJ border edge, so once that edge can't + // take one more minimal wagon the booking window may close — even while + // home-side legs still run empty. Intercity ride-alongs never consult this + // flag; they keep booking the free legs through the per-leg budget. + // A single-country (domestic) corridor has no mandatory edge, so it is + // full only when EVERY edge is closed on some axis. const wagonDims = await this.loadWagonDims(); - const limits = await this.capacityLimits(locomotive); + const physicalWagons = await this.builtTrainWagonCount(schedule); + let limits: TrainLimits; + if (physicalWagons != null) { + // The consist is the capacity; weight/length were settled at build time. + // remainingBudget swaps in the physical wagon count per edge itself. + limits = { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; + } else { + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); + // No loco, no built train: only the slot axis exists to bind against. + if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; + limits = await this.capacityLimits(locomotive); + } const budget = await this.remainingBudget(schedule, limits, wagonDims); - return budget.isExhausted(this.minPerWagonNeed(wagonDims)); + const minNeed = this.minPerWagonNeed(wagonDims); + const border = await this.borderLeg(budget.stops); + if (border) { + return !budget.fits( + { + wagons: 1, + weightTons: minNeed.grossWeightTons, + lengthMeters: minNeed.lengthMeters, + }, + border, + ); + } + return budget.isExhausted(minNeed); } /** - * Wagons the schedule's allocated + reserved bookings occupy train-wide, - * regardless of which corridor leg each rides. Deduped by booking id — a - * booking mid-settle can momentarily be both linked and reserved. + * The corridor's single border-crossing edge (last home-country stop → first + * far-country stop), or null when every stop is in one country. This is the + * edge every EXPORT and IMPORT booking must ride, whichever sub-corridor it + * books — which makes it the train's directional fullness gauge. */ - private async committedWagons(schedule: TrainSchedule): Promise { - const wagonDims = await this.loadWagonDims(); - const allocated = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b)); - const reserved = await this.bookingsRepository.findReservedForSchedule( - schedule.id, - ); - const byId = new Map( - [...allocated, ...reserved].map((b) => [b.id, b] as const), - ); - let total = 0; - for (const booking of byId.values()) { - total += this.wagonsFor(booking, wagonDims); - } - return total; + private async borderLeg(stops: string[]): Promise { + if (stops.length < 2) return null; + const yards = await this.dataSource + .getRepository(Yard) + .find({ where: { id: In(stops) } }); + const countryOf = new Map(yards.map((y) => [y.id, y.country])); + const first = countryOf.get(stops[0]); + if (!first) return null; + const crossIdx = stops.findIndex((id) => { + const country = countryOf.get(id); + return country != null && country !== first; + }); + if (crossIdx <= 0) return null; + return { fromEdge: crossIdx - 1, toEdge: crossIdx }; } /** diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 4d408689b..cacffaba4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -1,3 +1,4 @@ +import { bookingCargoTons } from './train-capacity.util'; import type { Booking } from '../bookings/entities/booking.entity'; import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { @@ -187,5 +188,5 @@ export function summarizeFleetWarnings( } export function totalAssignedWeight(bookings: Booking[]): number { - return roundTons(bookings.reduce((sum, b) => sum + Number(b.cargoTotalWeightVgm ?? 0), 0)); + return roundTons(bookings.reduce((sum, b) => sum + bookingCargoTons(b), 0)); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts index dd9234bdb..8342eae47 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.spec.ts @@ -7,6 +7,7 @@ import { grossWagonWeightTons, minLocomotiveLimits, sizePartialOfferWagons, + trainSetLocomotiveLimits, } from './train-capacity.util'; describe('train-capacity.util', () => { @@ -205,6 +206,37 @@ describe('train-capacity.util', () => { expect(limits?.overageToleranceTons).toBe(20); }); + it('ignores unconfigured (null) tolerances instead of zeroing the set (S-2026-00024)', () => { + // LOCO-019 had 90T tolerance, LOCO-020 had none configured: the set must + // keep the 90, not collapse to 0 and reject 3547.6T on a 3500T train. + const limits = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }, + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: null }, + ]); + expect(limits?.overageToleranceTons).toBe(90); + // All unconfigured → no tolerance. + const none = minLocomotiveLimits([ + { maxPullWeightTons: 3500, maxTrainLengthMeters: 760 }, + ]); + expect(none?.overageToleranceTons).toBe(0); + }); + + it('trainSetLocomotiveLimits prefers link rows and falls back to the legacy single loco', () => { + const l1 = { maxPullWeightTons: 3500, maxTrainLengthMeters: 760, overageToleranceTons: 90 }; + const l2 = { maxPullWeightTons: 3600, maxTrainLengthMeters: 700, overageToleranceTons: null }; + expect( + trainSetLocomotiveLimits({ locomotive: null, locomotives: [{ locomotive: l1 }, { locomotive: l2 }] }), + ).toEqual({ + maxPullWeightTons: 3500, + maxTrainLengthMeters: 700, + overageToleranceTons: 90, + overageToleranceMeters: 0, + }); + expect(trainSetLocomotiveLimits({ locomotive: l1 })?.maxPullWeightTons).toBe(3500); + expect(trainSetLocomotiveLimits(null)).toBeNull(); + expect(trainSetLocomotiveLimits({ locomotive: null, locomotives: [] })).toBeNull(); + }); + describe('sizePartialOfferWagons', () => { it('sizes a bulk split by the WEIGHT axis when the pull limit binds, not wagon slots', () => { // The 3500T-train scenario: two 1000T bookings boarded gross (each 15 PW2 diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts index 3e4fab0d3..b4b3a64de 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-capacity.util.ts @@ -86,6 +86,27 @@ function num(value: unknown, fallback = 0): number { return Number.isFinite(n) ? n : fallback; } +/** + * Cargo tons of a booking: the stored VGM total when present, else the sum of + * its container lines (quantity × VGM per unit). The portal's container flow + * stores per-line VGM and leaves `cargoTotalWeightVgm` at 0 — reading the + * total alone made every such booking weigh only its tare. + */ +export function bookingCargoTons(booking: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + quantity?: number | null; + vgmPerUnitTons?: number | string | null; + }> | null; +}): number { + const total = num(booking.cargoTotalWeightVgm); + if (total > 0) return total; + return (booking.bookingContainers ?? []).reduce( + (sum, line) => sum + num(line.quantity) * num(line.vgmPerUnitTons), + 0, + ); +} + /** Gross weight of one loaded wagon: it hauls itself plus its cargo. */ export function grossWagonWeightTons(slot: Pick): number { return num(slot.tareWeightTons) + num(slot.cargoTons); @@ -260,11 +281,34 @@ export function minLocomotiveLimits( }; } -function minConfigured(values: Array): number { +function minConfigured(values: Array): number { const configured = values.filter((v) => v != null).map((v) => num(v)); return configured.length ? Math.min(...configured) : 0; } +/** + * Effective limits for a whole train set: min across its linked locomotives, + * falling back to the legacy single `locomotive` column for sets created + * before multi-loco support. Null when the set has no locomotive at all. + */ +export function trainSetLocomotiveLimits( + trainSet?: { + locomotive?: LocomotiveLimits | null; + locomotives?: Array<{ locomotive?: LocomotiveLimits | null }> | null; + } | null, +): LocomotiveLimits | null { + if (!trainSet) return null; + const linked = (trainSet.locomotives ?? []) + .map((link) => link.locomotive) + .filter((l): l is LocomotiveLimits => Boolean(l)); + const pool = linked.length + ? linked + : trainSet.locomotive + ? [trainSet.locomotive] + : []; + return minLocomotiveLimits(pool); +} + /** Per-booking train length from wagon count and freight-specific wagon type length. */ export function bookingTrainLengthMeters( freightType: string | null | undefined, 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 4a26236b0..03b293e4c 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 @@ -122,6 +122,7 @@ import { roundTons, sumWagonsRequired, type TrainLimitConfig, + maxEdgeConsistUsage, validateContainerPlacements, validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, @@ -130,9 +131,12 @@ import { import { deriveScheduleDirection } from './derive-schedule-direction.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; import { + bookingCargoTons, deriveTrainCapacityFromLocomotive, minLocomotiveLimits, + trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, + LocomotiveLimits, WagonTypeDimensions, } from './train-capacity.util'; import { @@ -1706,25 +1710,36 @@ export class TrainSchedulingService { relations: { wagonType: true }, }) : null; - const planTareTons = consistWagons + const planTareTons = roundTons( + wagonPlan.reduce((sum, slot) => sum + Number(slot.tareWeightTons ?? 0), 0), + ); + const consistTareTons = 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); + : planTareTons; + // The pull limit binds on the HEAVIEST LEG, not the whole-route sum — + // disjoint legs (intercity Gelan→Adama + export Adama→Doraleh) are never + // hauled at the same time. Coupled-but-unplanned wagons ride every edge, + // so their tare rides on top of the binding edge. + const emptyConsistTareTons = Math.max(0, consistTareTons - planTareTons); + const edgeUsage = maxEdgeConsistUsage( + wagonPlan, + await this.stopYardsForSchedule(schedule), + ); + const grossWeightTons = roundTons(edgeUsage.grossWeightTons + emptyConsistTareTons); if (!dto.forceAssign && weightCapWithOverage < grossWeightTons) { throw new BadRequestException( - `Train set locomotives cannot pull ${grossWeightTons}T gross (${totalWeightTons}T cargo + ${planTareTons}T wagon tare)`, + `Train set locomotives cannot pull ${grossWeightTons}T gross on the heaviest leg (limit ${roundTons(weightCapWithOverage)}T incl. tolerance)`, ); } - if (!dto.forceAssign && lengthCapWithOverage < totalLengthMeters) { + const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); + if (!dto.forceAssign && lengthCapWithOverage < maxEdgeLengthMeters) { throw new BadRequestException( - `Train set locomotives cannot support ${totalLengthMeters}m`, + `Train set locomotives cannot support ${maxEdgeLengthMeters}m`, ); } @@ -4081,9 +4096,6 @@ export class TrainSchedulingService { } const totalWeightTons = totalAssignedWeight(fittingBookings); - // Every weight limit below (global max, loco pull) is a GROSS axis, so the - // figure spent against it must be gross too — cargo alone under-reports the - // train by the full consist tare and disagrees with the assign path. const totalTareTons = roundTons( wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0), ); @@ -4091,12 +4103,13 @@ export class TrainSchedulingService { const totalLengthMeters = roundTons( wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0), ); - if (grossWeightTons > trainLimits.maxWeightTons) { - const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`; - if (!violations.includes(message) && !warnings.includes(message)) { - pushLimit([message]); - } - } + // Weight/length limits are enforced PER EDGE by validateMixedTrainLimitsPerEdge + // above — the whole-route totals here are informational (summary) only. The + // locomotive checks below also compare the heaviest single edge: a train is + // never heavier than its heaviest leg, so disjoint legs must not be summed. + const edgeUsage = maxEdgeConsistUsage(wagonPlan, stops); + const maxEdgeGrossTons = roundTons(edgeUsage.grossWeightTons); + const maxEdgeLengthMeters = roundTons(edgeUsage.lengthMeters); let assignedLocomotives: Locomotive[] = []; if (targetScheduleId) { @@ -4119,9 +4132,9 @@ export class TrainSchedulingService { if ( setLimits && (setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) < - grossWeightTons || + maxEdgeGrossTons || setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) < - totalLengthMeters) + maxEdgeLengthMeters) ) { pushLimit([ 'Assigned locomotives cannot support the total train weight and length', @@ -4140,9 +4153,9 @@ export class TrainSchedulingService { !inServiceLocomotives.some( (l) => Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >= - grossWeightTons && + maxEdgeGrossTons && Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >= - totalLengthMeters, + maxEdgeLengthMeters, ) ) { pushLimit(['No locomotive can support the total train weight and length']); @@ -4201,10 +4214,7 @@ export class TrainSchedulingService { maxTrainLengthMeters?: number; maxWagonsPerTrain?: number; }, - locomotive?: Pick< - Locomotive, - 'maxPullWeightTons' | 'maxTrainLengthMeters' | 'overageToleranceTons' | 'overageToleranceMeters' - >, + locomotive?: LocomotiveLimits | null, ): Promise> { const row = await this.loadGlobalRulesRow(); const configured = this.configService?.get<{ @@ -6512,7 +6522,7 @@ export class TrainSchedulingService { >, tareDims: Awaited>, ): number { - const cargo = Number(booking.cargoTotalWeightVgm ?? 0); + const cargo = bookingCargoTons(booking); const fallback = booking.freightType === 'BULK' ? tareDims.bulk : tareDims.container; // Same first-configured-type resolution the batch engine's dimsFor uses. @@ -6878,6 +6888,18 @@ export class TrainSchedulingService { // Ordered corridor stops (route milestones; falls back to the two // endpoints) — lets the UI draw per-segment occupancy and label legs. stops: this.mapScheduleStops(schedule), + // Gross ceiling the validator holds each leg to: the set's weakest + // locomotive pull limit plus its overage tolerance. Booking weightTons + // above are gross too, so the strip can sum them per leg against this. + maxGrossWeightTons: (() => { + const setLimits = trainSetLocomotiveLimits(schedule.trainSet); + return setLimits + ? roundTons( + Number(setLimits.maxPullWeightTons) + + (Number(setLimits.overageToleranceTons) || 0), + ) + : null; + })(), // True when the wagon plan above is served from the frozen snapshot (schedule // is dispatched/arrived/cancelled) rather than the live joins — the UI can badge // it "historical" and skip re-pin affordances. @@ -6973,7 +6995,10 @@ export class TrainSchedulingService { originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, }; - const limits = await this.resolveTrainLimitConfig(undefined, schedule.trainSet.locomotive); + const limits = await this.resolveTrainLimitConfig( + undefined, + trainSetLocomotiveLimits(schedule.trainSet), + ); const validation = await this.validateBookingsForScheduling( previewDto, @@ -7099,7 +7124,7 @@ export class TrainSchedulingService { }; const limits = await this.resolveTrainLimitConfig( undefined, - schedule.trainSet.locomotive, + trainSetLocomotiveLimits(schedule.trainSet), ); let validation: Awaited>; @@ -7643,7 +7668,7 @@ export class TrainSchedulingService { }; const limits = await this.resolveTrainLimitConfig( undefined, - schedule.trainSet.locomotive, + trainSetLocomotiveLimits(schedule.trainSet), ); let validation: Awaited>; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts index c3d48f286..a7d430b91 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts @@ -9,6 +9,7 @@ import { containerWagonsForLines, expandBookingContainerUnits, expandContainerItems, + maxEdgeConsistUsage, roundTons, sumWagonsRequired, validate20ftContainerRules, @@ -279,3 +280,54 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () => expect(containerWagonsForLines([])).toBe(0); }); }); + +describe('maxEdgeConsistUsage — the binding edge, not the whole-route sum', () => { + const slot = ( + tare: number, + cargo: number, + length: number, + board?: string | null, + alight?: string | null, + ) => + ({ + tareWeightTons: tare, + assignedWeightTons: cargo, + lengthMeters: length, + boardYardId: board ?? null, + alightYardId: alight ?? null, + }) as never; + + const stops = ['a', 'b', 'c']; + + it('does not sum disjoint legs: intercity a→b + export b→c', () => { + const plan = [ + slot(24, 65, 14, null, 'b'), // intercity, rides a→b only + slot(24, 65, 14, 'b', null), // export, rides b→c only + ]; + // Each edge carries one slot: 89T gross / 14m — never 178T. + expect(maxEdgeConsistUsage(plan, stops)).toEqual({ + grossWeightTons: 89, + lengthMeters: 14, + }); + }); + + it('sums overlapping legs on their shared edge (the S-2026-00024 shape)', () => { + // 20 intercity a→b wagons + 20 export a→c wagons, 23.94T tare, 64.75T cargo: + // shared edge a→b carries all 40 slots = 3547.6T gross. + const plan = [ + ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, 'b')), + ...Array.from({ length: 20 }, () => slot(23.94, 64.75, 14, null, null)), + ]; + const usage = maxEdgeConsistUsage(plan, stops); + expect(usage.grossWeightTons).toBeCloseTo(3547.6, 1); + expect(usage.lengthMeters).toBe(560); + }); + + it('degrades to whole-train totals on a two-stop route', () => { + const plan = [slot(24, 65, 14), slot(24, 65, 14)]; + expect(maxEdgeConsistUsage(plan, ['a', 'b'])).toEqual({ + grossWeightTons: 178, + lengthMeters: 28, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts index 1aa555531..84a2dc1f5 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts @@ -539,15 +539,9 @@ export function validateMixedTrainLimitsPerEdge( stops: string[], ): string[] { if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); - const lastIdx = stops.length - 1; - const spans = wagonPlan.map((slot) => { - const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; - const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; - // A yard missing from the stop list keeps the slot on the whole route. - return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; - }); + const spans = slotSpans(wagonPlan, stops); const violations = new Set(); - for (let edge = 0; edge < lastIdx; edge += 1) { + for (let edge = 0; edge < stops.length - 1; edge += 1) { const active = wagonPlan.filter( (_, i) => spans[i].from <= edge && edge < spans[i].to, ); @@ -559,6 +553,52 @@ export function validateMixedTrainLimitsPerEdge( return [...violations]; } +/** Per-slot stop-index spans; a yard missing from the stop list keeps the slot on the whole route. */ +function slotSpans( + wagonPlan: WagonPlanSlot[], + stops: string[], +): Array<{ from: number; to: number }> { + const lastIdx = stops.length - 1; + return wagonPlan.map((slot) => { + const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0; + const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : lastIdx; + return { from: from >= 0 ? from : 0, to: to > 0 ? to : lastIdx }; + }); +} + +/** + * The corridor's binding edge: gross tons (tare + assigned cargo) and length + * summed over only the slots riding each edge, maxed across edges. This is the + * figure a locomotive pull/length limit must be compared against — a train is + * never heavier than its heaviest single leg, so summing disjoint legs + * (intercity Gelan→Adama + export Adama→Doraleh) over-reports the train. + * Two stops or fewer degrade to the whole-train totals. + */ +export function maxEdgeConsistUsage( + wagonPlan: WagonPlanSlot[], + stops: string[], +): { grossWeightTons: number; lengthMeters: number } { + const totals = (slots: WagonPlanSlot[]) => ({ + grossWeightTons: slots.reduce( + (sum, w) => + sum + Number(w.tareWeightTons ?? 0) + Number(w.assignedWeightTons ?? 0), + 0, + ), + lengthMeters: slots.reduce((sum, w) => sum + Number(w.lengthMeters ?? 0), 0), + }); + if (stops.length <= 2) return totals(wagonPlan); + const spans = slotSpans(wagonPlan, stops); + const usage = { grossWeightTons: 0, lengthMeters: 0 }; + for (let edge = 0; edge < stops.length - 1; edge += 1) { + const active = totals( + wagonPlan.filter((_, i) => spans[i].from <= edge && edge < spans[i].to), + ); + usage.grossWeightTons = Math.max(usage.grossWeightTons, active.grossWeightTons); + usage.lengthMeters = Math.max(usage.lengthMeters, active.lengthMeters); + } + return usage; +} + export function validate20ftContainerRules( units: ContainerUnitRow[], placements: ContainerPlacementInput[], 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 babf11ef1..19d631fbc 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 @@ -15,6 +15,7 @@ import { import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; @@ -27,12 +28,12 @@ import { TrainBuilderService } from './train-builder.service'; @ApiTags('train-builder') @ApiBearerAuth() @Controller('train-builder') -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.create) @ApiOperation({ summary: 'Build a train: code + yard + 2+ locomotives (+ optional wagons)' }) build(@Body() dto: BuildTrainDto) { return this.trainBuilderService.buildTrain(dto); @@ -60,7 +61,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Replace the locomotive set (minimum 2, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -70,7 +71,7 @@ export class TrainBuilderController { } @Patch(':id/details') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: "Edit the train's name and fixed import/export run numbers", }) @@ -82,7 +83,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -91,14 +92,14 @@ export class TrainBuilderController { } @Post(':id/wagons') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) { return this.trainBuilderService.assignWagons(id, dto); } @Delete(':id/wagons/:wagonId') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Detach one wagon from the consist' }) removeWagon( @Param('id', ParseUUIDPipe) id: string, @@ -108,7 +109,7 @@ export class TrainBuilderController { } @Post(':id/wagons/:wagonId/maintenance') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' }) sendWagonToMaintenance( @Param('id', ParseUUIDPipe) id: string, @@ -118,14 +119,14 @@ export class TrainBuilderController { } @Post(':id/reorder-wagons') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Persist a drag-reorder of the full consist' }) reorderWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReorderTrainWagonsDto) { return this.trainBuilderService.reorderWagons(id, dto); } @Post(':id/deactivate') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -134,14 +135,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.delete) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index 0217bc161..173a738fa 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -12,18 +12,19 @@ import { import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { FleetManage, FleetView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { CreateTrainDto } from "./dto/create-train.dto"; import { UpdateTrainDto } from "./dto/update-train.dto"; import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainsController { constructor(private readonly trainsService: TrainsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.create) @ApiOperation({ summary: "Register a new train" }) create(@Body() dto: CreateTrainDto) { return this.trainsService.create(dto); @@ -42,14 +43,14 @@ export class TrainsController { } @Patch(":id") - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.update) @ApiOperation({ summary: "Update a train" }) update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { return this.trainsService.update(id, dto); } @Delete(":id") - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.delete) @ApiOperation({ summary: "Delete a train" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.trainsService.remove(id); 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 d6925b71b..b00e4a64f 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.controller.ts @@ -19,6 +19,7 @@ import { WagonTransferHistoryAll, WagonTransferRequest, } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto'; import { CreateTransferRequestDto } from './dto/create-transfer-request.dto'; import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto'; @@ -31,7 +32,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service' */ @ApiTags('wagon-transfer-requests') @Controller('wagon-transfer-requests') -@FleetView() +@FleetView(FREIGHT_PERMS.wagons.view) export class WagonTransferRequestsController { constructor(private readonly service: WagonTransferRequestsService) {} @@ -110,7 +111,7 @@ export class WagonTransferRequestsController { } @Post(':id/cancel') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.transferRequest) @ApiOperation({ summary: 'Withdraw a pending transfer request' }) cancel(@Param('id', ParseUUIDPipe) id: string) { return this.service.cancelRequest(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 556907cde..2ef7f00a5 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -12,7 +12,8 @@ import { import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; -import { FleetManage, FleetView } from '../../common/booking-guards'; +import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -23,31 +24,36 @@ import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @ApiTags('wagons') +// No class-level guard: reads (list, by-id, movements) are login-only reference +// data — any staff can fetch wagon data for a cross-flow view without the +// fleet:view that drives the Fleet sidebar. Every mutation has its @FleetManage(). @Controller('wagons') -@FleetView() export class WagonsController { constructor(private readonly wagonsService: WagonsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.create) @ApiOperation({ summary: 'Create a new wagon' }) create(@Body() dto: CreateWagonDto) { return this.wagonsService.create(dto); } @Get() + @StaffReference() @ApiOperation({ summary: 'List all wagons' }) findAll(@Query() query: ListWagonsQueryDto) { return this.wagonsService.findAll(query); } @Get(':id') + @StaffReference() @ApiOperation({ summary: 'Get a wagon by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.findById(id); } @Get(':id/movements') + @StaffReference() @ApiOperation({ summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first", }) @@ -56,42 +62,42 @@ export class WagonsController { } @Patch(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Update a wagon' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { return this.wagonsService.update(id, dto); } @Delete(':id') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.delete) @ApiOperation({ summary: 'Delete a wagon' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.remove(id); } @Post(':id/assign-train') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Assign wagon to a train' }) assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { return this.wagonsService.assignToTrain(id, dto); } @Post(':id/unassign-train') - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Unassign wagon from train' }) unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); } @Post('bulk-transfer') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { return this.wagonsService.bulkTransfer(dto, user?.id); } @Post('bulk-status') - @FleetManage() + @FleetManage(FREIGHT_PERMS.wagons.update) @ApiOperation({ summary: 'Set the status of multiple wagons' }) bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { return this.wagonsService.bulkSetStatus(dto); @@ -100,12 +106,12 @@ export class WagonsController { // Separate controller for train‑specific reorder (registered in module) @Controller('trains/:trainId/reorder-wagons') -@FleetView() +@FleetView(FREIGHT_PERMS.trains.view) export class TrainWagonsReorderController { constructor(private readonly wagonsService: WagonsService) {} @Post() - @FleetManage() + @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: 'Reorder wagons of a train' }) reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { return this.wagonsService.reorderWagons(trainId, dto); 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 c90fb5a16..85f66b74f 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 @@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity { /** EDR last-mile: when the goods were delivered to the customer. */ @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) deliveredAt?: Date | null; + + /** URL to the signer's saved signature image, if available at sign time. */ + @Column({ name: 'signature_image_url', type: 'text', nullable: true }) + signatureImageUrl?: 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 81f83a57a..d7bb881cd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -287,6 +287,7 @@ export class HandoverService { handoverId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { const repo = this.dataSource.getRepository(BookingHandover); const handover = await repo.findOne({ where: { id: handoverId } }); @@ -297,6 +298,7 @@ export class HandoverService { handover.signedAt = new Date(); handover.signedByUserId = userId ?? null; handover.signerName = signerName?.trim() || null; + handover.signatureImageUrl = signatureImageUrl ?? null; return repo.save(handover); } @@ -305,6 +307,7 @@ export class HandoverService { bookingId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { await this.dataSource .getRepository(BookingHandover) @@ -314,6 +317,7 @@ export class HandoverService { signedAt: new Date(), signedByUserId: userId ?? null, signerName: signerName?.trim() || null, + signatureImageUrl: signatureImageUrl ?? null, }, ); } 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 8e9e0bc76..582face93 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 @@ -2504,27 +2504,37 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - let document = await this.interchangeDocuments.generateFromSchedule({ - scheduleId, - direction: 'EXPORT', - handoverLocation: schedule.destinationName ?? 'Djibouti Port', - handoverFrom: 'EDR', - handoverTo: 'Djibouti Port Operator', - portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy ?? 'EDR Operations', - remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', - }); - if (document.status !== 'ACKNOWLEDGED') { - document = await this.interchangeDocuments.acknowledge(document.id, { - acknowledgedBy: 'Djibouti Port Operator', - remarks: 'Auto acknowledged after Djibouti export unloading.', + // Best-effort: the unload is already committed — a paperwork failure must + // not fail the response (it did once: items unloaded, request 500'd, and + // the document only appeared after a manual retry days later). The doc + // backfills on any retry since already-unloaded items count as unloaded. + try { + let document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } catch (err) { + this.logger.warn( + `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`, + ); } - result.interchangeDocument = { - id: document.id, - documentNo: document.documentNo, - status: document.status, - }; } return result; @@ -4087,10 +4097,11 @@ export class WarehouseInventoryService { await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); + const signatureImageUrl = signature?.signatureImageUrl ?? null; const approval = { approvedAt: approvedAt.toISOString(), signerDisplayName: name, - signatureImageUrl: signature?.signatureImageUrl ?? null, + signatureImageUrl, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -4114,7 +4125,7 @@ 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, name); + await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl); return { bookingId, @@ -4149,6 +4160,8 @@ export class WarehouseInventoryService { throw new BadRequestException('Please enter your full name to sign the handover'); } + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [h]: Array<{ bookingId: string; reference: string; @@ -4181,7 +4194,7 @@ export class WarehouseInventoryService { ); if (inv) await this.invoices.assertClearanceAllowed(inv.id); - const signed = await this.handover.sign(handoverId, userId, name); + const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null); const allSigned = await this.handover.isFullySigned(h.bookingId); if (inv) { 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 741ceafb1..9871f6980 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -70,9 +70,15 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ */ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ perm('a3000001-0001-4000-8000-000000000001', 'edr_freight_app:contracts:view', 'View contracts'), - perm('a3000001-0001-4000-8000-000000000002', 'edr_freight_app:contracts:staff_accept', 'Accept contract intake'), - perm('a3000001-0001-4000-8000-000000000003', 'edr_freight_app:contracts:request_changes', 'Request contract changes'), - perm('a3000001-0001-4000-8000-000000000004', 'edr_freight_app:contracts:reject', 'Reject contract'), + // Intake actions are split per freight type (bulk vs container) — fresh ids + // because the seeder upserts ON CONFLICT (key); reusing the old ids with new + // keys would PK-collide with the legacy staff_accept/request_changes/reject rows. + perm('a3000001-0001-4000-8000-000000000011', 'edr_freight_app:contracts:staff_accept:bulk', 'Accept bulk contract intake'), + perm('a3000001-0001-4000-8000-000000000012', 'edr_freight_app:contracts:staff_accept:container', 'Accept container contract intake'), + perm('a3000001-0001-4000-8000-000000000013', 'edr_freight_app:contracts:request_changes:bulk', 'Request bulk contract changes'), + perm('a3000001-0001-4000-8000-000000000014', 'edr_freight_app:contracts:request_changes:container', 'Request container contract changes'), + perm('a3000001-0001-4000-8000-000000000015', 'edr_freight_app:contracts:reject:bulk', 'Reject bulk contract'), + perm('a3000001-0001-4000-8000-000000000016', 'edr_freight_app:contracts:reject:container', 'Reject container contract'), perm('a3000001-0001-4000-8000-000000000005', 'edr_freight_app:contracts:approve_line_staff', 'Approve contract as line staff'), perm('a3000001-0001-4000-8000-000000000006', 'edr_freight_app:contracts:approve_director', 'Approve contract as director'), perm('a3000001-0001-4000-8000-000000000007', 'edr_freight_app:contracts:approve_ceo', 'Approve contract as CEO'), @@ -212,6 +218,10 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ perm('e1f00001-0001-4000-8000-000000000002', 'edr_freight_app:cargoes:create', 'Create cargo'), perm('e1f00001-0001-4000-8000-000000000003', 'edr_freight_app:cargoes:update', 'Update cargo'), perm('e1f00001-0001-4000-8000-000000000004', 'edr_freight_app:cargoes:delete', 'Delete cargo'), + // NB: id prefixes must stay hex — 'e1g…' once crashed the boot seeder + // (postgres: invalid input syntax for type uuid). + perm('e1900001-0001-4000-8000-000000000001', 'edr_freight_app:consignments:view', 'View consignments'), + perm('e1900001-0001-4000-8000-000000000002', 'edr_freight_app:consignments:create', 'Create consignment'), ]; // G. Fleet — road & telemetry @@ -302,8 +312,6 @@ export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ // L. Administration & settings (split from the coarse admin umbrella) export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ - perm('b3a00001-0001-4000-8000-000000000001', 'edr_freight_app:config:contract_validity:view', 'View contract validity periods'), - perm('b3a00001-0001-4000-8000-000000000002', 'edr_freight_app:config:contract_validity:manage', 'Manage contract validity periods'), perm('b4a00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:file_upload:view', 'View file-upload settings'), perm('b4a00001-0001-4000-8000-000000000002', 'edr_freight_app:settings:file_upload:manage', 'Manage file-upload settings'), perm('b4b00001-0001-4000-8000-000000000001', 'edr_freight_app:settings:dropdown:view', 'View dropdown settings'), @@ -371,9 +379,18 @@ export const FREIGHT_PERMS = { }, contracts: { view: 'edr_freight_app:contracts:view', - staffAccept: 'edr_freight_app:contracts:staff_accept', - requestChanges: 'edr_freight_app:contracts:request_changes', - reject: 'edr_freight_app:contracts:reject', + staffAccept: { + bulk: 'edr_freight_app:contracts:staff_accept:bulk', + container: 'edr_freight_app:contracts:staff_accept:container', + }, + requestChanges: { + bulk: 'edr_freight_app:contracts:request_changes:bulk', + container: 'edr_freight_app:contracts:request_changes:container', + }, + reject: { + bulk: 'edr_freight_app:contracts:reject:bulk', + container: 'edr_freight_app:contracts:reject:container', + }, approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', @@ -495,6 +512,10 @@ export const FREIGHT_PERMS = { update: 'edr_freight_app:cargoes:update', delete: 'edr_freight_app:cargoes:delete', }, + consignments: { + view: 'edr_freight_app:consignments:view', + create: 'edr_freight_app:consignments:create', + }, vehicles: { view: 'edr_freight_app:vehicles:view', create: 'edr_freight_app:vehicles:create', @@ -594,12 +615,6 @@ export const FREIGHT_PERMS = { cancel: 'edr_freight_app:warehouse_fee_invoices:cancel', pay: 'edr_freight_app:warehouse_fee_invoices:pay', }, - config: { - contractValidity: { - view: 'edr_freight_app:config:contract_validity:view', - manage: 'edr_freight_app:config:contract_validity:manage', - }, - }, settings: { fileUpload: { view: 'edr_freight_app:settings:file_upload:view', @@ -661,9 +676,56 @@ export const FREIGHT_PERMS = { }, } as const; +/** Both arms of a freight-type-split permission (for one-of route guards). */ +export const bothFreightTypes = (p: { bulk: string; container: string }): string[] => [ + p.bulk, + p.container, +]; + +/** The arm of a freight-type-split permission matching a contract's freightType. */ +export const forFreightType = ( + p: { bulk: string; container: string }, + freightType: string, +): string => (freightType === 'BULK' ? p.bulk : p.container); + const allRuleEngineViewKeys = () => RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); +/** + * Granular equivalents of the legacy fleet:view + fleet:manage pair. + * Deliberately excludes the wagon-transfer keys — those were always separate + * grants (requester vs OCC vs admin history), not part of fleet:manage. + */ +const FLEET_GRANULAR_KEYS: string[] = [ + FREIGHT_PERMS.locomotives.view, + FREIGHT_PERMS.locomotives.create, + FREIGHT_PERMS.locomotives.update, + FREIGHT_PERMS.locomotives.delete, + FREIGHT_PERMS.wagons.view, + FREIGHT_PERMS.wagons.create, + FREIGHT_PERMS.wagons.update, + FREIGHT_PERMS.wagons.delete, + FREIGHT_PERMS.trains.view, + FREIGHT_PERMS.trains.create, + FREIGHT_PERMS.trains.update, + FREIGHT_PERMS.trains.delete, + FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.routes.view, + FREIGHT_PERMS.routes.create, + FREIGHT_PERMS.routes.update, + FREIGHT_PERMS.routes.delete, + FREIGHT_PERMS.containers.view, + FREIGHT_PERMS.containers.create, + FREIGHT_PERMS.containers.update, + FREIGHT_PERMS.containers.delete, + FREIGHT_PERMS.cargoes.view, + FREIGHT_PERMS.cargoes.create, + FREIGHT_PERMS.cargoes.update, + FREIGHT_PERMS.cargoes.delete, + FREIGHT_PERMS.consignments.view, + FREIGHT_PERMS.consignments.create, +]; + export const ROLE_PERMISSION_PRESETS = { // Marketing / line staff: drives a booking from intake through line-staff // approval and contract generation/signing — i.e. until the contract is ready @@ -677,9 +739,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.contracts.view, - FREIGHT_PERMS.contracts.staffAccept, - FREIGHT_PERMS.contracts.requestChanges, - FREIGHT_PERMS.contracts.reject, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), + ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, ...allRuleEngineViewKeys(), ], @@ -692,6 +754,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.manage, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, + ...FLEET_GRANULAR_KEYS, // Path A (no customs): Operations reviews the customer's self-clearance docs // — on the contract for ONE_TIME contracts, and PER BOOKING for GENERAL // contracts (booking-level document review → finalize → CLEARANCE_READY). @@ -768,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.contracts.view, - FREIGHT_PERMS.contracts.staffAccept, - FREIGHT_PERMS.contracts.requestChanges, - FREIGHT_PERMS.contracts.reject, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), + ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.signStaff, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a36ca98b3..04a26e3fe 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Routes", href: "/dashboard/routes", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view], }, { label: "Train Builder", href: "/dashboard/train-builder", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], }, // { @@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Wagons", href: "/dashboard/wagons", icon: , - permission: FREIGHT_PERMS.fleet.view, + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], }, { label: "Vehicles", @@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), - { - label: "Contract validity", - href: "/dashboard/configuration/contract-validity-periods", - permission: FREIGHT_PERMS.config.contractValidity.view, - }, { label: "Train scheduling rules", href: "/dashboard/configuration/train-scheduling-rules", @@ -586,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance"; const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance"; +// Routes a GL officer may reach beyond their clearance hub. Path B booking is +// part of their job (create/rebook under a cleared contract, then view that +// booking's clearance), but those routes live outside the clearance prefix — +// without this allowlist the single-prefix lock bounces them out of their own +// workflow. Matched against location.pathname (no query string). +const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [ + /^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/, + /^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/, +]; + const isEtClearanceItem = (item: SidebarItem): boolean => item.href === ET_CLEARANCE_HREF; const isDjClearanceItem = (item: SidebarItem): boolean => @@ -721,7 +726,11 @@ const DashboardShell = () => { document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE; }, [location.pathname, sidebarSections]); - if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) { + if ( + glClearanceHome && + !location.pathname.startsWith(glClearanceHome) && + !GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname)) + ) { return ; } @@ -1104,7 +1113,7 @@ const App = () => { + } @@ -1112,7 +1121,7 @@ const App = () => { + } @@ -1120,7 +1129,7 @@ const App = () => { + } @@ -1128,7 +1137,7 @@ const App = () => { + } @@ -1136,7 +1145,7 @@ const App = () => { + } @@ -1144,7 +1153,7 @@ const App = () => { + } @@ -1152,7 +1161,7 @@ const App = () => { + } @@ -1160,7 +1169,7 @@ const App = () => { + } @@ -1168,7 +1177,7 @@ const App = () => { + } @@ -1254,7 +1263,7 @@ const App = () => { + } @@ -1342,7 +1351,7 @@ const App = () => { + } @@ -1350,7 +1359,7 @@ const App = () => { + } @@ -1358,7 +1367,7 @@ const App = () => { + } @@ -1366,7 +1375,7 @@ const App = () => { + } @@ -1374,7 +1383,7 @@ const App = () => { + } @@ -1382,7 +1391,7 @@ const App = () => { + } @@ -1390,7 +1399,7 @@ const App = () => { + } @@ -1398,7 +1407,7 @@ const App = () => { + } @@ -1465,14 +1474,14 @@ const App = () => { } /> - } - /> + /> */} } /> ("accept"); const [previewOpen, setPreviewOpen] = useState(false); @@ -97,7 +110,8 @@ export function ContractActionsToolbar({ ); } - const canAccept = status === "SUBMITTED"; + const canAccept = + status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject); // The document stays editable for the whole approval chain, but only by the // approver whose turn it is. The server resolves that against the caller's // position type; the client cannot derive it. @@ -126,35 +140,41 @@ export function ContractActionsToolbar({ {canAccept && ( <> - - - + {mayAccept && ( + + )} + {mayRequestChanges && ( + + )} + {mayReject && ( + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index dcdfe6e39..25f877de3 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -16,6 +16,8 @@ import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; +import { useAuth } from "@/auth/useAuth"; +import { canApproveContractStep } from "@/lib/permissions"; type Mutations = ReturnType; @@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({ contract, mutations, }: ContractApprovalStepsCardProps) { + const { user } = useAuth(); const [confirmOpen, setConfirmOpen] = useState(false); const [pendingStep, setPendingStep] = useState(null); @@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({ key={step.id} step={step} isNext={actionable && nextPending?.id === step.id} + // Buttons show only to the step's actual approver (matching + // position type): a chief step never offers Approve/Reject to a + // marketing officer. Everyone still sees the "next" highlight. + canAct={canApproveContractStep(user, step.requiredRole)} isPending={ mutations.approveStep.isPending || mutations.rejectStep.isPending @@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({ function StepRow({ step, isNext, + canAct, isPending, onApprove, onReject, }: { step: Freight.IContractApprovalStep; isNext: boolean; + canAct: boolean; isPending: boolean; onApprove: () => void; onReject: () => void; @@ -372,8 +381,11 @@ function StepRow({ )} - - {isNext && step.status === "PENDING" && ( + {/* One element type per row: action buttons on the active step (they + already imply "pending & actionable"), a status badge otherwise. + Mixing compact buttons + a badge here made them read as misaligned. */} + + {isNext && canAct && step.status === "PENDING" ? ( <>