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/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index af9b9428c..48eadb6c7 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { REEFER: 'Reefer (refrigerated) surcharge', WITH_RETURN: 'Empty-container return service', SHIPPING_LINE: 'Shipping line handling', - CONSOLIDATION: 'Container consolidation (extra document)', + CONSOLIDATION: 'Penalty (container consolidation)', LASHING: 'Cargo lashing and securing', CANCELLATION: 'Booking cancellation fee', DEMURRAGE: 'Demurrage / wagon detention', 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/migrations/2860000000000-DropClearanceFeePrepay.ts b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts new file mode 100644 index 000000000..9fd854b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customs clearance service fee is no longer prepaid via its own + * `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the + * booking invoice, together with the freight (see BookingPricingService). + * + * - Contracts/bookings parked at the payment gate move straight to the + * document step (the gate no longer exists — nothing could ever pay them). + * - Open (unpaid) clearance invoices are expired; PAID ones stay as history. + * NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but + * has not booked yet will be billed the fee again on its booking invoice — + * accepted for dev data; reverses the old AddClearanceFeePayment migration. + * - clearance_fee_paid_at columns are dropped from contracts and bookings. + */ +export class DropClearanceFeePrepay2860000000000 implements MigrationInterface { + name = 'DropClearanceFeePrepay2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.contracts + SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.contracts + SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE clearance_status = 'AWAITING_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.bookings + SET status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.invoices + SET status = 'EXPIRED', updated_at = now() + WHERE source = 'clearance' + AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'); + `); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Moved rows and expired invoices stay — only the columns come back. + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts new file mode 100644 index 000000000..be01e7825 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customs clearance fees are now sold per cargo kind: container fees name a + * container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type + * (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be + * mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the + * base-freight and return-surcharge reshapes, kept readable for snapshot + * history. Per-kind replacements must be re-entered; a customs contract or + * booking without a matching fee hard-blocks. Contracts that already froze a + * FLAT snapshot keep billing it (legacy honoured at booking pricing). + */ +export class CustomsClearancePerKind2870000000000 implements MigrationInterface { + name = 'CustomsClearancePerKind2870000000000'; + + 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 rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts new file mode 100644 index 000000000..9d311c60f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now sold per cargo kind, like the customs clearance fee: + * container rates name a container type (PER_CONTAINER / PER_WAGON), bulk + * rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape + * cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept + * readable for snapshot history. Per-kind replacements must be re-entered; + * an unconfigured lashing rate simply bills nothing (lenient, like + * hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates + * share the LASHING rate_type and must survive. + */ +export class LashingPerKind2880000000000 implements MigrationInterface { + name = 'LashingPerKind2880000000000'; + + 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 "trigger" = 'LASHING' + AND rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts new file mode 100644 index 000000000..8904c6194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT), + * optionally narrowed to one leaf commodity. Rates that no longer fit — + * container-scoped, or carrying no direction — cannot be mapped and are + * retired (SUPERSEDED + soft-deleted), kept readable for snapshot history. + * Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING). + */ +export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface { + name = 'LashingBulkOnlyPerDirection2890000000000'; + + 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 "trigger" = 'LASHING' + AND (container_type_id IS NOT NULL + OR trade_direction IS NULL + OR trade_direction NOT IN ('IMPORT', 'EXPORT')); + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-direction bulk rates instead. + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3d8222d63..82bb1144e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,7 +16,6 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; -import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -121,8 +120,7 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open invoices (freight PREPAID and the - * per-shipment clearance fee) when the booking is + * Expire the booking's currently-open freight (PREPAID) invoice when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no @@ -133,15 +131,6 @@ export class BookingInvoiceService { bookingId: string, manager?: EntityManager, ): Promise { - // The per-shipment clearance fee (GENERAL contracts) bills this same booking - // id under its own source/type — retire it alongside the freight invoice, or - // a cancelled shipment keeps a payable clearance invoice open. - await this.billing.expirePayable( - Freight.InvoiceSource.Clearance, - bookingId, - CLEARANCE_BOOKING_INVOICE_TYPE, - manager, - ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index fecd9111a..667abe842 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => { ratesService as never, exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + {} as never, ); }); @@ -240,3 +241,235 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.blocked[0]).toContain('rate is configured'); }); }); + +describe('BookingPricingService — customs clearance fee billed on the booking price', () => { + const DJ = 'yard-dj'; + + const containerFee20: Rate = { + id: 'rate-cc-20', + rateType: 'CUSTOMS_CLEARANCE', + trigger: 'CUSTOMS_CLEARANCE', + currency: 'USD', + rateValue: 100, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: 'ct-20', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: DIRE, + } as Rate; + + const bulkFeePerTon: Rate = { + ...containerFee20, + id: 'rate-cc-bulk', + rateValue: 5, + rateUnit: 'PER_TON', + containerTypeId: null, + } as Rate; + + const emptyEval = { + priorityScore: 0, + appliedModifiers: [], + containerWeightResults: [], + warnings: [], + hardBlocked: [], + requiresDirectorApproval: false, + }; + + const makeService = (opts: { + snapshots?: unknown[]; + liveRates?: Rate[]; + wagonCapacity?: number; + }) => + new BookingPricingService( + { + calculateWagonCount: jest.fn().mockResolvedValue(0), + findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []), + } as never, + { evaluate: jest.fn().mockResolvedValue(emptyEval) } as never, + { + findById: jest.fn(async (id: string) => ({ + id, + sizeFt: id === 'ct-40' ? 40 : 20, + isReefer: false, + code: id === 'ct-40' ? 'C40' : 'C20', + })), + } as never, + { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + findById: jest.fn().mockResolvedValue({ + wagonTypes: + opts.wagonCapacity !== undefined + ? [{ capacityTons: opts.wagonCapacity }] + : [], + }), + } as never, + ); + + const containerBooking = (overrides: Record = {}) => + ({ + id: 'b-cc', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 }, + ], + ...overrides, + }) as unknown as Booking; + + const bulkBooking = (overrides: Record = {}) => + ({ + id: 'b-cc-bulk', + freightType: 'BULK', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + cargoTypeId: 'cargo-1', + cargoTotalWeightVgm: 120, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [], + ...overrides, + }) as unknown as Booking; + + it('bills a container booking per box at its own container type fee', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.unit).toBe('PER_CONTAINER'); + expect(line!.quantity).toBe(4); + expect(line!.amount).toBe(400); + }); + + it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => { + const service = makeService({ + liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); + expect(line!.amount).toBe(200); + }); + + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(containerBooking()); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true); + }); + + it('bills a bulk booking per ton at the route bulk fee', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_TON'); + expect(line!.quantity).toBe(120); + expect(line!.amount).toBe(600); + }); + + it('the fee scoped to the booking commodity wins over the catch-all', async () => { + const service = makeService({ + liveRates: [ + { ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate, + { + ...bulkFeePerTon, + id: 'rate-cc-sugar', + rateValue: 9, + cargoTypeId: 'cargo-1', + } as Rate, + ], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all + expect(line!.amount).toBe(1080); + }); + + it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate], + wagonCapacity: 60, + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon + expect(line!.amount).toBe(100); + }); + + it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true); + }); + + it('prefers the contract frozen per-size snapshot over the live rate', async () => { + const service = makeService({ + liveRates: [containerFee20], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE_20FT', + unitPrice: 80, + currency: 'USD', + unitOfMeasure: 'per_container', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-1' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100 + }); + + it('honours a legacy FLAT snapshot once for the whole container booking', async () => { + const service = makeService({ + liveRates: [], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE', + unitPrice: 500, + currency: 'USD', + unitOfMeasure: 'flat', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-legacy' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('FLAT'); + expect(line!.amount).toBe(500); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false); + }); + + it('adds no fee line when customs clearing is disabled', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking( + containerBooking({ customsClearingEnabled: false }), + ); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + }); +}); 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..89825e100 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 @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; @@ -10,7 +11,10 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; -import { containersPerWagonForSize } from '../rule-engine/container-type.util'; +import { + containersPerWagonForSize, + wagonsPerUnitForSize, +} from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -76,6 +80,7 @@ export class BookingPricingService { private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, + private readonly cargoTypesService: CargoTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -165,8 +170,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 +195,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 @@ -215,6 +228,23 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Customs clearance service fee (Path B) — billed HERE, on the booking + // invoice with the freight; no separate prepaid clearance invoice. Sold per + // cargo kind: container bookings bill each container type's own fee (per + // box or per wagon), bulk bookings the route's bulk fee (per ton or per + // wagon). Frozen contract snapshots win over live rates; a customs booking + // with nothing configured hard-blocks — clearance never ships for free. + const clearanceBlocked: string[] = []; + if (booking.customsClearingEnabled) { + const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates); + for (const line of clearance.lineItems) { + lineItems.push(line); + total += line.amount; + } + for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate); + clearanceBlocked.push(...clearance.blocked); + } + // Overweight detail for the customer: map the engine's per-line results back // to the booking's container lines (same order) for code + weights. maxAllowed // is derived from the line total minus the excess the engine computed. @@ -252,7 +282,7 @@ export class BookingPricingService { appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: [...ruleResult.warnings, ...baseWarnings], - hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked], overweightLines, }; } @@ -313,6 +343,8 @@ export class BookingPricingService { hazardousQuantity: Number(bc.hazardousQuantity ?? 0), reeferQuantity: Number(bc.reeferQuantity ?? 0), returnQuantity: Number(bc.returnQuantity ?? 0), + // Wagon share per box — a PER_WAGON empty-return rate bills on it. + wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt), }, perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, @@ -330,6 +362,13 @@ export class BookingPricingService { ), ) : 0; + // Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing). + // Deliberately NOT totalWagons — that would shift wagon-count priority + // scoring for bulk bookings. + const bulkWagons = + booking.freightType === 'BULK' + ? ((await this.bulkWagonCount(booking)) ?? 0) + : 0; // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever // a container type leaves a wagon partially filled. Aggregate by type first — @@ -369,6 +408,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. @@ -376,6 +417,7 @@ export class BookingPricingService { booking.freightType === 'BULK' ? Number(booking.cargoTotalWeightVgm ?? 0) : 0, + bulkWagons, containers, }; } @@ -884,6 +926,191 @@ export class BookingPricingService { return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); } + /** + * Customs clearance service fee lines for a customs booking (Path B), billed + * with the freight. Container bookings bill each container line at its own + * container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the + * line occupies (two 20ft share one). Bulk bookings bill the route's type-less + * fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen + * contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE) + * win over live rates; contracts frozen before the per-kind model carry one + * FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking. + */ + private async customsClearanceLines( + booking: Booking, + frozenRates: Map | null, + liveRates: Rate[], + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> { + const lineItems: PriceLineItemDto[] = []; + const usedRates: Rate[] = []; + const blocked: string[] = []; + const currency = booking.paymentCurrency; + const isEtb = currency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === booking.tradeDirection && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, + ); + const missingRateMessage = (scope: string): string => + `No customs clearance service fee is configured for ${scope} on this ` + + 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; + + if (booking.freightType === 'CONTAINER') { + // Legacy short-circuit: an old contract froze one flat fee — bill it once. + const hasPerSizeSnapshot = + frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || + frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + if (legacyFlat && !hasPerSizeSnapshot) { + const amount = Number(legacyFlat.unitPrice); + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service', + amount, + unitAmount: amount, + unit: 'FLAT', + quantity: 1, + currency, + }); + } + return { lineItems, usedRates, blocked }; + } + + for (const bc of booking.bookingContainers ?? []) { + if (!bc.containerTypeId) continue; + const qty = Number(bc.quantity || 0); + if (!(qty > 0)) continue; + let sizeFt = 0; + try { + sizeFt = + Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0; + } catch { + // unknown type — falls through to the live per-type lookup below + } + const frozen = sizeFt + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + : null; + const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`)); + continue; + } + const unit = frozen + ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) + : live!.rateUnit; + const unitAmount = frozen + ? Number(frozen.unitPrice) + : convert(Number(live!.rateValue)); + const billedQty = + unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty; + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (!(amount > 0)) continue; + lineItems.push({ + code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', + description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + // Bulk — one fee for the whole booking. The bulk snapshot and the legacy + // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. + // Live lookup: the rate scoped to the booking's commodity wins; a + // commodity-less rate (legacy) is the catch-all fallback. + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const live = + (booking.cargoTypeId + ? onLeg.find( + (r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId, + ) + : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage('bulk cargo')); + return { lineItems, usedRates, blocked }; + } + const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; + const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); + let billedQty = 1; + if (unit === 'PER_TON') { + billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); + } else if (unit === 'PER_WAGON') { + const wagons = await this.bulkWagonCount(booking); + if (wagons == null) { + blocked.push( + 'The bulk customs clearance fee is per wagon, but this cargo type has ' + + 'no wagon type with a capacity configured — the wagon count cannot ' + + 'be derived. Ask EDR to configure the cargo type’s wagon types.', + ); + return { lineItems, usedRates, blocked }; + } + billedQty = wagons; + } + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service (bulk)', + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + /** Snapshot unit-of-measure → the rate unit the billing math applies. */ + private rateUnitFromSnapshot(unitOfMeasure: string): string { + switch (unitOfMeasure) { + case 'per_wagon': + return 'PER_WAGON'; + case 'per_ton': + return 'PER_TON'; + case 'per_container': + return 'PER_CONTAINER'; + default: + return 'FLAT'; + } + } + + /** + * Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the + * largest-capacity wagon type its cargo type allows. Null when the chain is + * unconfigured (no cargo type, no wagon types, no capacity). + * ponytail: pricing-time estimate off the biggest allowed wagon; scheduling + * may stock a smaller type and use more wagons. + */ + private async bulkWagonCount(booking: Booking): Promise { + const tons = Number(booking.cargoTotalWeightVgm ?? 0); + if (!(tons > 0) || !booking.cargoTypeId) return null; + try { + const cargo = await this.cargoTypesService.findById(booking.cargoTypeId); + const capacity = Math.max( + 0, + ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), + ); + if (!(capacity > 0)) return null; + return Math.max(1, Math.ceil(tons / capacity)); + } catch { + return null; + } + } + private lineItemsSignature(items: PriceLineItemDto[]): string { return JSON.stringify( [...items] diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 36705daa0..ec6e466b2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -605,11 +605,6 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - if (booking.status === "AWAITING_CLEARANCE_PAYMENT") { - throw new ConflictException( - "The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.", - ); - } assertBookingStatus(booking, [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", 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..c292a0395 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -252,8 +252,11 @@ export class BookingsController { return this.bookingsService.findAll(filter, companyId); } + // Powers the customer-detail bookings tab, so `customers:view` reaches it too + // — otherwise a staffer granted only the customer permission gets a page whose + // tabs 403 individually. @Get("by-company/:companyId/customer-view") - @BookingView() + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.bookings.view]) @ApiOperation({ summary: "List bookings for a company (customer-view shape, backoffice)", }) @@ -436,18 +439,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/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 2aa3ee8c2..821b9c9f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [ 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Post counter-sign document-clearance gate (GL workflow). - 'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', @@ -521,10 +520,6 @@ export class Booking extends BaseEntity { @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) clearanceCurrentPhase?: string | null; - /** When the prepaid customs clearance service fee settled (GENERAL + customs). */ - @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) - clearanceFeePaidAt?: Date | null; - @Column({ name: 'duty_required', type: 'boolean', nullable: true }) dutyRequired?: boolean | null; 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/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, 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/clearance-fee.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts deleted file mode 100644 index 8de7aed87..000000000 --- a/apps/edr-freight-api/src/modules/contracts/clearance-fee.service.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Injectable, Logger, UnprocessableEntityException } from '@nestjs/common'; -import { OnEvent } from '@nestjs/event-emitter'; -import { Freight } from '@edr/types'; - -import { BillingService, InvoiceEventPayload } from '../billing/billing.service'; -import { Invoice } from '../billing/entities/invoice.entity'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { Booking } from '../bookings/entities/booking.entity'; -import { ContractPricingBreakdown } from './contract-pricing.service'; -import { ContractNotifierService } from './contract-notifier.service'; -import { ContractsRepository } from './contracts.repository'; -import { Contract } from './entities/contract.entity'; - -/** Invoice `type` for the contract-level fee (Path B ONE_TIME, after counter-sign). */ -export const CLEARANCE_CONTRACT_INVOICE_TYPE = 'CLEARANCE_CONTRACT'; -/** Invoice `type` for the per-shipment fee (Path B GENERAL, at shipment request). */ -export const CLEARANCE_BOOKING_INVOICE_TYPE = 'CLEARANCE_BOOKING'; - -/** - * The prepaid customs clearance service fee (Path B) — the GL service charge, - * separate from both freight (booking invoice) and duty/tax (paid offline). - * Issued as its own `clearance`-source invoice and paid BEFORE the clearance - * document step opens and before GL touches the file: - * - ONE_TIME: once per contract, at staff counter-sign - * (AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_CLEARANCE_DOCUMENTS); - * - GENERAL: once per shipment request, on the initiated booking instance - * (booking AWAITING_CLEARANCE_PAYMENT → paid → AWAITING_DOCUMENTS). - * The fee amount is the frozen CUSTOMS_CLEARANCE contract rate snapshot, so - * customers pay what their contract shows, not the live rate of the day. - */ -@Injectable() -export class ClearanceFeeService { - private readonly logger = new Logger(ClearanceFeeService.name); - - constructor( - private readonly billing: BillingService, - private readonly contractsRepository: ContractsRepository, - private readonly bookingsRepository: BookingsRepository, - private readonly notifier: ContractNotifierService, - ) {} - - /** The frozen flat fee for a contract; falls back to the pricing breakdown. */ - private async feeAmountOrNull( - contract: Contract, - ): Promise<{ amount: number; currency: string } | null> { - const snapshots = await this.contractsRepository.findRateSnapshots(contract.id); - const snapshot = snapshots.find( - (s) => s.isClearance || s.rateCode === 'CUSTOMS_CLEARANCE', - ); - if (snapshot && Number(snapshot.unitPrice) > 0) { - return { amount: Number(snapshot.unitPrice), currency: snapshot.currency }; - } - const breakdown = contract.pricingBreakdown as ContractPricingBreakdown | null; - const line = breakdown?.lineItems?.find((l) => l.code === 'CUSTOMS_CLEARANCE'); - if (line && Number(line.unitPrice) > 0) { - return { amount: Number(line.unitPrice), currency: breakdown!.currency }; - } - return null; - } - - private async feeAmount( - contract: Contract, - ): Promise<{ amount: number; currency: string }> { - const fee = await this.feeAmountOrNull(contract); - if (!fee) { - throw new UnprocessableEntityException( - `Contract ${contract.reference} has no frozen customs clearance fee — regenerate its price with a live CUSTOMS_CLEARANCE rate.`, - ); - } - return fee; - } - - /** - * Whether the payment gate applies. Skipped for government/unlinked - * contracts (no company to bill — invoices require one, same rule the - * booking invoice applies) and for legacy customs contracts frozen before - * the fee existed (no CUSTOMS_CLEARANCE snapshot to bill from) — both keep - * the pre-fee flow instead of dead-ending. - */ - async gateApplies(contract: Contract): Promise { - // Customs disabled → the prepay gate genuinely does not apply. - if (!contract.customsClearingEnabled) return false; - // No company to bill (government / unlinked) → the gate cannot raise an - // invoice, so it stays out of the flow (same rule the booking invoice uses). - if (!contract.companyId) return false; - // M26: customs IS enabled and billable. A missing frozen fee line must NOT - // silently waive the gate — that ships clearance for free. Hard-fail exactly - // as price generation does when no CUSTOMS_CLEARANCE rate is configured, so a - // missing fee blocks counter-sign / shipment instead of bypassing payment. - if ((await this.feeAmountOrNull(contract)) === null) { - 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.', - ); - } - return true; - } - - /** Issue (idempotently) the ONE_TIME contract-level fee invoice. */ - async issueForContract(contract: Contract): Promise { - const existing = await this.billing.findPayable( - Freight.InvoiceSource.Clearance, - contract.id, - CLEARANCE_CONTRACT_INVOICE_TYPE, - ); - if (existing) return existing; - - const { amount, currency } = await this.feeAmount(contract); - const invoice = await this.billing.generateInvoice({ - source: Freight.InvoiceSource.Clearance, - sourceId: contract.id, - type: CLEARANCE_CONTRACT_INVOICE_TYPE, - companyId: contract.companyId!, - companyProfileId: contract.companyProfileId!, - currency, - lines: [ - { - chargeType: 'CUSTOMS_CLEARANCE', - description: `Customs clearance service fee — contract ${contract.reference}`, - quantity: 1, - unitRate: amount, - amount, - currency, - }, - ], - status: Freight.InvoiceStatus.Pending, - }); - this.notifier.clearanceFeeDue(contract, amount, currency); - return invoice; - } - - /** Issue (idempotently) the GENERAL per-shipment fee invoice on the booking. */ - async issueForBooking(booking: Booking, contract: Contract): Promise { - const existing = await this.billing.findPayable( - Freight.InvoiceSource.Clearance, - booking.id, - CLEARANCE_BOOKING_INVOICE_TYPE, - ); - if (existing) return existing; - - const { amount, currency } = await this.feeAmount(contract); - const invoice = await this.billing.generateInvoice({ - source: Freight.InvoiceSource.Clearance, - sourceId: booking.id, - type: CLEARANCE_BOOKING_INVOICE_TYPE, - companyId: booking.companyId ?? contract.companyId!, - companyProfileId: booking.companyProfileId ?? contract.companyProfileId!, - currency, - lines: [ - { - chargeType: 'CUSTOMS_CLEARANCE', - description: `Customs clearance service fee — shipment ${booking.reference}`, - quantity: 1, - unitRate: amount, - amount, - currency, - }, - ], - status: Freight.InvoiceStatus.Pending, - }); - this.notifier.clearanceFeeDue(contract, amount, currency, booking.reference); - return invoice; - } - - /** - * Retire (idempotently) the unpaid contract-level fee invoice when the - * contract reaches a terminal state — a dead contract must not leave a - * payable clearance invoice open for the customer to settle. No-op when the - * fee was already paid or never invoiced (mirrors the booking cancel path, - * {@link BillingService.expirePayable}). - */ - async expireForContract(contractId: string): Promise { - return this.billing.expirePayable( - Freight.InvoiceSource.Clearance, - contractId, - CLEARANCE_CONTRACT_INVOICE_TYPE, - ); - } - - /** - * Settlement branch point for `clearance`-source invoices: unlock the - * document-upload step the fee was gating. Idempotent — a replayed event on - * an already-advanced contract/booking is a no-op. - */ - @OnEvent('clearance.invoice.paid') - async onClearanceInvoicePaid(payload: InvoiceEventPayload): Promise { - this.logger.log( - `clearance.invoice.paid (${payload.type}) for ${payload.sourceId} from ${payload.invoiceId}`, - ); - switch (payload.type) { - case CLEARANCE_CONTRACT_INVOICE_TYPE: - await this.advanceContract(payload.sourceId); - break; - case CLEARANCE_BOOKING_INVOICE_TYPE: - await this.advanceBooking(payload.sourceId); - break; - default: - this.logger.warn( - `Unhandled clearance invoice type "${payload.type}" paid (${payload.invoiceId})`, - ); - } - } - - private async advanceContract(contractId: string): Promise { - const contract = await this.contractsRepository.findById(contractId); - if (!contract) { - this.logger.warn(`Cannot advance unknown contract ${contractId} on clearance fee payment.`); - return; - } - if (contract.status !== 'AWAITING_CLEARANCE_PAYMENT') return; - - await this.contractsRepository.update(contractId, { - status: 'AWAITING_CLEARANCE_DOCUMENTS', - clearanceStatus: 'AWAITING_DOCUMENTS', - clearanceFeePaidAt: new Date(), - } as never); - const updated = await this.contractsRepository.findByIdWithRelations(contractId); - if (updated) this.notifier.clearanceFeePaid(updated); - } - - private async advanceBooking(bookingId: string): Promise { - const booking = await this.bookingsRepository.findById(bookingId); - if (!booking) { - this.logger.warn(`Cannot advance unknown booking ${bookingId} on clearance fee payment.`); - return; - } - if (booking.status !== 'AWAITING_CLEARANCE_PAYMENT') return; - - await this.bookingsRepository.update(bookingId, { - status: 'AWAITING_DOCUMENTS', - clearanceFeePaidAt: new Date(), - } as never); - if (booking.contractId) { - const contract = await this.contractsRepository.findByIdWithRelations( - booking.contractId, - ); - if (contract) this.notifier.clearanceFeePaid(contract, booking.reference); - } - } -} diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts index 7f0aaabea..563f056d2 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.completion.spec.ts @@ -26,7 +26,6 @@ describe('ContractBookingService — quantity-cap completion', () => { {} as never, // milestoneService {} as never, // workflowService {} as never, // invoiceService - {} as never, // clearanceFeeService { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index 68c02fa7d..bb74f062c 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -57,7 +57,6 @@ describe('ContractBookingService — drawdown consolidation gate', () => { milestoneService as never, {} as never, // workflowService invoiceService as never, - {} as never, // clearanceFeeService { createdToStaff: jest.fn() } as never, // bookingNotifier {} as never, // dataSource {} as never, // trainSchedulingService 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..8e102e5a1 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 @@ -38,7 +38,6 @@ import { hasFreightPermission } from '../../common/freight-permission.util'; import { Contract } from './entities/contract.entity'; import { ContractRoute } from './entities/contract-route.entity'; import { ContractsRepository } from './contracts.repository'; -import { ClearanceFeeService } from './clearance-fee.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceWorkflowService } from './clearance-workflow.service'; import { @@ -97,7 +96,6 @@ export class ContractBookingService { private readonly milestoneService: ClearanceMilestoneService, private readonly workflowService: ClearanceWorkflowService, private readonly invoiceService: BookingInvoiceService, - private readonly clearanceFeeService: ClearanceFeeService, private readonly bookingNotifier: BookingLifecycleNotifierService, private readonly dataSource: DataSource, @Inject(forwardRef(() => TrainSchedulingService)) @@ -289,6 +287,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), @@ -536,11 +535,8 @@ export class ContractBookingService { const route = await this.resolveRoute(contract, opts.contractRouteId); - // Prepay gate: each shipment request owes its own flat clearance service - // fee before the document step opens (the paid event advances the booking - // to AWAITING_DOCUMENTS). Government/unlinked contracts skip the gate. - const feeGate = await this.clearanceFeeService.gateApplies(contract); - + // No prepay gate: the clearance service fee is billed on the booking + // invoice at completion, so the document step opens immediately. const booking = await insertWithGeneratedReference( () => this.generateReference(), (reference) => @@ -550,7 +546,7 @@ export class ContractBookingService { companyProfileId: contract.companyProfileId ?? null, isGovernment: contract.isGovernment, governmentInstitution: contract.governmentInstitution ?? null, - status: feeGate ? 'AWAITING_CLEARANCE_PAYMENT' : 'AWAITING_DOCUMENTS', + status: 'AWAITING_DOCUMENTS', bookingType: 'ONE_TIME', contractId: contract.id, contractRouteId: route?.id ?? null, @@ -589,10 +585,6 @@ export class ContractBookingService { contract.tradeDirection, ); - if (feeGate) { - await this.clearanceFeeService.issueForBooking(booking, contract); - } - const created = (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; this.bookingNotifier.createdToStaff(created); @@ -738,6 +730,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-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 029952f45..d76391f42 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -499,11 +499,6 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status === 'AWAITING_CLEARANCE_PAYMENT') { - throw new ConflictException( - 'The customs clearance service fee has not been paid yet — pay it from the portal to unlock document upload.', - ); - } if ( contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && contract.status !== 'CLEARANCE_UNDER_REVIEW' diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index ac4fe2a0f..fd81083fc 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -178,26 +178,6 @@ export class ContractNotifierService { }); } - /** Clearance service fee invoiced — customer must pay before document upload. */ - clearanceFeeDue(c: Contract, amount: number, currency: string, shipmentRef?: string): void { - const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; - const msg = - `A customs clearance service fee of ${amount} ${currency} is due for ${scope}. ` + - `Please pay from the portal to unlock the clearance document upload.`; - void this.notifyContact(c, msg, 'CLEARANCE FEE DUE'); - this.inApp(c, 'Clearance fee due', msg); - } - - /** Clearance service fee settled — document upload is now open. */ - clearanceFeePaid(c: Contract, shipmentRef?: string): void { - const scope = shipmentRef ? `shipment ${shipmentRef} under contract ${c.reference}` : `contract ${c.reference}`; - const msg = - `Your customs clearance service fee for ${scope} has been received. ` + - `You can now upload the clearance documents from the portal.`; - void this.notifyContact(c, msg, 'CLEARANCE FEE PAID'); - this.inApp(c, 'Clearance fee paid', msg); - } - // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */ 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..149643441 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 @@ -10,7 +10,7 @@ import { Contract } from './entities/contract.entity'; export interface ContractUnitRateLineItem { code: string; label: string; - unit: 'per_container' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; + unit: 'per_container' | 'per_wagon' | 'per_ton' | 'per_item' | 'per_km' | 'flat'; unitPrice: number; containerSize?: string | null; conditionalOn?: string | null; @@ -37,8 +37,9 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] { return 'per_ton'; case 'PER_KM': return 'per_km'; - case 'PER_CONTAINER': case 'PER_WAGON': + return 'per_wagon'; + case 'PER_CONTAINER': return 'per_container'; default: return 'flat'; @@ -188,49 +189,165 @@ export class ContractPricingService { }); } } + // Lashing / cargo securing — BULK only, shown when the contract's commodity + // needs lashing (cargoType.hasLashing). The commodity-scoped rate for the + // contract's direction wins over the commodity-wide catch-all; billed at + // booking on the live rate (per ton / per wagon), this line is display. + if (contract.freightType === 'BULK') { + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + if (scope?.cargoType?.hasLashing) { + const onDirection = liveRates.filter( + (r) => + r.trigger === 'LASHING' && + r.currency === 'USD' && + !r.containerTypeId && + r.tradeDirection === contract.tradeDirection, + ); + const lashing = + onDirection.find((r) => r.cargoTypeId === scope.cargoTypeId) ?? + onDirection.find((r) => !r.cargoTypeId); + if (lashing && Number(lashing.rateValue) > 0) { + lineItems.push({ + code: 'LASHING', + label: `Lashing / cargo securing (${scope.cargoType.cargoTypeName})`, + unit: toContractUnit(lashing.rateUnit), + unitPrice: convert(Number(lashing.rateValue)), + cargoTypeCode: scope.cargoType.code ?? null, + conditionalOn: 'has_lashing', + }); + } + } + } + // Empty-container return service — container contracts only, toggled on the // contract like hazard/reefer. Billed at booking per WITH_RETURN container. if ( 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', + }); + } } } - // Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the - // contract and billed via its own clearance invoice: after counter-sign for - // ONE_TIME, per shipment request for GENERAL. Excluded from booking totals. - // A customs contract may not proceed without a configured live rate. + // Customs clearance service fee (Path B) — billed on the booking invoice + // together with the freight. Sold per direction + route + cargo kind: + // container contracts freeze one fee line per contract size (each size's + // own container-type rate), bulk contracts freeze the route's bulk fee. + // A customs contract may not proceed without the fee(s) configured. if (contract.customsClearingEnabled) { - const clearance = liveRates.find( - (r) => r.rateType === 'CUSTOMS_CLEARANCE' && r.currency === 'USD', - ); - 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.', - ); + // 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 onLeg = route + ? liveRates.filter( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === contract.tradeDirection && + r.originYardId === route.originYardId && + r.destinationYardId === route.destinationYardId, + ) + : []; + if (contract.freightType === 'CONTAINER') { + 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), + ); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + `No customs clearance service fee is configured for ${size} containers on this direction and route. Ask the rates team to set a live CUSTOMS_CLEARANCE rate for this container type and origin → destination.`, + ); + } + lineItems.push({ + // Distinct code per size so the frozen snapshots don't collide — + // booking pricing looks each size up by CUSTOMS_CLEARANCE_FT. + code: `CUSTOMS_CLEARANCE_${sizeFt}FT`, + label: `Customs clearance service (${size})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + containerSize: size, + isClearance: true, + }); + } + } else { + // Bulk fee — the rate scoped to the contract's commodity wins; a + // commodity-less rate (legacy) is the catch-all fallback. + const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId); + const rate = + (scope?.cargoTypeId + ? onLeg.find( + (r) => !r.containerTypeId && r.cargoTypeId === scope.cargoTypeId, + ) + : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); + if (!rate || Number(rate.rateValue) <= 0) { + throw new UnprocessableEntityException( + 'No bulk customs clearance service fee is configured for this cargo type on this direction and route. Ask the rates team to set a live bulk CUSTOMS_CLEARANCE rate for this commodity and origin → destination.', + ); + } + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + label: `Customs clearance service (${scope?.cargoType?.cargoTypeName ?? 'bulk'})`, + unit: toContractUnit(rate.rateUnit), + unitPrice: convert(Number(rate.rateValue)), + cargoTypeCode: scope?.cargoType?.code ?? null, + isClearance: true, + }); } - lineItems.push({ - code: 'CUSTOMS_CLEARANCE', - label: - contract.contractKind === 'GENERAL' - ? 'Customs clearance service fee (per shipment request, prepaid)' - : 'Customs clearance service fee (prepaid)', - unit: toContractUnit(clearance.rateUnit), - unitPrice: convert(Number(clearance.rateValue)), - isClearance: true, - }); } return { 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..54158c383 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'; @@ -28,7 +36,6 @@ import { SignaturesService } from '../signatures/signatures.service'; import { OtpService } from '../otp/otp.service'; import { ContractTemplatesService } from '../contract-templates/contract-templates.service'; import { ContractPricingService } from './contract-pricing.service'; -import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractsRepository } from './contracts.repository'; @@ -154,7 +161,6 @@ export class ContractTransitionService { private readonly otpService: OtpService, private readonly notifier: ContractNotifierService, private readonly contractTemplates: ContractTemplatesService, - private readonly clearanceFeeService: ClearanceFeeService, @InjectDataSource() private readonly dataSource: DataSource, ) {} @@ -236,8 +242,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 +444,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 +545,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 +569,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( @@ -563,10 +589,6 @@ export class ContractTransitionService { actorId, 'STAFF', ); - // Stop the open-invoice leak: a rejected contract must not leave a payable - // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). - await this.clearanceFeeService.expireForContract(contractId); - await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -626,10 +648,6 @@ export class ContractTransitionService { 'STAFF', ); - // Stop the open-invoice leak: a rejected contract must not leave a payable - // clearance fee invoice open. Mirror the booking cancel path (billing.expirePayable). - await this.clearanceFeeService.expireForContract(contractId); - await this.contractsRepository.update(contractId, { status: 'REJECTED', } as never); @@ -641,9 +659,9 @@ export class ContractTransitionService { /** * Internal send-back branch of rejectStep: return the contract to an earlier, * already-approved stage of the chain instead of rejecting it outright. - * Deliberately NOT the terminal path: no clearance-fee expiry (the contract - * is still alive) and no customer-facing REJECTION note — the trail is a - * staff note plus a backoffice inbox ping. + * Deliberately NOT the terminal path: the contract is still alive and there + * is no customer-facing REJECTION note — the trail is a staff note plus a + * backoffice inbox ping. */ private async sendBackToStep( contract: Contract, @@ -1141,17 +1159,11 @@ export class ContractTransitionService { const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); - // Path B prepay gate: the customs clearance service fee is invoiced here - // and must settle before the document step opens (the paid event advances - // to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee. - if (await this.clearanceFeeService.gateApplies(contract)) { - await this.clearanceFeeService.issueForContract(contract); - updates.status = 'AWAITING_CLEARANCE_PAYMENT'; - updates.clearanceStatus = 'AWAITING_PAYMENT'; - } else { - updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; - updates.clearanceStatus = 'AWAITING_DOCUMENTS'; - } + // No prepay gate: the customs clearance service fee (Path B) is billed on + // the booking invoice together with the freight, so the document step + // opens immediately. + updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; + updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or any GENERAL contract 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..ba80cc035 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,32 @@ 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, ); } + // Readable by anyone who may view the contract: the draft carries + // `editableByMe`, and the approval chain's approvers (identified by position + // type, not by staff_accept) must be able to fetch it to learn it is their + // turn. Gating this on staff_accept hid the edit dialog from every approver. @Get(':id/document/draft') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', @@ -376,8 +392,15 @@ export class ContractsController { return this.documentHistory.list(id); } + // Coarse gate only. WHO may actually edit is turn-based, not a static + // permission, so `updateContractDocument` -> `assertDocumentEditable` is the + // real boundary: it admits only the approver whose step is currently pending + // (edit rights hand off down the chain on each approval). @Put(':id/document/articles') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -396,29 +419,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/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 050417a45..bb12648a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; -import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; @@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, - ClearanceFeeService, ContractNotifierService, ContractTransitionService, ContractDocumentHistoryService, 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/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index 52fcd437f..244a2816d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity { conditionalOn?: string | null; /** - * Customs clearance service fee line — billed up front via a clearance - * invoice, excluded from shipment booking totals. + * Customs clearance service fee line — billed on the booking invoice + * together with the freight (no separate prepaid clearance invoice). */ @Column({ name: 'is_clearance', type: 'boolean', default: false }) isClearance!: boolean; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 4838d3f52..b8635199d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [ 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', - 'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', @@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; export const CONTRACT_CLEARANCE_STATUSES = [ 'NOT_APPLICABLE', - 'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking @@ -217,10 +215,6 @@ export class Contract extends BaseEntity { @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) clearanceCycleNumber!: number; - /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */ - @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) - clearanceFeePaidAt?: Date | null; - @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | null; diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 03797f3b7..61a13744e 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -54,11 +54,4 @@ export class InterchangeDocumentsController { dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) { return this.service.dispute(id, dto); } - - @Patch(':id/cancel') - @BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel) - @ApiOperation({ summary: 'Cancel a draft/generated interchange document' }) - cancel(@Param('id', ParseUUIDPipe) id: string) { - return this.service.cancel(id); - } } diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index f91e942c0..e5fb5dc4d 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -203,11 +203,12 @@ export class InterchangeDocumentsService { async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { const document = await this.findOne(id); - // A dispute can only be raised on a live handover — a GENERATED or already - // ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here. - if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) { + // A dispute can only be raised BEFORE the handover is acknowledged — an + // acknowledged document is settled. DISPUTED itself is terminal and + // read-only: the registered dispute cannot be re-raised or overwritten. + if (document.status !== 'GENERATED') { throw new BadRequestException( - `Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`, + `Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`, ); } await this.dataSource.getRepository(InterchangeDocument).update(id, { @@ -217,15 +218,6 @@ export class InterchangeDocumentsService { return this.findOne(id); } - async cancel(id: string): Promise { - const document = await this.findOne(id); - if (!['DRAFT', 'GENERATED'].includes(document.status)) { - throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); - } - await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); - return this.findOne(id); - } - private async getScheduleSnapshot(scheduleId: string): Promise { const [schedule] = await this.dataSource.query( `SELECT ts.id, 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/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 50856c3d7..2ae62d4f3 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -16,7 +16,8 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { BookingView } from "../../common/booking-guards"; +import { BookingStaff, BookingView } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { PaymentService } from "./payment.service"; import { IntentStatusDto } from "./payments.dto"; @@ -25,7 +26,9 @@ import { IntentStatusDto } from "./payments.dto"; export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + // Customer-detail payments tab — same one-of rule as the bookings tab. @Get("by-company/:companyId/customer-view") + @BookingStaff([FREIGHT_PERMS.customers.view, FREIGHT_PERMS.payments.view]) @ApiOperation({ summary: "List payments for a company (customer-view shape, backoffice)" }) findByCompanyCustomerView( @Param("companyId", ParseUUIDPipe) companyId: string, 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/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 41971bbf1..4f9d06870 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -10,6 +10,7 @@ import { const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; +export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const; export class CreateRateDto { @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) @@ -47,6 +48,15 @@ export class CreateRateDto { @IsIn([...INTERCITY_KINDS]) intercityKind?: string; + @ApiPropertyOptional({ + enum: CARGO_KINDS, + description: + 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', + }) + @IsOptional() + @IsIn([...CARGO_KINDS]) + cargoKind?: string; + @ApiPropertyOptional({ description: 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', 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/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 78e2eb724..3519a0c06 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; export function allowedRateUnits(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; + /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ + cargoKind?: 'CONTAINER' | 'BULK' | null; }): RateUnit[] { const { appliesTo, trigger } = input; @@ -29,16 +31,20 @@ export function allowedRateUnits(input: { case 'DEMURRAGE': return ['PER_CONTAINER', 'PER_TON']; case 'WITH_RETURN': - // Container-only empty-return service — bills per returned container. - return ['PER_CONTAINER', 'FLAT']; + // Container-only empty-return service — per returned container, per + // wagon the empties ride back on, or a flat fee. + return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; case 'CANCELLATION': return ['FLAT', 'PER_INVOICE']; case 'CUSTOMS_CLEARANCE': - // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). - return ['FLAT']; + // Sold per cargo kind: container fees bill per box or per wagon, bulk + // fees per ton or per wagon. Billed on the booking invoice. + return input.cargoKind === 'BULK' + ? ['PER_TON', 'PER_WAGON'] + : ['PER_CONTAINER', 'PER_WAGON']; case 'LASHING': - // Flat cargo-securing fee, billed once per booking. - return ['FLAT']; + // Bulk-only cargo securing — per ton or per wagon. + return ['PER_TON', 'PER_WAGON']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': @@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate export function isRateUnitAllowed(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; + cargoKind?: 'CONTAINER' | 'BULK' | null; unit: RateUnit; }): boolean { return allowedRateUnits(input).includes(input.unit); 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..a736d4b05 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,349 @@ 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('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => { + // Same service, but the return rate is sold per wagon: 4× 20ft return = + // 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20. + 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, rateUnit: 'PER_WAGON' } as Rate]), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const result = await service.evaluate( + returnInput({ + containers: [ + { + containerTypeId: 'ct-20', + quantity: 4, + vgmPerUnitTons: 10, + totalVgmTons: 40, + returnQuantity: 4, + wagonsPerUnit: 0.5, + }, + ], + }), + ); + + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(ret).toHaveLength(1); + expect(ret[0].triggerValue).toBe(2); + expect(ret[0].calculatedAmount).toBe(40); + expect(ret[0].billingUnit).toBe('PER_WAGON'); + }); + + 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); + }); +}); + +describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => { + const lashingBulkImport: Rate = { + id: 'rate-lash-bulk', + rateType: 'LASHING', + trigger: 'LASHING', + rateValue: 2, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: null, + destinationYardId: null, + } as Rate; + + const buildService = (rates: Rate[]): RuleEngineService => + new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const bulkInput = (overrides: Partial = {}): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + cargoTypeId: 'cargo-sugar', + totalWagons: 0, + bulkTons: 100, + bulkWagons: 3, + containers: [], + ...overrides, + }); + + const lashingMods = (result: Awaited>) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING'); + + it('bulk lashing bills per ton on the direction-matched rate', async () => { + const result = await buildService([lashingBulkImport]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods).toHaveLength(1); + expect(mods[0].triggerValue).toBe(100); + expect(mods[0].calculatedAmount).toBe(200); + expect(mods[0].billingUnit).toBe('PER_TON'); + }); + + it('a rate for the other direction never bills', async () => { + const result = await buildService([ + { ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate, + ]).evaluate(bulkInput()); + expect(lashingMods(result)).toHaveLength(0); + }); + + it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => { + const result = await buildService([ + { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, + ]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods[0].triggerValue).toBe(3); + expect(mods[0].calculatedAmount).toBe(75); + }); + + it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => { + const result = await buildService([ + lashingBulkImport, + { ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate, + ]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods).toHaveLength(1); + expect(mods[0].unitPriceUsd).toBe(7); + expect(mods[0].calculatedAmount).toBe(700); + }); + + it('container bookings never incur lashing (bulk-only service)', async () => { + const result = await buildService([lashingBulkImport]).evaluate( + bulkInput({ + cargoTypeId: null, + hasLashing: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, + ], + }), + ); + expect(lashingMods(result)).toHaveLength(0); + }); + + it('no lashing charge when the cargo does not need lashing', async () => { + const service = new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + const result = await service.evaluate(bulkInput()); + expect(lashingMods(result)).toHaveLength(0); + }); +}); 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..3ad97bb53 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 @@ -48,6 +48,12 @@ export interface BookingContainerEvalInput { hazardousQuantity?: number; reeferQuantity?: number; returnQuantity?: number; + /** + * Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5). + * Lets a PER_WAGON empty-return rate bill the wagons the returned empties + * ride back on. Missing ⇒ one wagon per container. + */ + wagonsPerUnit?: number; } export interface BookingEvaluationInput { @@ -67,6 +73,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 @@ -80,6 +92,12 @@ export interface BookingEvaluationInput { * container freight, which is scaled by container count instead. */ bulkTons?: number; + /** + * Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by + * the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing); + * 0/undefined when unknown — those charges then bill nothing. + */ + bulkWagons?: number; containers: BookingContainerEvalInput[]; } @@ -91,6 +109,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 +192,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 +304,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 +316,17 @@ 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; + // Lashing is sold per cargo kind + container type — billed by the + // kind-aware block below, never by this generic loop. + if (rate.trigger === 'LASHING') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -384,6 +419,25 @@ 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); + + if (hasLashing) { + appliedModifiers.push(...this.lashingCharges(input, liveRates)); + } + return { priorityScore, appliedModifiers, @@ -394,6 +448,186 @@ 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); + // PER_WAGON bills the wagons the returned empties occupy (two 20ft share + // one wagon), PER_CONTAINER the boxes themselves, FLAT once per line. + const billed = + rate.rateUnit === 'PER_WAGON' + ? Math.ceil(qty * (container.wagonsPerUnit ?? 1)) + : qty; + const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: rate.rateUnit === 'FLAT' ? qty : billed, + 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)] }; + } + + /** + * Cargo securing / lashing — BULK only, sold per trade direction, optionally + * narrowed to one leaf commodity (the commodity-scoped rate wins over the + * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the + * wagons the bulk occupies. Container bookings never incur lashing, and an + * unconfigured rate simply bills nothing — same leniency as hazard/reefer. + */ + private lashingCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + if (input.containers.length > 0) return modifiers; // bulk-only service + + const onDirection = liveRates.filter( + (r) => + r.trigger === 'LASHING' && + r.currency === 'USD' && + !r.containerTypeId && + r.tradeDirection === input.tradeDirection, + ); + const rate = + (input.cargoTypeId + ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId) + : undefined) ?? onDirection.find((r) => !r.cargoTypeId); + if (!rate) return modifiers; + + const billedQty = + rate.rateUnit === 'PER_TON' + ? Math.max(0, Number(input.bulkTons ?? 0)) + : rate.rateUnit === 'PER_WAGON' + ? Math.max(0, Number(input.bulkWagons ?? 0)) + : 1; + const rateValue = Number(rate.rateValue); + const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue; + if (!(amount > 0)) return modifiers; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + return modifiers; + } + /** * 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/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index 6c3adce66..be7c8984c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => { expect(request.payload).toEqual({ rateValue: 200 }); }); + it('carries a re-routed leg — a yard-only edit is a real change', async () => { + const { service } = build({ + rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }), + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 100, + originYardId: 'yard-a', + destinationYardId: 'yard-c', + }, + }); + + expect(request.payload).toEqual({ destinationYardId: 'yard-c' }); + }); + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { const { service } = build(); await expect( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 357c67f95..36c55dad3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate + // diffed to nothing and the submit was refused as "nothing changed". + 'originYardId', + 'destinationYardId', ] as const; /** 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..700d6366c 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 @@ -69,18 +69,19 @@ export class RatesService { appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], requestedUnit: Rate['rateUnit'] | undefined, + cargoKind?: 'CONTAINER' | 'BULK' | null, ): Rate['rateUnit'] { // Overweight is per-ton, full stop — the admin form hides the unit field // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - const allowed = allowedRateUnits({ appliesTo, trigger }); + const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind }); if (!requestedUnit) { throw new BadRequestException( `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, ); } - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) { throw new BadRequestException( `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); @@ -93,6 +94,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 +140,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 +148,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) { @@ -174,11 +188,76 @@ export class RatesService { trigger: Rate['trigger']; tradeDirection: string | null; intercityKind: string | null; + cargoKind: string | null; containerTypeId: string | null; cargoTypeId: string | null; }): void { - const { appliesTo, trigger, tradeDirection, intercityKind } = input; + const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = 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.', + ); + } + // Sold per cargo kind: a container fee names the container type it covers + // (20ft and 40ft price differently); a bulk fee carries no type at all — + // that absence is what marks it as the bulk fee. + if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { + throw new BadRequestException( + 'A customs clearance rate must say whether it covers containers or bulk.', + ); + } + if (cargoKind === 'CONTAINER' && !containerTypeId) { + throw new BadRequestException( + 'A container customs clearance rate must name the container type it covers.', + ); + } + if (cargoKind === 'BULK' && containerTypeId) { + throw new BadRequestException( + 'A bulk customs clearance rate cannot be scoped to a container type.', + ); + } + // The bulk customs fee names the commodity it covers (sugar and + // fertilizer clear differently). + if (cargoKind === 'BULK' && !cargoTypeId) { + throw new BadRequestException( + 'A bulk customs clearance rate must name the bulk cargo type it covers.', + ); + } + if (cargoKind === 'CONTAINER' && cargoTypeId) { + throw new BadRequestException( + 'A container customs clearance rate cannot be scoped to a bulk cargo type.', + ); + } + return; + } + if (trigger === 'LASHING') { + // Bulk-only cargo securing, sold per direction. May narrow to one leaf + // commodity (specific wins over the commodity-wide catch-all). + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'A lashing rate must say whether it covers IMPORT or EXPORT.', + ); + } + if (containerTypeId) { + throw new BadRequestException( + 'Lashing is bulk-only — it cannot be scoped to a container type.', + ); + } + 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 +326,35 @@ 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 and empty-container return keep direction + + // container type — both are sold per lane (and per container type). const isSurcharge = trigger !== 'ALWAYS'; - const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); - const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); + const cargoKind = + trigger === 'CUSTOMS_CLEARANCE' + ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) + : null; + const containerTypeId = + trigger === 'WITH_RETURN' || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); + const cargoTypeId = + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + trigger === 'LASHING' + ? (dto.cargoTypeId ?? null) + : 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' || trigger === 'LASHING' + ? (dto.tradeDirection ?? null) + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : (dto.tradeDirection ?? null); const intercityKind = dto.intercityKind ?? null; this.assertScopeCoherent({ @@ -261,6 +362,7 @@ export class RatesService { trigger, tradeDirection, intercityKind, + cargoKind, containerTypeId, cargoTypeId, }); @@ -282,6 +384,7 @@ export class RatesService { appliesTo, trigger, dto.rateUnit as Rate['rateUnit'] | undefined, + cargoKind, ); await this.assertNoDuplicatePattern({ @@ -376,22 +479,42 @@ export class RatesService { if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.trigger) updates.trigger = trigger; - const containerTypeId = isSurcharge + // A patch that leaves the cargo kind unsaid keeps the one the rate already + // has — read back off its container scope (container fees carry the type). + const cargoKind = + trigger !== 'CUSTOMS_CLEARANCE' + ? null + : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? + (existing.containerTypeId ? 'CONTAINER' : 'BULK')); + + const keepsContainerType = + !isSurcharge || + trigger === 'WITH_RETURN' || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); + const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId : existing.containerTypeId; - const cargoTypeId = isSurcharge + const keepsCargoType = + !isSurcharge || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + trigger === 'LASHING'; + const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' - ? null - : dto.tradeDirection !== undefined + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + ? 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; @@ -407,6 +530,7 @@ export class RatesService { trigger, tradeDirection: updates.tradeDirection, intercityKind, + cargoKind, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, }); @@ -438,7 +562,7 @@ export class RatesService { // Re-validate the unit against the (possibly changed) shape; overweight is // forced to PER_TON. const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind); // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ 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-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 072c559c6..7bbf2b0cb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -416,9 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, - // Cargo-securing / lashing — flat fee, billed once per booking whose - // cargo type has hasLashing = true. - { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" }, + // Cargo-securing / lashing — bulk-only, fires when the cargo type has + // hasLashing. Sold per direction; commodity-wide catch-alls seeded here, + // commodity-specific rates are configured by the rates team. + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" }, + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" }, // ── First/last-mile road haulage (per km) — drives the mile invoices ── { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d216447f3..bb4d024c8 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 => @@ -627,7 +632,9 @@ const filterSidebarByPermission = ( const filterItems = (items: SidebarItem[]): SidebarItem[] => items .map((item) => - item.children ? { ...item, children: filterItems(item.children) } : item, + item.children + ? { ...item, children: filterItems(item.children) } + : item, ) .filter((item) => { if (etGl || djGl) { @@ -719,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 ; } @@ -791,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> { + } @@ -1110,7 +1135,7 @@ const App = () => { + } @@ -1118,7 +1143,7 @@ const App = () => { + } @@ -1126,7 +1151,7 @@ const App = () => { + } @@ -1134,7 +1159,7 @@ const App = () => { + } @@ -1142,7 +1167,7 @@ const App = () => { + } @@ -1150,7 +1175,7 @@ const App = () => { + } @@ -1158,7 +1183,7 @@ const App = () => { + } @@ -1166,7 +1191,7 @@ const App = () => { + } @@ -1252,7 +1277,7 @@ const App = () => { + } @@ -1340,7 +1365,7 @@ const App = () => { + } @@ -1348,7 +1373,7 @@ const App = () => { + } @@ -1356,7 +1381,7 @@ const App = () => { + } @@ -1364,7 +1389,7 @@ const App = () => { + } @@ -1372,7 +1397,7 @@ const App = () => { + } @@ -1380,7 +1405,7 @@ const App = () => { + } @@ -1388,7 +1413,7 @@ const App = () => { + } @@ -1396,7 +1421,7 @@ const App = () => { + } @@ -1463,14 +1488,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" ? ( <>