diff --git a/.gitignore b/.gitignore index ca2a5b7af..316f08dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,22 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json + +# local run scripts (contain personal DB credentials — never commit) +run-passenger-local.sh +run-passenger-web.sh + +# generated test output +e2e-ui-report/ +test-results/ +playwright-report/ +blob-report/ +RUNNING_LOCALLY.md 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 9769a7f18..a412bf990 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -14,6 +14,13 @@ export const BookingStaff = (permission: string | string[]) => ), ); +/** + * Read-only reference data (yard dropdowns, search filters): any signed-in + * staff. Menu/page visibility stays permission-gated in the frontend — this + * only lets forms populate their lookups. + */ +export const StaffReference = () => applyDecorators(UseGuards(JwtGuard)); + export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); export const TrainSchedulingView = () => @@ -22,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/common/mile-financials.util.ts b/apps/edr-freight-api/src/common/mile-financials.util.ts new file mode 100644 index 000000000..2f5288048 --- /dev/null +++ b/apps/edr-freight-api/src/common/mile-financials.util.ts @@ -0,0 +1,61 @@ +import { DataSource } from 'typeorm'; + +type MileRecord = { + bookingId?: string | null; + advancedPayment?: number | string | null; + booking?: { + cargoTotalWeightVgm?: number | string | null; + bookingContainers?: Array<{ + units?: Array<{ vgmTons?: number | string | null }> | null; + }> | null; + } | null; +}; + +/** + * Display enrichment for first/last-mile lists (Assign Vehicle modal etc.): + * - Advance payment: mile records are created with advanced_payment 0 — the + * real advance is the FIRST_MILE/LAST_MILE line the customer already paid + * on the booking invoice. + * - Cargo tons: container bookings often carry tonnage on the per-unit VGMs + * while cargo_total_weight_vgm stays 0 — fall back to the summed units. + * Fills both in-memory on the loaded records; nothing is persisted. + */ +export async function attachMileFinancials( + dataSource: DataSource, + records: MileRecord[], + chargeType: 'FIRST_MILE' | 'LAST_MILE', +): Promise { + for (const r of records) { + const b = r.booking; + if (!b || Number(b.cargoTotalWeightVgm) > 0) continue; + const unitTons = (b.bookingContainers ?? []).reduce( + (sum, bc) => + sum + (bc.units ?? []).reduce((s, u) => s + (Number(u.vgmTons) || 0), 0), + 0, + ); + if (unitTons > 0) b.cargoTotalWeightVgm = Number(unitTons.toFixed(3)); + } + + const needAdvance = records.filter( + (r) => r.bookingId && !(Number(r.advancedPayment) > 0), + ); + if (!needAdvance.length) return; + + const rows: Array<{ bookingId: string; amount: string }> = await dataSource.query( + `SELECT i.source_id AS "bookingId", SUM(il.amount) AS amount + FROM freight.invoice_lines il + JOIN freight.invoices i ON i.id = il.invoice_id AND i.deleted_at IS NULL + WHERE i.source = 'booking' + AND i.status = 'PAID' + AND i.source_id = ANY($1::text[]) + AND il.charge_type = $2 + AND il.deleted_at IS NULL + GROUP BY i.source_id`, + [needAdvance.map((r) => r.bookingId), chargeType], + ); + const byBooking = new Map(rows.map((r) => [r.bookingId, Number(r.amount)])); + for (const r of needAdvance) { + const paid = byBooking.get(r.bookingId as string); + if (paid) r.advancedPayment = paid; + } +} 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/main.ts b/apps/edr-freight-api/src/main.ts index 0fa1056dd..5b027448c 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -33,6 +33,13 @@ async function bootstrap() { "delegator-position-id", "current-project-id", "current-position-id", + // x-prefixed variants sent by the user-management / record-management + // frontend modules (same values, different naming convention) + "x-organization-unit-id", + "x-delegator-id", + "x-delegator-position-id", + "x-current-project-id", + "x-current-position-id", ], exposedHeaders: ["Content-Disposition"], maxAge: 86400, // cache preflight for 24h to cut chatter in dev diff --git a/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts new file mode 100644 index 000000000..d8119930f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2450000000000-DropActiveProfileTypeFromExternalProfiles.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Drop the `active_profile_type` "active mode" column. A booking/contract now + * resolves its company_profile from the trade direction at creation time (with + * a forwarder passing an explicit companyProfileId), so no per-user active mode + * is stored. `onboarding_step` / `onboarding_completed` are unaffected. + */ +export class DropActiveProfileTypeFromExternalProfiles2450000000000 + implements MigrationInterface +{ + name = 'DropActiveProfileTypeFromExternalProfiles2450000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + // Rebuild the mode the same way the original column was backfilled: + // importer first, then exporter, then whichever profile the company has. + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts new file mode 100644 index 000000000..e6cfe70c3 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2460000000000-AddCacBankPaymentMethod.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddCacBankPaymentMethod2460000000000 implements MigrationInterface { + name = "AddCacBankPaymentMethod2460000000000"; + + public async up(queryRunner: QueryRunner): Promise { + // The entity + frontend already list 'cac-bank' as a valid method, but the + // DB enum was never extended. Filtering payments by 'cac-bank' cast the + // literal to the enum and errored (invalid input value for enum). EDRFREIGHT-301. + await queryRunner.query(`ALTER TYPE freight.payments_method_enum ADD VALUE IF NOT EXISTS 'cac-bank';`); + } + + public async down(_queryRunner: QueryRunner): Promise { + // PostgreSQL does not support removing enum values directly. + // To roll back, recreate the type without the added value and update the column. + } +} diff --git a/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts new file mode 100644 index 000000000..fadacda69 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2470000000000-AddAcquisitionItemName.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Acquisitions describe WHAT was acquired (vehicle, parts, equipment…) — the + * vehicle link is optional and only for acquisitions that ARE a fleet vehicle. + */ +export class AddAcquisitionItemName2470000000000 implements MigrationInterface { + name = 'AddAcquisitionItemName2470000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + ADD COLUMN IF NOT EXISTS item_name varchar(200) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.asset_acquisitions + DROP COLUMN IF EXISTS item_name + `); + } +} 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/auth/customer-reset.service.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts index 91ddaf1a9..4eb6ecc31 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.service.ts @@ -12,6 +12,7 @@ import { RESET_LINK_TTL_MS, } from "./forgot-password.service"; import { maskOtpTarget } from "./mask-target.util"; +import { isDomesticPhone } from "../otp/otp.service"; /** The account a staff-triggered reset would land on. */ export interface CustomerResetTarget { @@ -19,6 +20,12 @@ export interface CustomerResetTarget { name: string; email: string | null; phone: string | null; + /** + * Whether the SMS gateway (domestic-only) can reach `phone`. `null` when + * there is no phone. The backoffice uses this to disable the SMS channel for + * foreign numbers instead of sending a link that will never arrive. + */ + phoneIsDomestic: boolean | null; } export interface SentResetLink { @@ -58,6 +65,9 @@ export class CustomerResetService { name: `${profile.firstName} ${profile.lastName}`.trim(), email: user.email ?? null, phone: user.phoneNumber ?? null, + phoneIsDomestic: user.phoneNumber + ? isDomesticPhone(user.phoneNumber) + : null, }; } @@ -80,6 +90,17 @@ export class CustomerResetService { const target = this.forgotPasswordService.targetFor(user, channel); if (!target) return null; + // A foreign number is unreachable by the domestic-only SMS gateway — treat + // it like a missing phone rather than reporting "link sent" for a message + // that will never arrive. The backoffice disables the channel up front via + // `phoneIsDomestic`; this guards direct API calls. + if (channel === "phone" && target.phone && !isDomesticPhone(target.phone)) { + this.logger.warn( + `Staff reset via SMS refused for user ${userId} — non-domestic phone`, + ); + return null; + } + // Mint first, send second: a failed send leaves an unused ticket that simply // expires, whereas sending a link before the ticket exists would hand the // customer a URL that is dead on arrival. diff --git a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts index 50c90213b..006b482f4 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-me.service.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-me.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { DataSource } from 'typeorm'; import { collectPermissionKeys, @@ -9,7 +11,35 @@ import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry'; @Injectable() export class FreightMeService { - getEnrichedProfile(user: TCurrentUser) { + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * The JWT session snapshot has no position TYPE, but the backoffice needs it + * (GL sub-positions are identified by type key). Resolved live from IAM. + */ + private async lookupPositionType( + positionId: string | undefined, + ): Promise<{ key: string; name: unknown } | null> { + if (!positionId) return null; + try { + const rows: { key: string; name: unknown }[] = await this.dataSource.query( + `SELECT pt.key, pt.name + FROM iam.positions p + JOIN iam.position_types pt ON pt.id = p.position_type_id + WHERE p.id = $1`, + [positionId], + ); + return rows[0] ?? null; + } catch { + return null; // iam schema unreachable — degrade to the old payload shape + } + } + + async getEnrichedProfile(user: TCurrentUser) { + const positionType = await this.lookupPositionType( + user.employee?.position?.id, + ); + const employee = user.employee ? [ { @@ -27,6 +57,7 @@ export class FreightMeService { isDelegate: user.employee.position.isDelegate, parentPositionId: user.employee.position.parentPositionId, permissions: user.employee.position.permissions ?? [], + positionType, }, ] : [], 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 3f4f64186..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, ); }); @@ -163,11 +164,12 @@ describe('BookingPricingService — domestic corridor', () => { computeBaseRailLinesWithRates: ( b: Booking, input: { containers: [] }, - ) => Promise<{ lineItems: Array<{ amount: number }> }>; + ) => Promise<{ lineItems: Array<{ amount: number }>; blocked: string[] }>; } ).computeBaseRailLinesWithRates(booking, { containers: [] }); expect(result.lineItems).toHaveLength(0); + expect(result.blocked).toHaveLength(1); }); it('does not price containers off a rate configured for a different leg', async () => { @@ -197,4 +199,277 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.lineItems).toHaveLength(0); }); + + // A mixed booking where only one container size has a configured rate must + // hard-block, not silently carry the unconfigured size for free. + it('blocks the unconfigured container size and prices the configured one', async () => { + const fortyOnly: Rate = { + ...intercityContainerUsd, + id: 'rate-ct-40-only', + containerTypeId: 'ct-40', + } as Rate; + ratesService.findLiveRates.mockResolvedValue([fortyOnly]); + + const booking = { + id: 'b-5', + freightType: 'CONTAINER', + tradeDirection: 'DOMESTIC', + paymentCurrency: 'USD', + originYardId: MOJO, + destinationYardId: DIRE, + bookingContainers: [], + } as unknown as Booking; + + const result = await ( + service as unknown as { + computeBaseRailLinesWithRates: ( + b: Booking, + input: { + containers: Array<{ containerTypeId: string; quantity: number }>; + }, + ) => Promise<{ lineItems: Array<{ code: string }>; blocked: string[] }>; + } + ).computeBaseRailLinesWithRates(booking, { + containers: [ + { containerTypeId: 'ct-40', quantity: 2 }, + { containerTypeId: 'ct-20', quantity: 3 }, + ], + }); + + expect(result.lineItems).toHaveLength(1); + expect(result.blocked).toHaveLength(1); + 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 a42d23859..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 { @@ -137,8 +142,12 @@ export class BookingPricingService { const lineItems: PriceLineItemDto[] = []; let total = 0; - const { lineItems: baseLines, usedRates: baseRates, warnings: baseWarnings } = - await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); + const { + lineItems: baseLines, + usedRates: baseRates, + warnings: baseWarnings, + blocked: baseBlocked, + } = await this.computeBaseRailLinesWithRates(booking, evalInput, frozenRates); for (const line of baseLines) { lineItems.push(line); total += line.amount; @@ -161,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 @@ -178,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 @@ -211,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. @@ -248,7 +282,7 @@ export class BookingPricingService { appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: [...ruleResult.warnings, ...baseWarnings], - hardBlocked: ruleResult.hardBlocked, + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked], overweightLines, }; } @@ -309,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, @@ -326,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 — @@ -365,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. @@ -372,6 +417,7 @@ export class BookingPricingService { booking.freightType === 'BULK' ? Number(booking.cargoTotalWeightVgm ?? 0) : 0, + bulkWagons, containers, }; } @@ -454,7 +500,12 @@ export class BookingPricingService { booking: Booking, evalInput: BookingEvaluationInput, frozenRates: Map | null = null, - ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; warnings: string[] }> { + ): Promise<{ + lineItems: PriceLineItemDto[]; + usedRates: Rate[]; + warnings: string[]; + blocked: string[]; + }> { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; @@ -477,6 +528,7 @@ export class BookingPricingService { const lines: PriceLineItemDto[] = []; const usedRatesMap = new Map(); const warnings: string[] = []; + const blocked: string[] = []; const wagonCount = await this.resolveWagonCount(booking); for (const container of evalInput.containers) { @@ -500,11 +552,14 @@ export class BookingPricingService { const label = await this.containerTypeLabel(container.containerTypeId); if (!rate && !frozen) { // Never price this line off another container type's (or another - // route's) rate — an unpriced line with a warning is recoverable; a - // silently mischarged one is not. - warnings.push( + // route's) rate, and never let an unpriced line through: a booking + // that ships a container type nobody configured a rate for would be + // carried for free. Hard-block instead — the customer drops the line + // or EDR configures the rate. + blocked.push( `No ${rateType} rate is configured for ${label} on this route — ` + - 'the line was not priced.', + `the booking cannot be priced. Remove the ${label} line or ask EDR ` + + 'to configure its rate for this origin → destination.', ); continue; } @@ -587,10 +642,18 @@ export class BookingPricingService { quantity: this.effectiveUnitQuantity(fallback.rateUnit, quantity, wagonCount), currency: paymentCurrency, }); + } else if (isBulk) { + // Same rule as container lines: bulk freight with no rate on this leg + // must not proceed unpriced. + blocked.push( + `No ${rateType} rate is configured for this route — the booking ` + + 'cannot be priced. Ask EDR to configure the rate for this ' + + 'origin → destination.', + ); } } - return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings }; + return { lineItems: lines, usedRates: [...usedRatesMap.values()], warnings, blocked }; } /** @@ -863,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 1896034cc..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 @@ -7,6 +7,7 @@ import { Logger, Optional, } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -349,6 +350,22 @@ export class BookingTransitionService { return fresh; } + /** + * Import EDR last-mile: every handover signed + every truck departed ⇒ the + * warehouses module delivered the goods and asks the booking to complete. + * Best-effort — a booking already COMPLETED (or not yet in transit) just logs. + */ + @OnEvent('import.handover.completed') + async onImportHandoverCompleted(payload: { bookingId: string }): Promise { + try { + await this.complete(payload.bookingId); + } catch (err) { + this.logger.log( + `Booking ${payload.bookingId} not auto-completed on handover sign: ${(err as Error).message}`, + ); + } + } + async complete(bookingId: string): Promise { const booking = await this.bookingsService.findById(bookingId); assertBookingStatus(booking, ["IN_TRANSIT", "ARRIVED"]); @@ -588,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 bc8c80982..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); @@ -480,6 +491,20 @@ export class BookingsController { return this.customerTruckService.addTruck(id, dto); } + @Post(':id/customer-trucks/bulk') + @ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' }) + async bulkAddCustomerTrucks( + @Param('id', ParseUUIDPipe) id: string, + @Body() payload: { trucks: AddCustomerTruckDto[] }, + @CurrentUser() user: TCurrentUser, + ) { + const booking = await this.bookingsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking); + } + return this.customerTruckService.addBulkTrucks(id, payload.trucks); + } + @Patch(':id/customer-trucks/:assignmentId') @ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' }) async updateCustomerTruck( 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 d9dd53f94..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -12,7 +12,6 @@ import { Freight, SchedulingStatus } from '@edr/types'; import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; @@ -143,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) { @@ -172,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, { @@ -269,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 @@ -387,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('')} `; } @@ -423,6 +442,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -468,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, @@ -635,12 +658,9 @@ export class BookingsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - // A customer can only book once their company has been approved. - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create bookings yet.", - ); - } + // A customer can only book once their company has been approved; the + // helper names the real status (suspended/blacklisted) when it isn't. + this.companiesService.assertCompanyActiveFor(company, 'bookings'); companyId = company.id; } @@ -746,21 +766,13 @@ export class BookingsService { ); companyProfileId = profile.id; } else if (companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } + // No explicit profile pin: resolve from the booking's trade direction + // (import→importer, export→exporter; otherwise the first profile). A + // forwarder booking sends dto.companyProfileId and takes the branch above. companyProfileId = await this.companiesService.resolveCompanyProfileIdForBooking( companyId, tradeDirection, - fallbackType, ); // A customer booking under their own account may only do so once the @@ -800,6 +812,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -1010,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, }); @@ -1068,9 +1084,6 @@ export class BookingsService { await this.companiesService.resolveCompanyProfileIdForBooking( existing.companyId, tradeDirection, - existing.companyProfileId - ? undefined - : (existing.companyProfile?.type as ProfileType | undefined), ); } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); @@ -1228,9 +1241,9 @@ export class BookingsService { /** * Batched version of the findById flag: marks each page item whose booking - * has a generated-but-unsigned SELF_HAUL handover, so list rows (portal - * dashboard) can show "Approve delivery" for exactly the generated→signed - * window. One query for the whole page. + * has a generated-but-unsigned handover (self-haul or EDR last-mile), so list + * rows (portal dashboard) can show "Approve delivery" for exactly the + * generated→signed window. One query for the whole page. */ private async attachHandoverFlags(bookings: Booking[]): Promise { const ids = bookings.map((b) => b.id); @@ -1239,8 +1252,7 @@ export class BookingsService { `SELECT DISTINCT booking_id AS "bookingId" FROM freight.booking_handovers WHERE booking_id = ANY($1::uuid[]) - AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL'`, + AND signed_at IS NULL AND deleted_at IS NULL`, [ids], ); const pending = new Set(rows.map((r) => r.bookingId)); @@ -1392,15 +1404,6 @@ export class BookingsService { } } - /** - * Resolve the active company_profile id a customer's bookings should be - * scoped to (importer/exporter mode). Null when not onboarded — callers fall - * back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - return this.companiesService.resolveActiveCompanyProfileId(userId); - } - /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong @@ -1583,14 +1586,12 @@ export class BookingsService { schedule?.status ?? null; } - // A generated-but-unsigned SELF_HAUL handover means the customer must approve - // delivery from the portal (booking-based, one per booking). EDR last-mile - // handovers are per delivering truck and signed by the receiver at the door, - // so they never surface the portal "Approve delivery" action. + // A generated-but-unsigned handover means the customer must approve delivery + // from the portal. Self-haul: booking-based, one per booking. EDR last-mile: + // per delivering truck (generated on truck exit), signed one by one. const [pendingHandover] = await this.dataSource.query( `SELECT 1 FROM freight.booking_handovers WHERE booking_id = $1 AND signed_at IS NULL AND deleted_at IS NULL - AND mile_type = 'SELF_HAUL' LIMIT 1`, [id], ); diff --git a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts index eb4699008..4ca578f0b 100644 --- a/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/customer-truck.service.ts @@ -576,4 +576,35 @@ export class CustomerTruckService { } /** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */ + + async addBulkTrucks( + bookingId: string, + dtos: AddCustomerTruckDto[], + ): Promise<{ + success: number; + failed: number; + errors: Array<{ row: number; truck: string; reason: string }>; + }> { + const errors: Array<{ row: number; truck: string; reason: string }> = []; + let successCount = 0; + + for (let i = 0; i < dtos.length; i++) { + try { + await this.addTruck(bookingId, dtos[i]); + successCount++; + } catch (err: any) { + errors.push({ + row: i + 2, // Row 1 is header + truck: dtos[i].truckPlateNumber, + reason: err.message || 'Unknown error', + }); + } + } + + return { + success: successCount, + failed: errors.length, + errors, + }; + } } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts new file mode 100644 index 000000000..5e03c7bc4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/bulk-customer-truck.dto.ts @@ -0,0 +1,48 @@ +import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator'; +import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto'; + +export class BulkCustomerTruckRow { + @IsString() + @IsNotEmpty() + truckPlateNumber!: string; + + @IsString() + @IsNotEmpty() + driverName!: string; + + @IsString() + @IsNotEmpty() + @IsIn(CUSTOMER_TRUCK_TYPES) + truckType!: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(2) + @ArrayUnique() + @Matches(/^[A-Z]{4}\d{7}$/, { + each: true, + message: 'each container must be ISO format (e.g. ABCD1234567)', + }) + containerNumbers?: (string | null)[]; +} + +export class BulkCustomerTrucksDto { + @IsArray() + @ArrayMaxSize(100) + trucks!: BulkCustomerTruckRow[]; +} + +export interface BulkTruckUploadResult { + success: number; + failed: number; + errors: Array<{ + row: number; + truck: string; + reason: string; + }>; + created: Array<{ + truckPlateNumber: string; + driverName: string; + containers: number; + }>; +} 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 6111bd32a..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"; @@ -26,7 +35,6 @@ import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; -import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { DashboardQueryDto } from "./dto/dashboard-query.dto"; @@ -60,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 { @@ -353,21 +378,6 @@ export class CompaniesController { return this.companiesService.removePoaDelegationLetter(user.id, fileId); } - @Patch("active-mode") - @ApiOperation({ - summary: "Switch the current user's active operational mode (importer/exporter)", - }) - async setActiveMode( - @CurrentUser() user: CurrentIamUser, - @Body() dto: SetActiveModeDto, - ): Promise { - const { profile, company } = await this.companiesService.setActiveMode( - user.id, - dto.type, - ); - return new CompanyInfoResponseDto(profile, company); - } - @Patch("onboarding-step") @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) @HttpCode(HttpStatus.NO_CONTENT) @@ -426,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)", @@ -437,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, @@ -452,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, @@ -462,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) => ({ @@ -506,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: @@ -548,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, @@ -566,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, @@ -576,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)", }) @@ -592,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", }) @@ -610,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, @@ -624,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, @@ -634,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/companies/companies.repository.ts b/apps/edr-freight-api/src/modules/companies/companies.repository.ts index 3ac12b11a..db8db0d2e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.repository.ts @@ -41,6 +41,18 @@ export class CompaniesRepository extends BaseRepository { AND ccr.deleted_at IS NULL )`; + /** + * The `sortBy = 'review'` queue ordering: whatever marketing must act on + * floats to the top. Tier 0 — submitted applications awaiting first approval + * (drafts excluded: nothing to review yet). Tier 1 — approved customers with + * a pending change request. Tier 2 — everyone else, drafts included. + */ + private static readonly REVIEW_TIER_SQL = `(CASE + WHEN company.status = 'pending' AND NOT ${CompaniesRepository.DRAFT_SQL} THEN 0 + WHEN ${CompaniesRepository.PENDING_CHANGE_REQUEST_SQL} THEN 1 + ELSE 2 + END)`; + constructor( @InjectRepository(Company) repo: Repository, @@ -80,8 +92,8 @@ export class CompaniesRepository extends BaseRepository { status, onboardingCompleted, hasPendingChangeRequest, - sortBy = 'name', - sortOrder = 'ASC', + sortBy = 'review', + sortOrder = 'DESC', } = query; const qb = this.repository @@ -137,8 +149,18 @@ export class CompaniesRepository extends BaseRepository { } // sortBy is whitelisted by @IsIn on the DTO, so it is safe to interpolate. + if (sortBy === 'review') { + // Queue ordering: actionable tiers first, newest first within each. The + // tier is selected under an alias because skip/take pagination with + // joins re-derives the ORDER BY in a subquery — a raw expression there + // breaks, a selected alias survives. + qb.addSelect(CompaniesRepository.REVIEW_TIER_SQL, 'review_tier') + .orderBy('review_tier', 'ASC') + .addOrderBy('company.createdAt', 'DESC'); + } else { + qb.orderBy(`company.${sortBy}`, sortOrder); + } const [items, total] = await qb - .orderBy(`company.${sortBy}`, sortOrder) // Names are not unique and createdAt can tie on bulk imports; the id // tiebreaker keeps paging stable instead of dropping/repeating rows. .addOrderBy('company.id', 'ASC') diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index df0e14998..3867bef3a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -201,18 +201,6 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); - // Default active mode from the chosen role(s): importer wins when both are - // picked, otherwise the first allowed type chosen. - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - const chosenTypes = (dto.companyProfiles ?? []) - .map((p) => p.type) - .filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; - const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -220,7 +208,6 @@ export class CompaniesService { lastName: identity.lastName, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, - activeProfileType, onboardingStep: "company", }); @@ -293,11 +280,6 @@ export class CompaniesService { const allowedTypes = this.getProfileTypeForCompanyType(companyType); const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); - const activeProfileType = - chosenTypes.find((t) => t === ProfileType.importer) ?? - chosenTypes[0] ?? - allowedTypes[0] ?? - null; const company = await this.companiesRepo.create({ name: identity.firstName @@ -316,7 +298,6 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, isPrimaryContact: true, - activeProfileType, onboardingStep: "company", onboardingCompleted: false, }); @@ -1090,6 +1071,23 @@ export class CompaniesService { if (!existing) throw new NotFoundException(`Company profile ${profileId} not found`); + // Suspension and reactivation must carry a staff explanation — the customer + // sees it, so "why" can never be left blank. Reactivation is the + // active-write that leaves Suspended; a first approval stays note-free. + const reactivating = + status === ProfileStatus.Active && + existing.status === ProfileStatus.Suspended; + if ( + (status === ProfileStatus.Suspended || reactivating) && + !note?.trim() + ) { + throw new BadRequestException( + status === ProfileStatus.Suspended + ? "A message explaining the suspension is required — the customer will see it." + : "A message explaining the reactivation is required — the customer will see it.", + ); + } + // A self-registered company is only reviewable once its owner submits the // onboarding wizard (markOnboardingComplete) — until then its profiles are // half-filled drafts and approving one would mint a reference against an @@ -1176,9 +1174,13 @@ export class CompaniesService { ); } - // Track the review outcome. Rejection keeps the note so the customer knows - // why; approval clears it. Any decision stamps the reviewer + time. - if (status === ProfileStatus.Rejected) { + // Track the review outcome. Rejection and suspension keep the note so the + // customer knows why; approval/reactivation clears it. Any decision stamps + // the reviewer + time. + if ( + status === ProfileStatus.Rejected || + status === ProfileStatus.Suspended + ) { patch.reviewNote = note ?? null; } else if (status === ProfileStatus.Active) { patch.reviewNote = null; @@ -1192,23 +1194,50 @@ export class CompaniesService { if (!updated) throw new NotFoundException(`Company profile ${existing.id} not found`); - // Approving any profile promotes a pending company to active, so the - // customer can start working as soon as their first profile is cleared. - if (status === ProfileStatus.Active) { + // Every reviewed transition that changes what the customer can do is told + // to them, carrying the staff message so they know why. Approval has no + // message (the note is cleared); the others require one. + const change = + status === ProfileStatus.Suspended + ? "suspended" + : status === ProfileStatus.Rejected + ? "rejected" + : status === ProfileStatus.Active + ? existing.status === ProfileStatus.Suspended + ? "reactivated" + : "approved" + : null; + if (change) { const company = await this.companiesRepo.findById(updated.companyId); - if (company && company.status === CompanyStatus.Pending) { - await this.companiesRepo.update(updated.companyId, { - status: CompanyStatus.Active, - }); + if (company) { + this.companyNotifier.profileStatusChanged( + company, + updated.type, + change, + note ?? "", + ); + // The first approved role promotes a pending company to active — a + // bigger event (the account itself goes live), so tell them that too. + if ( + status === ProfileStatus.Active && + company.status === CompanyStatus.Pending + ) { + await this.companiesRepo.update(updated.companyId, { + status: CompanyStatus.Active, + }); + this.companyNotifier.companyApproved(company); + } } } return updated; } /** - * Customer reapplies for a rejected operational role (after fixing whatever the - * reviewer flagged, e.g. re-uploading a license): flip it back to Pending and - * clear the rejection note so it re-enters the approval queue. + * Customer reapplies for a rejected or suspended operational role (after + * fixing whatever the reviewer flagged, e.g. re-uploading a license): flip it + * back to Pending and clear the review note so it re-enters the approval + * queue. Suspension is a staff lockout, so resubmitting is an appeal — the + * backoffice still has to approve before the role goes live again. */ async reapplyCompanyProfile( userId: string, @@ -1223,9 +1252,12 @@ export class CompaniesService { if (!target || target.companyId !== companyId) { throw new NotFoundException(`Company profile ${profileId} not found`); } - if (target.status !== ProfileStatus.Rejected) { + if ( + target.status !== ProfileStatus.Rejected && + target.status !== ProfileStatus.Suspended + ) { throw new BadRequestException( - "Only a rejected role can be resubmitted for approval", + "Only a rejected or suspended role can be resubmitted for approval", ); } @@ -1349,10 +1381,9 @@ export class CompaniesService { /** * Create a single operational profile for the current user's company. The new - * role starts Pending, so it deliberately does NOT become the active mode: - * switching onto an unapproved profile would strip the user of `canBook` and - * block them from creating contracts under the role they already had approved. - * Callers switch explicitly via {@link setActiveMode} once the role is Active. + * role starts Pending and carries no reference until a backoffice reviewer + * approves it; a booking/contract resolves its profile from the trade + * direction at creation time, so no "active mode" is stored. */ async createCompanyProfileForUser( userId: string, @@ -1387,40 +1418,6 @@ export class CompaniesService { return created; } - /** - * Switch the user's active operational mode. The target profile must already - * exist — clients create it first via createCompanyProfileForUser. - */ - async setActiveMode( - userId: string, - type: ProfileType, - ): Promise<{ profile: ExternalProfile; company: Company }> { - const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) - throw new NotFoundException(`Profile for user ${userId} not found`); - - const companyId = profile.company?.id ?? profile.companyId; - const company = await this.findCompanyById(companyId); - - const allowedTypes = this.getProfileTypeForCompanyType(company.type); - if (!allowedTypes.includes(type)) { - throw new BadRequestException( - `Profile type "${type}" is not allowed for company type "${company.type}"`, - ); - } - - const existing = await this.companyProfilesRepo.findByType(companyId, type); - if (!existing) { - throw new ConflictException( - `No ${type} profile exists yet — create it before switching`, - ); - } - - await this.profilesRepo.update(profile.id, { activeProfileType: type }); - - return this.getCompanyInfoByUserId(userId); - } - async setOnboardingStep(userId: string, step: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); if (!profile) @@ -1611,21 +1608,68 @@ export class CompaniesService { } /** - * Block a customer from booking under a profile that isn't approved yet. - * Called from the booking-create path for self-service bookings; staff- and - * government-initiated bookings bypass this. No-op when the profile can't be - * found (defensive — resolution is best-effort upstream). + * Block a self-service action when the company account isn't active, naming + * the actual status — a suspended customer told "awaiting approval" has no + * idea what happened or who to call. + */ + assertCompanyActiveFor(company: Company, action: string): void { + if (company.status === CompanyStatus.Active) return; + switch (company.status) { + case CompanyStatus.Suspended: + throw new ForbiddenException( + `Your company account is suspended — you can't create ${action} right now. ` + + `Please contact EDR support for details.`, + ); + case CompanyStatus.Blacklisted: + throw new ForbiddenException( + `Your company account is blacklisted — you can't create ${action}. ` + + `Please contact EDR support.`, + ); + default: + throw new ForbiddenException( + `Your company is awaiting approval — you can't create ${action} yet.`, + ); + } + } + + /** + * Block a customer from booking under a profile that isn't approved yet — or + * that a reviewer has since suspended. Called from the booking/contract + * create path for self-service actions; staff- and government-initiated ones + * bypass this. No-op when the profile can't be found (defensive — resolution + * is best-effort upstream). The message names the profile's real status: + * suspension in particular is per-role, so the customer must learn which + * operation is blocked (their other roles still work). */ async assertCompanyProfileApprovedForBooking( companyProfileId: string, ): Promise { const profile = await this.companyProfilesRepo.findById(companyProfileId); if (!profile) return; - if (profile.status !== ProfileStatus.Active) { - const role = profile.type.replace(/_/g, " "); - throw new ForbiddenException( - `Your ${role} profile is awaiting approval. You'll be able to create bookings once it has been approved.`, - ); + if (profile.status === ProfileStatus.Active) return; + + const role = profile.type.replace(/_/g, " "); + switch (profile.status) { + case ProfileStatus.Suspended: + throw new ForbiddenException( + `Your ${role} role is suspended${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Your other roles are unaffected. Please contact EDR support to resolve this.`, + ); + case ProfileStatus.Blacklisted: + throw new ForbiddenException( + `Your ${role} role is blacklisted. Please contact EDR support.`, + ); + case ProfileStatus.Rejected: + throw new ForbiddenException( + `Your ${role} role was rejected${ + profile.reviewNote ? ` — ${profile.reviewNote}` : "" + }. Amend and resubmit it from your settings page.`, + ); + default: + throw new ForbiddenException( + `Your ${role} profile is awaiting approval. You'll be able to proceed once it has been approved.`, + ); } } @@ -2244,15 +2288,14 @@ export class CompaniesService { /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → - * exporter profile; for DOMESTIC or a forwarder/single-profile company (or - * when the natural profile doesn't exist) it falls back to the user's active - * profile, then the company's first profile. Returns null when the company - * has no profiles at all. + * exporter profile; for DOMESTIC (or when the natural profile doesn't exist, + * e.g. a freight forwarder) it falls back to the company's first profile. + * Callers that need a specific role (a forwarder) pass an explicit + * companyProfileId instead. Returns null when the company has no profiles. */ async resolveCompanyProfileIdForBooking( companyId: string, tradeDirection: string, - fallbackType?: ProfileType | null, ): Promise { const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); if (profiles.length === 0) return null; @@ -2264,30 +2307,12 @@ export class CompaniesService { ? ProfileType.exporter : null; - const byType = (type?: ProfileType | null) => - type ? profiles.find((p) => p.type === type) : undefined; - - const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + const match = + (naturalType && profiles.find((p) => p.type === naturalType)) ?? + profiles[0]; return match?.id ?? null; } - /** - * Resolve the company_profile a customer's data should be scoped to, from - * their persisted active mode. Returns null when nothing can be resolved - * (not onboarded yet) so callers can fall back to company-level scoping. - */ - async resolveActiveCompanyProfileId(userId: string): Promise { - try { - const { profile, company } = await this.getCompanyInfoByUserId(userId); - const type = profile.activeProfileType; - if (!type) return null; - const match = company.companyProfiles?.find((p) => p.type === type); - return match?.id ?? null; - } catch { - return null; - } - } - async fetchETradeData(tin: string) { const { businessInfo, companyInfo } = await this.etradeService.resolveCompanyData(tin); diff --git a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts index f71a67976..66f88e9f8 100644 --- a/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/companies/company-notifier.service.ts @@ -59,23 +59,106 @@ export class CompanyNotifierService { } } + /** SMS + email + in-app account-status item to the company contact. */ + private notifyAccount( + company: Company, + title: string, + body: string, + link = "/settings", + ): void { + void this.notifyContact(company, `${title}. ${body}`); + void this.inbox.notify({ + recipients: { companyId: company.id }, + audience: NotificationAudience.PORTAL, + type: NotificationType.ACCOUNT_STATUS, + title, + body, + link, + data: { companyId: company.id, status: company.status }, + priority: NotificationPriority.HIGH, + }); + } + /** - * Tell the customer their account was suspended or blacklisted. Called only on - * a real transition into one of those statuses; other status writes are silent. + * Tell the customer their account changed status. Fires on the transitions + * that change what they can do: suspended/blacklisted (locked out) and + * reactivated (back to Active from a lockout). Silent otherwise. */ statusChanged(company: Company, previous: CompanyStatus): void { const status = company.status; if (status === previous) return; + + if (status === CompanyStatus.Active && PUNITIVE_STATUSES.includes(previous)) { + this.logger.log(`ACCOUNT_REACTIVATED — ${company.id}`); + this.notifyAccount( + company, + "Account reactivated", + "Your company account has been reactivated. " + + "You can submit new contracts and bookings again.", + ); + return; + } + if (!PUNITIVE_STATUSES.includes(status)) return; const label = status === CompanyStatus.Suspended ? "suspended" : "blacklisted"; - const title = `Account ${label}`; - const body = - `Your company account has been ${label}. ` + - `You will not be able to submit new contracts or bookings. ` + - `Please contact EDR support for assistance.`; - this.logger.log(`ACCOUNT_${label.toUpperCase()} — ${company.id}`); + this.notifyAccount( + company, + `Account ${label}`, + `Your company account has been ${label}. ` + + `You will not be able to submit new contracts or bookings. ` + + `Please contact EDR support for assistance.`, + ); + } + + /** + * Tell the customer their company account was approved and is now live — the + * first operational role clearing review promotes a pending company to Active. + */ + companyApproved(company: Company): void { + this.logger.log(`ACCOUNT_APPROVED — ${company.id}`); + this.notifyAccount( + company, + "Account approved", + "Your company account has been approved and is now active. " + + "You can start submitting bookings and contracts.", + "/dashboard", + ); + } + + /** + * Tell the customer one of their operational roles changed review status — + * approved, rejected, suspended, or reactivated — quoting the staff message + * when one was given (rejection/suspension/reactivation require one; approval + * carries none). + */ + profileStatusChanged( + company: Company, + profileType: string, + change: "approved" | "rejected" | "suspended" | "reactivated", + staffMessage: string, + ): void { + const title = `${profileType} role ${change}`; + const consequence: Record = { + approved: "You can now operate under this role.", + rejected: + "You will not be able to operate under this role. Amend the required " + + "documents and resubmit it for approval from your settings page.", + suspended: + "You will not be able to operate under this role until it is " + + "reactivated; your other roles are unaffected.", + reactivated: "You can operate under this role again.", + }; + const message = staffMessage.trim(); + const body = + `Your company's ${profileType} role has been ${change}. ` + + `${consequence[change]}` + + (message ? ` Message from EDR staff: ${message}` : ""); + + this.logger.log( + `PROFILE_${change.toUpperCase()} — ${company.id} / ${profileType}`, + ); void this.notifyContact(company, `${title}. ${body}`); void this.inbox.notify({ recipients: { companyId: company.id }, @@ -84,7 +167,7 @@ export class CompanyNotifierService { title, body, link: "/settings", - data: { companyId: company.id, status }, + data: { companyId: company.id, profileType, change, staffMessage: message }, priority: NotificationPriority.HIGH, }); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index 9a4fb330a..2634e0943 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -24,7 +24,7 @@ export class CompanyInfoResponseDto { company: Company, changeRequest?: CompanyChangeRequest | null, ) { - this.profile = new ResponseExternalProfileDto(profile, company); + this.profile = new ResponseExternalProfileDto(profile); this.company = new ResponseCompanyDto(company); const open = diff --git a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts index 8d4910ded..ffb600e36 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/list-companies-query.dto.ts @@ -60,15 +60,19 @@ export class ListCompaniesQueryDto { hasPendingChangeRequest?: boolean; @ApiPropertyOptional({ - enum: ["name", "createdAt", "updatedAt"], - default: "name", - description: "Column to order by. Defaults to name for backwards compatibility.", + enum: ["review", "name", "createdAt", "updatedAt"], + default: "review", + description: + "Column to order by. The default `review` is a review-queue ordering: " + + "companies awaiting first approval, then those with a pending change " + + "request, then everyone else — newest first within each group. The " + + "other values are plain column sorts.", }) @IsOptional() - @IsIn(["name", "createdAt", "updatedAt"]) - sortBy?: "name" | "createdAt" | "updatedAt"; + @IsIn(["review", "name", "createdAt", "updatedAt"]) + sortBy?: "review" | "name" | "createdAt" | "updatedAt"; - @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "ASC" }) + @ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" }) @IsOptional() @Transform(({ value }: { value: unknown }) => String(value).toUpperCase()) @IsIn(["ASC", "DESC"]) diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index 256641074..916bb940d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,8 +1,6 @@ -import { Company } from '../entities/company.entity'; import { ExternalProfile, } from '../entities/external-profile.entity'; -import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -13,20 +11,12 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; - /** The active operational mode (importer/exporter/forwarder). */ - activeProfileType?: ProfileType | null; - /** - * The id of the company_profile matching activeProfileType, resolved - * server-side so the client never re-derives it. Null until a company - * (with profiles) is loaded and a matching profile exists. - */ - activeCompanyProfileId?: string | null; onboardingStep?: string | null; onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile, company?: Company) { + constructor(profile: ExternalProfile) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -35,13 +25,8 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; - this.activeProfileType = profile.activeProfileType ?? null; this.onboardingStep = profile.onboardingStep ?? null; this.onboardingCompleted = profile.onboardingCompleted ?? false; - this.activeCompanyProfileId = - company?.companyProfiles?.find( - (p) => p.type === profile.activeProfileType, - )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts deleted file mode 100644 index ac8f57a93..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsEnum } from 'class-validator'; -import { ProfileType } from '../entities/company-profile.entity'; - -export class SetActiveModeDto { - @IsEnum(ProfileType) - type!: ProfileType; -} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 93e499b5e..84f644091 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,7 +1,6 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; -import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -32,21 +31,6 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; - /** - * The operational profile the user is currently "in" (importer vs exporter, - * or the single forwarder profile). Drives header switching and scopes the - * customer's bookings / dashboard to that company_profile. Nullable for - * users who haven't picked a role yet. - */ - @Column({ - name: 'active_profile_type', - type: 'varchar', - length: 32, - nullable: true, - enum: ProfileType, - }) - activeProfileType?: ProfileType | null; - /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ @Column({ name: 'onboarding_step', 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 df930d805..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), @@ -320,6 +319,12 @@ export class ContractBookingService { await this.applyWeightResults(loaded); } const computed = await this.bookingPricingService.computePriceForBooking(loaded); + // A partially-priced booking (e.g. 40ft has a rate, 20ft has none) has + // a positive total, so the zero-price gate below misses it — enforce + // the pricing hard blocks first. The catch below rolls everything back. + if (computed.hardBlocked.length > 0) { + throw new BadRequestException(computed.hardBlocked.join('; ')); + } // Reject a zero-price booking outright. A total of 0 means no contract rate // matched the route/container (or the rate is unset), so the booking is not // valid to ship or invoice. The catch below rolls back the row + its lines. @@ -530,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) => @@ -544,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, @@ -583,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); @@ -732,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); @@ -744,15 +743,20 @@ export class ContractBookingService { const computed = await this.bookingPricingService.computePriceForBooking(loaded); // A zero price means no contract rate matches — roll the cargo back so // the instance stays CLEARANCE_READY and can be completed again once - // the contract rates are fixed (the clearance work is not lost). - if (!(computed.totalAmount > 0)) { + // the contract rates are fixed (the clearance work is not lost). A + // pricing hard block (e.g. one of two container sizes has no rate) + // rolls back the same way: a partially-priced total is positive but + // the booking must not proceed. + if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) { await this.bookingsRepository.deleteContainers(booking.id); await this.bookingsRepository.update(booking.id, { cargoTotalWeightVgm: 0, } as never); throw new BadRequestException( - 'Booking price came out as 0 — no contract rate matches this ' + - 'route/cargo. Set the contract rate and try again.', + computed.hardBlocked.length > 0 + ? computed.hardBlocked.join('; ') + : 'Booking price came out as 0 — no contract rate matches this ' + + 'route/cargo. Set the contract rate and try again.', ); } await this.bookingsRepository.update(booking.id, { @@ -1785,6 +1789,10 @@ export class ContractBookingService { // pricing service derives wagon counts from the in-memory lines. const route = await this.resolveRoute(contract, dto.contractRouteId); const previewBooking = Object.assign(new Booking(), { + // contractId makes the preview price off the contract's frozen rate + // snapshots exactly like the persisted booking will — without it the + // preview total is 0 on a leg with no live rate and the form blocks. + contractId: contract.id, freightType: contract.freightType, tradeDirection: contract.tradeDirection, paymentCurrency: contract.paymentCurrency, @@ -1892,7 +1900,10 @@ export class ContractBookingService { overweightSurchargeAmount, currency: computed.currency, pairingErrors, - capacityErrors: [...scopeErrors, ...capacityErrors], + // Pricing hard blocks (missing rate for a container size / requested + // service) ride the capacity-errors channel so the form hard-blocks in + // the preview instead of failing at the create call. + capacityErrors: [...scopeErrors, ...capacityErrors, ...computed.hardBlocked], containerClashErrors, spaceErrors, lineItems: computed.lineItems, 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 6ca4473cd..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); } @@ -196,7 +203,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // Staff see every contract; customers are force-scoped to their own company. - if (hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + hasFreightPermission(user, FREIGHT_PERMS.bookings.view) || + hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { return this.contractsService.findAll(filter); } const userId = user?.id; @@ -273,7 +283,8 @@ export class ContractsController { if ( !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && !hasFreightPermission(user, FREIGHT_PERMS.bookings.clearanceView) && - !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) + !hasFreightPermission(user, FREIGHT_PERMS.bookings.reviewDocuments) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } @@ -333,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', @@ -372,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)', @@ -392,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') @@ -475,7 +508,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { view, html, signatures } = @@ -510,7 +546,10 @@ export class ContractsController { @Res() res: Response, ): Promise { const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } const { stream, record } = await this.transitionService.streamContractPdf(id); @@ -566,9 +605,12 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { // H12(c): a customer may only renew a contract their company owns. Staff - // with bookings.view bypass, mirroring getContractView/downloadContractDocument. + // with bookings.view/contracts.view bypass, mirroring getContractView/downloadContractDocument. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.transitionService.renew(id, resolveAuthUserId(user)); @@ -592,9 +634,12 @@ export class ContractsController { @UploadedFiles() files: Express.Multer.File[], ) { // H12(c): only the owning company's customer may upload clearance docs. - // Staff with bookings.view bypass, mirroring the other contract handlers. + // Staff with bookings.view/contracts.view bypass, mirroring the other contract handlers. const contract = await this.contractsService.findById(id); - if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + if ( + !hasFreightPermission(user, FREIGHT_PERMS.bookings.view) && + !hasFreightPermission(user, FREIGHT_PERMS.contracts.view) + ) { await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); } return this.clearanceService.uploadDocuments(id, files ?? []); 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/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index a675adf39..65f0637f0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -12,8 +12,6 @@ import { YardCountry } from '@edr/types'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { CompaniesService } from '../companies/companies.service'; -import { ProfileType } from '../companies/entities/company-profile.entity'; -import { CompanyStatus } from '../companies/entities/company.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { Yard } from '../rule-engine/entities/yard.entity'; import { FilesService } from '../files/files.service'; @@ -181,11 +179,7 @@ export class ContractsService { ); } const { company } = await this.companiesService.getCompanyInfoByUserId(userId); - if (company.status !== CompanyStatus.Active) { - throw new ForbiddenException( - "Your company is awaiting approval — you can't create contracts yet.", - ); - } + this.companiesService.assertCompanyActiveFor(company, 'contracts'); companyId = company.id; } @@ -193,31 +187,31 @@ export class ContractsService { this.assertRouteShape(dto.contractKind, dto.routes); await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes); - // Stamp the operational profile (importer/exporter) for portal scoping. + // Stamp the operational profile for portal scoping. A forwarder contract + // pins its profile explicitly (trade direction can't tell it apart from a + // direct import/export); everything else resolves from the trade direction. let companyProfileId: string | null = null; if (!isGovernment && companyId) { - let fallbackType: ProfileType | null = null; - if (userId) { - try { - const { profile } = - await this.companiesService.getCompanyInfoByUserId(userId); - fallbackType = profile.activeProfileType ?? null; - } catch { - // No profile (e.g. staff creating on behalf) — fall back to mapping. - } - } - companyProfileId = - await this.companiesService.resolveCompanyProfileIdForBooking( - companyId, - dto.tradeDirection, - fallbackType, - ); + if (dto.companyProfileId) { + const profile = + await this.companiesService.getActiveCompanyProfileForBooking( + companyId, + dto.companyProfileId, + ); + companyProfileId = profile.id; + } else { + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + dto.tradeDirection, + ); - const customerSelfBooking = !dto.companyId && !!userId; - if (customerSelfBooking && companyProfileId) { - await this.companiesService.assertCompanyProfileApprovedForBooking( - companyProfileId, - ); + const customerSelfBooking = !dto.companyId && !!userId; + if (customerSelfBooking && companyProfileId) { + await this.companiesService.assertCompanyProfileApprovedForBooking( + companyProfileId, + ); + } } } 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/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 688e0b4c7..fb4f40654 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -124,6 +124,16 @@ export class CreateContractDto { @IsUUID() companyId?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Explicit company profile to stamp the contract to (a forwarder contract); ' + + 'commercial contracts otherwise auto-resolve from trade direction.', + }) + @IsOptional() + @IsUUID() + companyProfileId?: string; + @ApiProperty({ enum: CONTRACT_KINDS, description: 'ONE_TIME | GENERAL' }) @IsIn([...CONTRACT_KINDS]) contractKind!: string; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts index 6d2afce3c..2ece44f12 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -13,6 +13,7 @@ export const INCIDENT_TYPES = [ 'CONTAINER_OPENED', 'CONTAINER_DAMAGED', 'FLUID_LEAKING', + 'OTHER', ] as const; export type IncidentType = (typeof INCIDENT_TYPES)[number]; 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/first-mile/first-mile.service.ts b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts index 0143f0796..948853e22 100644 --- a/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts +++ b/apps/edr-freight-api/src/modules/first-mile/first-mile.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { FindOptionsWhere, In, IsNull, Not } from 'typeorm'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { VehicleAvailability } from '../vehicles/entities/vehicle.entity'; import { BookingsRepository } from "../bookings/bookings.repository"; import { DriversService } from "../drivers/drivers.service"; @@ -66,6 +67,7 @@ export class FirstMileService { for (const r of records) { (r as FirstMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'FIRST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto 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/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 43887d468..601e03aec 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -12,6 +12,7 @@ import { SELF_HAUL_CONFLICT_MESSAGE, usesEdrMileService, } from '../../common/mile-haulage.util'; +import { attachMileFinancials } from '../../common/mile-financials.util'; import { assertBulkTonnageRemains, assertTruckCountWithinContainers, @@ -88,6 +89,7 @@ export class LastMileService { for (const r of records) { (r as LastMile & { invoice?: unknown }).invoice = byId.get(r.id) ?? null; } + await attachMileFinancials(this.dataSource, records, 'LAST_MILE'); } /** Resolve a vehicle's driver + human labels, for stamping mile events onto 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/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts index cac5fdba0..3aefe7cdb 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { OtpController } from './otp.controller'; +import { OtpService } from './otp.service'; describe('OtpController', () => { let controller: OtpController; @@ -7,6 +8,9 @@ describe('OtpController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [OtpController], + providers: [ + { provide: OtpService, useValue: { send: jest.fn(), verify: jest.fn() } }, + ], }).compile(); controller = module.get(OtpController); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts index 0494b48b6..00f8d107a 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -1,33 +1,46 @@ -import { OtpService, normalizeOtpTarget } from './otp.service'; +import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service"; -describe('normalizeOtpTarget', () => { - it('canonicalises Ethiopian forms to one E.164 key', () => { - const forms = ['+251986680099', '251986680099', '0986680099', '+251 98 668 0099']; +describe("normalizeOtpTarget", () => { + it("canonicalises Ethiopian forms to one E.164 key", () => { + const forms = [ + "+251986680099", + "251986680099", + "0986680099", + "+251 98 668 0099", + ]; const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone); - expect(new Set(keys)).toEqual(new Set(['+251986680099'])); + expect(new Set(keys)).toEqual(new Set(["+251986680099"])); }); - it('maps local 07… mobile to +2517…', () => { - expect(normalizeOtpTarget({ phone: '0712345678' }).phone).toBe('+251712345678'); - }); - - it('canonicalises email case and surrounding whitespace to one key', () => { - const forms = ['a@b.com', 'A@B.com', ' a@B.COM ', 'A@b.COM']; + it("canonicalises email case and surrounding whitespace to one key", () => { + const forms = ["a@b.com", "A@B.com", " a@B.COM ", "A@b.COM"]; const keys = forms.map((email) => normalizeOtpTarget({ email }).email); - expect(new Set(keys)).toEqual(new Set(['a@b.com'])); + expect(new Set(keys)).toEqual(new Set(["a@b.com"])); }); - it('keeps an already-normalised email stable (idempotent)', () => { - const once = normalizeOtpTarget({ email: ' User@Example.COM ' }).email!; + it("keeps an already-normalised email stable (idempotent)", () => { + const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!; expect(normalizeOtpTarget({ email: once }).email).toBe(once); }); - it('keeps an already-normalised number stable (idempotent)', () => { - const once = normalizeOtpTarget({ phone: '0986680099' }).phone!; + it("keeps an already-normalised number stable (idempotent)", () => { + const once = normalizeOtpTarget({ phone: "0986680099" }).phone!; expect(normalizeOtpTarget({ phone: once }).phone).toBe(once); }); }); +describe("isDomesticPhone", () => { + it.each(["+251986680099", "0986680099", "251986680099"])( + "accepts Ethiopian mobile form %s", + (phone) => expect(isDomesticPhone(phone)).toBe(true), + ); + + it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])( + "rejects non-domestic or malformed %s", + (phone) => expect(isDomesticPhone(phone)).toBe(false), + ); +}); + interface FakeRow { id: string; phone?: string; @@ -51,7 +64,8 @@ function makeService( let nextId = 1; const matches = (row: FakeRow, t: { phone?: string; email?: string }) => - (!!t.email && row.email === t.email) || (!!t.phone && row.phone === t.phone); + (!!t.email && row.email === t.email) || + (!!t.phone && row.phone === t.phone); const repo = { findByTarget: jest.fn( @@ -89,30 +103,30 @@ function makeService( return { service, sms, email, rows: () => rows }; } -describe('OtpService — send/verify agree across phone formats', () => { - it('verifies a code sent to +251… when verify is called with 09…', async () => { +describe("OtpService — send/verify agree across phone formats", () => { + it("verifies a code sent to +251… when verify is called with 09…", async () => { const { service, rows } = makeService(); - await service.sendOtp({ phone: '+251986680099' }); + await service.sendOtp({ phone: "+251986680099" }); await expect( - service.verifyOtpForAction({ phone: '0986680099' }, rows()[0]!.otp), + service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('verifies a code sent to User@X.com when verify is called with user@x.com', async () => { + it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => { const { service, rows } = makeService(); - await service.sendOtp({ email: ' User@Example.COM ' }); + await service.sendOtp({ email: " User@Example.COM " }); await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); }); -describe('OtpService — dual-channel send', () => { - const both = { phone: '0986680099', email: 'User@Example.COM' }; +describe("OtpService — dual-channel send", () => { + const both = { phone: "0986680099", email: "User@Example.COM" }; - it('sends ONE code to both transports', async () => { + it("sends ONE code to both transports", async () => { const { service, sms, email, rows } = makeService(); await service.sendOtp(both); @@ -122,72 +136,95 @@ describe('OtpService — dual-channel send', () => { // Same secret on both messages — the user types whichever arrives first. expect(sms.sendSms).toHaveBeenCalledWith( expect.objectContaining({ - to: '+251986680099', + to: "+251986680099", message: expect.stringContaining(otp), }), ); expect(email.sendEmail).toHaveBeenCalledWith( expect.objectContaining({ - to: 'user@example.com', + to: "user@example.com", text: expect.stringContaining(otp), }), ); // One row, both channels canonicalised. expect(rows()).toHaveLength(1); expect(rows()[0]).toMatchObject({ - phone: '+251986680099', - email: 'user@example.com', + phone: "+251986680099", + email: "user@example.com", }); }); it.each([ - ['phone alone', { phone: '0986680099' }], - ['email alone', { email: 'user@example.com' }], - ['both', both], - ])('verifies a dual-channel code when quoted back by %s', async (_label, target) => { - const { service, rows } = makeService(); - await service.sendOtp(both); + ["phone alone", { phone: "0986680099" }], + ["email alone", { email: "user@example.com" }], + ["both", both], + ])( + "verifies a dual-channel code when quoted back by %s", + async (_label, target) => { + const { service, rows } = makeService(); + await service.sendOtp(both); - await expect( - service.verifyOtpForAction(target, rows()[0]!.otp), - ).resolves.toEqual({ success: true }); - }); + await expect( + service.verifyOtpForAction(target, rows()[0]!.otp), + ).resolves.toEqual({ success: true }); + }, + ); - it('consuming the code via one channel kills the other', async () => { + it("consuming the code via one channel kills the other", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; - await service.verifyOtpForAction({ email: 'user@example.com' }, otp); + await service.verifyOtpForAction({ email: "user@example.com" }, otp); // Single-use is per-code, not per-channel: the phone half must be dead too. await expect( - service.verifyOtpForAction({ phone: '0986680099' }, otp), + service.verifyOtpForAction({ phone: "0986680099" }, otp), ).rejects.toThrow(/No verification code was requested/); }); - it('replaces an overlapping single-channel row instead of colliding with it', async () => { + it("replaces an overlapping single-channel row instead of colliding with it", async () => { const { service, rows } = makeService(); // A pending signup code on the phone only, then a dual-channel send. - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); await service.sendOtp(both); expect(rows()).toHaveLength(1); - expect(rows()[0]).toMatchObject({ email: 'user@example.com' }); + expect(rows()[0]).toMatchObject({ email: "user@example.com" }); }); - it('degrades to one channel when the account has only one contact', async () => { + it("degrades to one channel when the account has only one contact", async () => { const { service, sms, email } = makeService(); - await service.sendOtp({ phone: '0986680099' }); + await service.sendOtp({ phone: "0986680099" }); expect(sms.sendSms).toHaveBeenCalledTimes(1); expect(email.sendEmail).not.toHaveBeenCalled(); }); - it('still succeeds when one transport throws', async () => { + it("skips SMS for a foreign number when email is available", async () => { + const { service, sms, email, rows } = makeService(); + await service.sendOtp({ phone: "+14155550123", email: "user@example.com" }); + + // The gateway is domestic-only — email is the delivery route, but the + // foreign phone stays on the row so verify still matches either channel. + expect(sms.sendSms).not.toHaveBeenCalled(); + expect(email.sendEmail).toHaveBeenCalledTimes(1); + await expect( + service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp), + ).resolves.toEqual({ success: true }); + }); + + it("still attempts SMS for a foreign number when it is the only channel", async () => { + const { service, sms } = makeService(); + await service.sendOtp({ phone: "+14155550123" }); + + expect(sms.sendSms).toHaveBeenCalledTimes(1); + }); + + it("still succeeds when one transport throws", async () => { const { service, rows } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); @@ -197,24 +234,24 @@ describe('OtpService — dual-channel send', () => { }); // The code is live and verifiable on the channel that worked. await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, rows()[0]!.otp), + service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp), ).resolves.toEqual({ success: true }); }); - it('fails the request when every transport throws', async () => { + it("fails the request when every transport throws", async () => { const { service } = makeService({ sms: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, email: async () => { - throw new Error('broker down'); + throw new Error("broker down"); }, }); - await expect(service.sendOtp(both)).rejects.toThrow('Failed to send OTP'); + await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP"); }); - it('shares one brute-force budget across both channels', async () => { + it("shares one brute-force budget across both channels", async () => { const { service, rows } = makeService(); await service.sendOtp(both); const otp = rows()[0]!.otp; @@ -222,17 +259,17 @@ describe('OtpService — dual-channel send', () => { // Alternating channels must not hand the attacker two independent budgets: // 5 wrong guesses in total burn the code regardless of how they are split. for (const target of [ - { phone: '0986680099' }, - { email: 'user@example.com' }, - { phone: '0986680099' }, - { email: 'user@example.com' }, + { phone: "0986680099" }, + { email: "user@example.com" }, + { phone: "0986680099" }, + { email: "user@example.com" }, ]) { - await expect(service.verifyOtpForAction(target, '000000')).rejects.toThrow( - 'Invalid verification code', - ); + await expect( + service.verifyOtpForAction(target, "000000"), + ).rejects.toThrow("Invalid verification code"); } await expect( - service.verifyOtpForAction({ email: 'user@example.com' }, '000000'), + service.verifyOtpForAction({ email: "user@example.com" }, "000000"), ).rejects.toThrow(/Too many incorrect attempts/); // Burned: even the correct code no longer works. diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts index e19323067..557548b00 100644 --- a/apps/edr-freight-api/src/modules/otp/otp.service.ts +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -36,9 +36,9 @@ function channelsOf(target: OtpTarget): Array<"email" | "sms"> { */ function normalizePhone(rawPhone: string): string { const raw = rawPhone.trim(); - const digits = raw.replace(/[^\d+]/g, ''); - if (digits.startsWith('+')) return digits; - const bare = digits.replace(/^0+/, ''); + const digits = raw.replace(/[^\d+]/g, ""); + if (digits.startsWith("+")) return digits; + const bare = digits.replace(/^0+/, ""); if (/^251\d{9}$/.test(digits)) return `+${digits}`; if (/^9\d{8}$|^7\d{8}$/.test(bare)) return `+251${bare}`; // Unknown shape (foreign number, already-clean intl without +) — prefix + if @@ -46,6 +46,16 @@ function normalizePhone(rawPhone: string): string { return digits.length >= 11 ? `+${digits}` : raw; } +/** + * Whether a phone is an Ethiopian mobile the SMS gateway can actually reach — + * the carrier integration is domestic-only, so a send to anything else is + * queued and silently lost. Callers use this to fall back to email instead of + * pretending an SMS is on its way. + */ +export function isDomesticPhone(rawPhone: string): boolean { + return /^\+2519\d{8}$/.test(normalizePhone(rawPhone)); +} + /** * Canonicalise every channel present on the target. Each field is normalised * independently — a dual-channel target must end up with both halves in their @@ -143,6 +153,20 @@ export class OtpService { // /otp/verify routes (a NestJS ThrottlerGuard / @Throttle) — none exists // in the codebase yet. + // A foreign number is unreachable by the domestic-only SMS gateway; when + // email is also on the target, go email-only rather than queueing an SMS + // that will never arrive. With no email the SMS attempt stays — it is the + // only route there is. + const smsPhone = + target.phone && (!target.email || isDomesticPhone(target.phone)) + ? target.phone + : null; + if (target.phone && !smsPhone) { + this.logger.warn( + `otp.dispatch.sms-skipped target=${label} — non-domestic phone, delivering via email only`, + ); + } + // Fan out to every channel the target has, independently: one transport // being down must not suppress the other, which is the whole point of // sending to both. Each helper swallows its own failure so a rejected @@ -150,16 +174,14 @@ export class OtpService { const outcomes = ( await Promise.all([ target.email ? this.dispatchEmail(target.email, otp) : null, - target.phone ? this.dispatchSms(target.phone, otp) : null, + smsPhone ? this.dispatchSms(smsPhone, otp) : null, ]) ).filter((outcome): outcome is DispatchOutcome => outcome !== null); for (const outcome of outcomes) { this.logger.log( - `otp.dispatch channel=${outcome.channel} target=${label} queued=${ - outcome.queued - } latencyMs=${Date.now() - startedAt}${ - outcome.error ? ` error=${outcome.error}` : "" + `otp.dispatch channel=${outcome.channel} target=${label} queued=${outcome.queued + } latencyMs=${Date.now() - startedAt}${outcome.error ? ` error=${outcome.error}` : "" }`, ); } @@ -183,8 +205,7 @@ export class OtpService { // user who never receives a code — indistinguishable from carrier loss, // and the misleading success response makes it look like our side worked. this.logger.error( - `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${ - process.env.RABBITMQ_ENABLED ?? "unset" + `otp.dispatch.dropped channels=${channels.join("+")} target=${label} rabbitmqEnabled=${process.env.RABBITMQ_ENABLED ?? "unset" } — no transport reported hand-off; no code will arrive for this send`, ); } @@ -209,8 +230,7 @@ export class OtpService { // Log the real cause (DB/SMS/email failure) with its stack so a deployed // "Failed to send OTP" 400 is diagnosable from the API logs, not opaque. this.logger.error( - `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${ - Date.now() - startedAt + `otp.dispatch.failed channels=${channels.join("+")} target=${label} latencyMs=${Date.now() - startedAt }: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined, ); @@ -270,9 +290,7 @@ export class OtpService { * address while printing the credential next to it would buy nothing. */ private targetLabel(target: OtpTarget): string { - return ( - [target.email, target.phone].filter(Boolean).join("+") || "unknown" - ); + return [target.email, target.phone].filter(Boolean).join("+") || "unknown"; } /** @@ -288,9 +306,8 @@ export class OtpService { ) { const line = `otp.verify channels=${channelsOf(target).join( "+", - )} target=${this.targetLabel(target)} mode=${mode} result=${result}${ - detail ? ` ${detail}` : "" - }`; + )} target=${this.targetLabel(target)} mode=${mode} result=${result}${detail ? ` ${detail}` : "" + }`; if (result === "ok") this.logger.log(line); else this.logger.warn(line); } @@ -439,7 +456,12 @@ export class OtpService { await this.otpRepository.deleteOtp(otpData); this.actionAttempts.delete(key); - this.logVerify(target, "action", "expired", `ageMs=${ageMs} ttlMs=${ttlMs}`); + this.logVerify( + target, + "action", + "expired", + `ageMs=${ageMs} ttlMs=${ttlMs}`, + ); throw new BadRequestException( "Verification code has expired. Request a new one.", ); 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/procurement/dto/procurement.dto.ts b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts index 943d79296..cfab53a5d 100644 --- a/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts +++ b/apps/edr-freight-api/src/modules/procurement/dto/procurement.dto.ts @@ -7,6 +7,7 @@ import { IsOptional, IsEnum, IsBoolean, + MinLength, } from 'class-validator'; import { VendorType } from '../entities/vendor.entity'; import { AcquisitionType, AcquisitionStatus } from '../entities/asset-acquisition.entity'; @@ -72,6 +73,11 @@ export class UpdateVendorDto { } export class CreateAcquisitionDto { + /** WHAT was acquired — required so an acquisition can't be saved empty. */ + @IsString() + @MinLength(2) + itemName!: string; + @IsOptional() @IsUUID() vehicleId?: string; @@ -120,6 +126,11 @@ export class CreateAcquisitionDto { } export class UpdateAcquisitionDto { + @IsOptional() + @IsString() + @MinLength(2) + itemName?: string; + @IsOptional() @IsUUID() vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts index d4f781c15..e1a9f4ff5 100644 --- a/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts +++ b/apps/edr-freight-api/src/modules/procurement/entities/asset-acquisition.entity.ts @@ -18,6 +18,12 @@ export enum AcquisitionStatus { @Entity({ name: 'asset_acquisitions', schema: 'freight' }) @Index(['vehicleId', 'acquisitionDate']) export class AssetAcquisition extends BaseEntity { + /** WHAT was acquired (vehicle, parts, equipment…) — the asset itself. */ + @Column({ name: 'item_name', type: 'varchar', length: 200, nullable: true }) + itemName?: string; + + /** Optional link — only when the acquisition IS a fleet vehicle. Parts and + * general procurement stay unlinked so reports don't misattribute them. */ @Column({ name: 'vehicle_id', type: 'uuid', nullable: true }) vehicleId?: string; diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts new file mode 100644 index 000000000..8004d9d9e --- /dev/null +++ b/apps/edr-freight-api/src/modules/procurement/procurement.acquisition-guard.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +import { ProcurementService } from './procurement.service'; +import { AcquisitionType } from './entities/asset-acquisition.entity'; + +// PURCHASE acquisitions must not carry lease terms; LEASE/RENTAL may. +describe('ProcurementService acquisition lease-field guard', () => { + const repo = { + createAcquisition: jest.fn(async (dto) => dto), + findAcquisitionById: jest.fn(async () => ({ acquisitionType: AcquisitionType.PURCHASE })), + updateAcquisition: jest.fn(async (_id, dto) => dto), + }; + const svc = new ProcurementService(repo as never); + + it('rejects a PURCHASE with lease dates', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + } as never), + ).rejects.toThrow(BadRequestException); + }); + + it('accepts a LEASE with lease dates and a plain PURCHASE', async () => { + await expect( + svc.createAcquisition({ + itemName: 'Rented crane', + acquisitionType: AcquisitionType.LEASE, + acquisitionDate: '2026-07-22', + leaseStart: '2026-07-01', + leaseEnd: '2027-07-01', + } as never), + ).resolves.toBeDefined(); + await expect( + svc.createAcquisition({ + itemName: 'Brake pads', + acquisitionType: AcquisitionType.PURCHASE, + acquisitionDate: '2026-07-22', + } as never), + ).resolves.toBeDefined(); + }); + + it('rejects adding lease terms to an acquisition that is a PURCHASE', async () => { + await expect( + svc.updateAcquisition('a1', { monthlyPayment: 500 } as never), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts index e799d5ff9..7095a6a09 100644 --- a/apps/edr-freight-api/src/modules/procurement/procurement.service.ts +++ b/apps/edr-freight-api/src/modules/procurement/procurement.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { ProcurementRepository } from './procurement.repository'; import { Vendor } from './entities/vendor.entity'; -import { AssetAcquisition } from './entities/asset-acquisition.entity'; +import { AcquisitionType, AssetAcquisition } from './entities/asset-acquisition.entity'; import { AssetDisposal } from './entities/asset-disposal.entity'; import { CreateVendorDto, @@ -51,7 +51,23 @@ export class ProcurementService { } // ---- Acquisitions ---- + /** Lease terms only make sense on LEASE / RENTAL — a PURCHASE must not carry them. */ + private assertLeaseFieldsValid(dto: { + acquisitionType?: string; + leaseStart?: string; + leaseEnd?: string; + monthlyPayment?: number; + }): void { + if (dto.acquisitionType !== AcquisitionType.PURCHASE) return; + if (dto.leaseStart || dto.leaseEnd || dto.monthlyPayment != null) { + throw new BadRequestException( + 'Lease start/end and monthly payment are not valid for a PURCHASE acquisition', + ); + } + } + async createAcquisition(dto: CreateAcquisitionDto): Promise { + this.assertLeaseFieldsValid(dto); return this.procurementRepository.createAcquisition(dto); } @@ -64,6 +80,20 @@ export class ProcurementService { } async updateAcquisition(id: string, dto: UpdateAcquisitionDto): Promise { + // Validate against the resulting record, not just the patch — switching an + // acquisition to PURCHASE must also shed any stored lease terms. + const existing = await this.procurementRepository.findAcquisitionById(id); + if (existing) { + const next = { ...existing, ...dto }; + if (next.acquisitionType === AcquisitionType.PURCHASE) { + this.assertLeaseFieldsValid({ + acquisitionType: next.acquisitionType, + leaseStart: dto.leaseStart, + leaseEnd: dto.leaseEnd, + monthlyPayment: dto.monthlyPayment, + }); + } + } return this.procurementRepository.updateAcquisition(id, dto); } 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/controllers/cargo-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts index 421e49165..3b1bf6ce1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/cargo-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto'; import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class CargoTypesController { constructor(private readonly service: CargoTypesService) {} @Get() - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'List cargo types' }) findAll(@Query() query: ListCargoTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class CargoTypesController { } @Get(':id') - @RuleEngineView('cargo-types') + @StaffReference() @ApiOperation({ summary: 'Get a cargo type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts index 3c1c27c7c..9ae96af9b 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/container-types.controller.ts @@ -3,7 +3,8 @@ import { Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateContainerTypeDto } from '../dto/create-container-type.dto'; import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto'; import { MoveOrderDto } from '../dto/move-order.dto'; @@ -18,7 +19,7 @@ export class ContainerTypesController { constructor(private readonly service: ContainerTypesService) {} @Get() - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'List container types' }) findAll(@Query() query: ListContainerTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ContainerTypesController { } @Get(':id') - @RuleEngineView('container-types') + @StaffReference() @ApiOperation({ summary: 'Get a container type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts index c36d4fd79..85d76b326 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/service-types.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateServiceTypeDto } from '../dto/create-service-type.dto'; import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,7 @@ export class ServiceTypesController { constructor(private readonly service: ServiceTypesService) {} @Get() - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'List service types' }) findAll(@Query() query: ListServiceTypesQueryDto) { return this.service.findAll(query); @@ -41,7 +42,7 @@ export class ServiceTypesController { } @Get(':id') - @RuleEngineView('service-types') + @StaffReference() @ApiOperation({ summary: 'Get a service type by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts index baec6b785..f078624eb 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/shipping-lines.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateShippingLineDto } from '../dto/create-shipping-line.dto'; import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -16,14 +17,14 @@ export class ShippingLinesController { constructor(private readonly service: ShippingLinesService) {} @Get() - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'List shipping lines' }) findAll(@Query() query: ListRuleEngineQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('shipping-lines') + @StaffReference() @ApiOperation({ summary: 'Get a shipping line by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts index c43e7e4e0..b0ba2c8d5 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yard-distances.controller.ts @@ -12,7 +12,8 @@ import { Query, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { CreateYardDistanceDto } from '../dto/create-yard-distance.dto'; import { ListYardDistancesQueryDto } from '../dto/list-rule-engine-query.dto'; import { UpdateYardDistanceDto } from '../dto/update-yard-distance.dto'; @@ -25,14 +26,14 @@ export class YardDistancesController { constructor(private readonly service: YardDistancesService) {} @Get() - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'List yard distances' }) findAll(@Query() query: ListYardDistancesQueryDto) { return this.service.findAll(query); } @Get(':id') - @RuleEngineView('yard-distances') + @StaffReference() @ApiOperation({ summary: 'Get a yard distance by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(id); diff --git a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts index 40b2764bf..b8f88b6b3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/controllers/yards.controller.ts @@ -2,7 +2,8 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query, } from '@nestjs/common'; -import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards'; +import { RuleEngineManage } from '../../../common/rule-engine-guards'; +import { StaffReference } from '../../../common/booking-guards'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { CreateYardDto } from '../dto/create-yard.dto'; import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto'; @@ -18,7 +19,9 @@ export class YardsController { constructor(private readonly service: YardsService) {} @Get() - @RuleEngineView('yards') + // Reference read: every staff form/search needs the yard list (origin / + // destination pickers), so login is the only requirement. + @StaffReference() @ApiOperation({ summary: 'List yards' }) findAll(@Query() query: ListYardsQueryDto) { return this.service.findAll(query); @@ -41,7 +44,7 @@ export class YardsController { } @Get(':id') - @RuleEngineView('yards') + @StaffReference() @ApiOperation({ summary: 'Get a yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.service.findById(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 new file mode 100644 index 000000000..a736d4b05 --- /dev/null +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -0,0 +1,424 @@ +import { RuleEngineService } from './rule-engine.service'; +import type { BookingEvaluationInput } from './rule-engine.service'; +import type { Rate } from './entities/rate.entity'; + +describe('RuleEngineService — requested service without a configured surcharge rate', () => { + const hazardRate: Rate = { + id: 'rate-hazard', + rateType: 'HAZARD_SURCHARGE', + trigger: 'HAZARDOUS', + rateValue: 50, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let ratesRepo: { findLiveRates: jest.Mock }; + let service: RuleEngineService; + + beforeEach(() => { + ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) }; + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes + { findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits + { findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs + ratesRepo as never, + { findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines + {} as never, // dataSource (unused by evaluate) + ); + }); + + const input = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + containers: [], + ...overrides, + }); + + it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => { + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(1); + expect(result.hardBlocked[0]).toContain('hazardous'); + }); + + it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => { + ratesRepo.findLiveRates.mockResolvedValue([hazardRate]); + const result = await service.evaluate(input({ isHazardous: true })); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('does not block a non-hazardous booking when no surcharge rates exist', async () => { + const result = await service.evaluate(input({})); + expect(result.hardBlocked).toHaveLength(0); + }); + + it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => { + const result = await service.evaluate( + input({ + containers: [ + { + containerTypeId: 'ct-20', + quantity: 2, + vgmPerUnitTons: 10, + totalVgmTons: 20, + reeferQuantity: 1, + }, + ], + }), + ); + expect(result.hardBlocked).toHaveLength(1); + 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 9d7aa6ba9..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 @@ -28,6 +28,10 @@ import { } from './interfaces/shipping-lines.repository.interface'; import { GOVERNMENT_PRIORITY_BONUS } from './government-priority.constants'; +// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. +// from multipart form-data) and a non-empty "false" string is truthy. +const truthy = (v: unknown): boolean => v === true || v === 'true'; + export interface BookingContainerEvalInput { containerTypeId: string; quantity: number; @@ -44,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 { @@ -63,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 @@ -76,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[]; } @@ -87,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 { @@ -161,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; @@ -245,7 +280,53 @@ export class RuleEngineService { liveRates.filter((r) => r.trigger && r.trigger !== 'ALWAYS'), ); + // A handling service the booking asks for (booking-level flag OR any + // per-container opt-in count) with no LIVE surcharge rate configured is a + // hard block — pricing would otherwise ship the service for free. System- + // derived charges (consolidation, overweight, shipping line, lashing) stay + // exempt: the customer never opted into those, so they must not block. + const requestedServices: Array<{ + trigger: RateTrigger; + wanted: boolean; + label: string; + }> = [ + { + trigger: 'HAZARDOUS', + wanted: + truthy(input.isHazardous) || + input.containers.some((c) => Number(c.hazardousQuantity ?? 0) > 0), + label: 'hazardous cargo', + }, + { + trigger: 'REEFER', + wanted: + hasReefer || + input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), + label: 'refrigerated (reefer) cargo', + }, + ]; + for (const svc of requestedServices) { + if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { + hardBlocked.push( + `No ${svc.label} surcharge rate is configured — the booking cannot ` + + `be priced with this service. Remove the ${svc.label} option or ` + + 'ask EDR to configure its rate.', + ); + } + } + 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, @@ -338,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, @@ -348,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 @@ -438,9 +718,6 @@ export class RuleEngineService { hasLashing: boolean; }, ): boolean { - // Coerce defensively: a flag may arrive as the string "true"/"false" (e.g. - // from multipart form-data) and a non-empty "false" string is truthy. - const truthy = (v: unknown): boolean => v === true || v === 'true'; switch (trigger) { case 'HAZARDOUS': return truthy(state.isHazardous); 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 fc97f772b..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 @@ -570,6 +570,104 @@ describe('BookingBatchService — PAID reconcile', () => { }); }); + describe('expireLeftoverExportDay — export day sweep', () => { + const exportSchedule = { + id: scheduleId, + direction: 'EXPORT', + originStationId: 'yard-origin', + destinationStationId: 'yard-dest', + scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'), + windowPhase: 'DONE', + bookingWindowStatus: 'CLOSED', + }; + let unacceptedSpy: jest.SpyInstance; + let poolSpy: jest.SpyInstance; + + beforeEach(() => { + unacceptedSpy = jest + .spyOn(service, 'expireUnacceptedForRouteDay') + .mockResolvedValue(undefined); + poolSpy = jest.spyOn(service, 'expireLeftoverDayPool').mockResolvedValue(0); + }); + + it('ignores non-export schedules', async () => { + trainSchedulesRepository.findById.mockResolvedValue({ + ...exportSchedule, + direction: 'IMPORT', + }); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while another export train on the day can still take bookings', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('defers while a FULL train still has live pay windows', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + bookingsRepository.findReservedForSchedule.mockResolvedValue([ + { + paymentStatus: 'PENDING', + status: 'AWAITING_PAYMENT', + paymentDeadline: new Date(Date.now() + 60_000), + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).not.toHaveBeenCalled(); + expect(poolSpy).not.toHaveBeenCalled(); + }); + + it('sweeps un-accepted + waiting bookings once every train on the day is shut', async () => { + trainSchedulesRepository.findById.mockResolvedValue(exportSchedule); + trainSchedulesRepository.findAll.mockResolvedValue([ + exportSchedule, + { + ...exportSchedule, + id: 'sched-2', + windowPhase: 'OPEN', + bookingWindowStatus: 'FULL', + }, + ]); + + await service.expireLeftoverExportDay(scheduleId); + + expect(unacceptedSpy).toHaveBeenCalledWith({ + originYardId: 'yard-origin', + destinationYardId: 'yard-dest', + day: '2026-06-20', + }); + expect(poolSpy).toHaveBeenCalledWith(scheduleId); + }); + }); + describe('maybeOfferPartial — split-eligibility gate', () => { const importGeneral = { id: 'b1', @@ -817,6 +915,122 @@ describe('BookingBatchService — PAID reconcile', () => { ); }); }); + + describe('acceptIntercity — export pay window expires at window close', () => { + const exportScheduleId = 'export-train'; + // Window closes in 30 minutes; the configured pay window is 60 minutes. + const closesAt = new Date(Date.now() + 30 * 60_000); + + const waiting = { + id: 'ic-1', + reference: 'IC-1', + isGovernment: false, + status: 'FULLY_EXECUTED', + trainScheduleId: null, + freightType: 'CONTAINER', + cargoTotalWeightVgm: 10, + bookingContainers: [], + } as unknown as Booking; + + let scheduleRepo: { findOne: jest.Mock }; + let bookingRepo: { findOne: jest.Mock; update: jest.Mock; find: jest.Mock }; + + beforeEach(() => { + bookingRepo = dataSource.getRepository(); + bookingRepo.findOne.mockResolvedValue(waiting); + scheduleRepo = { findOne: jest.fn() }; + // reserve() reads the target schedule to clamp export deadlines — route + // TrainSchedule reads to their own repo, everything else stays as before. + dataSource.getRepository.mockImplementation((entity?: { name?: string }) => + entity?.name === 'TrainSchedule' ? scheduleRepo : bookingRepo, + ); + }); + + it('clamps the intercity pay deadline to the export window close', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: closesAt, + scheduledDepartureDate: new Date(closesAt.getTime() + 2 * 3_600_000), + }); + + await service.acceptIntercity(waiting, exportScheduleId); + + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ + status: 'SELECTED_FOR_BATCH', + paymentDeadline: closesAt, + }), + ); + expect(notifier.payNow).toHaveBeenCalledTimes(1); + }); + + it('keeps the plain payment window on import trains', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: 'import-train', + direction: 'IMPORT', + windowClosesAt: closesAt, + }); + + await service.acceptIntercity(waiting, 'import-train'); + + const deadline = ( + bookingsRepository.update.mock.calls[0][1] as { paymentDeadline: Date } + ).paymentDeadline; + // 60-minute pay window runs past the 30-minutes-out close: no clamp. + expect(deadline.getTime()).toBeGreaterThan(closesAt.getTime()); + }); + + it('rejects an accept after the export window closed — no pay window opens', async () => { + scheduleRepo.findOne.mockResolvedValue({ + id: exportScheduleId, + direction: 'EXPORT', + windowClosesAt: new Date(Date.now() - 60_000), + }); + + await expect( + service.acceptIntercity(waiting, exportScheduleId), + ).rejects.toThrow(/window has closed/); + expect(bookingsRepository.update).not.toHaveBeenCalled(); + expect(notifier.payNow).not.toHaveBeenCalled(); + }); + + it('expires an unpaid export ride-along at close and frees the train', async () => { + const lapsed = { + ...(waiting as unknown as Record), + status: 'SELECTED_FOR_BATCH', + trainScheduleId: exportScheduleId, + paymentDeadline: new Date(Date.now() - 1_000), + originYardId: 'yard-a', + destinationYardId: 'yard-b', + priorityScore: 0, + wagonsRequired: 1, + } as unknown as Booking; + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([lapsed]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // expire()'s paid-guard re-reads the booking fresh — still unpaid. + bookingRepo.findOne.mockResolvedValue(lapsed); + trainSchedulesRepository.findById.mockResolvedValue({ + id: exportScheduleId, + bookingWindowStatus: 'CLOSED', + windowPhase: 'DONE', + scheduledDepartureDate: new Date(Date.now() + 3_600_000), + originStationId: 'yard-a', + destinationStationId: 'yard-b', + }); + + await service.settleDueReservations(exportScheduleId); + + expect(notifier.expired).toHaveBeenCalledTimes(1); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'ic-1', + expect.objectContaining({ status: 'EXPIRED', trainScheduleId: null }), + ); + }); + }); }); describe('BookingBatchService — wagonsFor', () => { @@ -973,6 +1187,7 @@ describe('BookingBatchService — built-train wagon capacity', () => { reserved: Booking[]; maxWagons?: number; routeStops?: string[]; + yardCountries?: Record; }) => { const schedule = { id: scheduleId, @@ -1004,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(), @@ -1050,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'], @@ -1063,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 415b5df97..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'; @@ -570,6 +573,10 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // This payment may have been the last live pay window on a now-full + // export day — the settle that normally re-runs the sweep finds nothing + // left to settle, so trigger it here. + void this.expireLeftoverExportDay(booking.trainScheduleId); } const result = await this.trainSchedulingService.tryAutoWagonAllocation( @@ -673,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); @@ -821,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); @@ -891,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); @@ -933,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); @@ -1030,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", @@ -1347,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 @@ -1407,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), } @@ -1460,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"); @@ -1495,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, @@ -1527,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), } @@ -1596,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.`, @@ -1823,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.`, @@ -2254,6 +2265,11 @@ export class BookingBatchService implements OnModuleInit { `— payment phase extended for them`, ); } + // The settle may have resolved the last pay window on a full export day + // (paid → allocated, and the top-up found nothing else that fits) — sweep + // the date's leftover bookings. Self-guarded: no-op for import/domestic + // and while any train on the day can still take bookings. + await this.expireLeftoverExportDay(scheduleId); // Emitted here (not in settleDueReservations/settleBatch, which both wrap // this) so one settle produces one push, after every allocation/expiry/ // top-up extension for this schedule has been persisted. @@ -2350,6 +2366,9 @@ export class BookingBatchService implements OnModuleInit { ); if (schedule && (await this.isTrainFull(schedule))) { await this.setWindow(booking.trainScheduleId, "FULL"); + // Same as the webhook path: a staff mark-paid can settle the last live + // pay window on a now-full export day — sweep the date's leftovers. + void this.expireLeftoverExportDay(booking.trainScheduleId); } void this.triggerWagonAllocation(booking.trainScheduleId!); this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid"); @@ -2457,17 +2476,17 @@ 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); - // Built trains: collapse to a single train-wide pool so the freed capacity of - // a booking that alights mid-corridor is NOT re-offered on the pass-through - // leg (see remainingBudget). Keeps intercity accept consistent with the - // train-wide isTrainFull / committedWagons finalize signal. - const budget = await this.remainingBudget(schedule, limits, wagonDims, { - collapseForBuiltTrain: true, - }); + // Built trains use the leg-aware corridor budget too: the wagon planner + // consumes stock PER EDGE (planWagonsWithStock legs), so a consist wagon + // that runs empty Gelan→Adama genuinely can carry an intercity booking + // there before its export cargo boards at Adama. A train full on one leg + // still accepts ride-alongs on its empty legs — that is the whole point + // of the ride-along flow. + const budget = await this.remainingBudget(schedule, limits, wagonDims); return { budget, needFor: (booking) => this.needFor(booking, wagonDims) }; } @@ -2526,7 +2545,26 @@ export class BookingBatchService implements OnModuleInit { return; } const now = new Date(); - const deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + let deadline = new Date(now.getTime() + (await this.paymentWindowMs())); + // EXPORT parity: pay windows on an export train never outlive its booking + // window — export bookings expire at close, so anything reserved onto the + // same train (FCFS export or an intercity ride-along) must too. Import + // keeps the plain payment window; its cycles re-fill after settle. + const targetSchedule = await this.dataSource + .getRepository(TrainSchedule) + .findOne({ where: { id: scheduleId } }); + if (targetSchedule?.direction === "EXPORT") { + const cutoff = + targetSchedule.windowClosesAt ?? targetSchedule.scheduledDepartureDate; + if (cutoff && cutoff.getTime() <= now.getTime()) { + throw new BadRequestException( + "Export booking window has closed — cannot open a pay window on this train", + ); + } + if (cutoff && cutoff.getTime() < deadline.getTime()) { + deadline = new Date(cutoff); + } + } await this.bookingsRepository.update(booking.id, { trainScheduleId: scheduleId, status: "SELECTED_FOR_BATCH", @@ -2822,6 +2860,60 @@ export class BookingBatchService implements OnModuleInit { return leftovers.length; } + /** + * EXPORT counterpart of the conclude-time sweep. Export has no batch cycle, + * so nothing ever concluded its day: bookings still waiting when the trains + * filled up or the window closed stayed pending forever. Once every export + * train on this route-day is shut — window DONE, or FULL with no pay window + * still live that could lapse and free space — the date is dead: expire the + * un-accepted bookings staff can no longer accept AND the ready + * (FULLY_EXECUTED) bookings that never got a reservation (consolidation + * waiters). Runs at export window close and whenever an export train's + * fullness settles. + */ + async expireLeftoverExportDay(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (schedule?.direction !== "EXPORT" || !schedule.scheduledDepartureDate) { + return; + } + const day = eatDay(schedule.scheduledDepartureDate); + const trains = ( + await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: schedule.originStationId, + destinationStationId: schedule.destinationStationId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }) + ).filter( + (s) => + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day, + ); + for (const s of trains) { + // Any train still taking bookings keeps the date alive. + if (s.windowPhase !== "DONE" && s.bookingWindowStatus !== "FULL") return; + // A FULL train whose reservations are still inside their pay windows can + // reopen when one lapses unpaid — defer; the settle re-runs this sweep. + if (s.windowPhase !== "DONE" && (await this.hasLiveReservations(s.id))) { + return; + } + } + await this.expireUnacceptedForRouteDay({ + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day, + }); + await this.expireLeftoverDayPool(scheduleId); + } + /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings @@ -3029,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 @@ -3137,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; @@ -3158,7 +3249,7 @@ export class BookingBatchService implements OnModuleInit { return { wagons, weightTons: bookingGrossWeightTons( - Number(booking.cargoTotalWeightVgm ?? 0), + bookingCargoTons(booking), wagons, dims.tareWeightTons, ), @@ -3185,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( { @@ -3219,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 = @@ -3398,7 +3489,6 @@ export class BookingBatchService implements OnModuleInit { schedule: TrainSchedule, limits: TrainLimits, wagonDims: WagonDims, - opts?: { collapseForBuiltTrain?: boolean }, ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { @@ -3411,21 +3501,10 @@ export class BookingBatchService implements OnModuleInit { tolerance: { weightTons: 0, lengthMeters: 0 }, }; } - // A built train's wagons are coupled for the WHOLE trip, and the allocator - // commits each booking to a wagon for the entire route — it never reloads a - // wagon at a mid-corridor alight yard. So a built train has no leg concept: - // its capacity is one train-wide pool, exactly as isTrainFull / - // committedWagons already count it. When a caller opts in, collapse the - // corridor to a single whole-route edge so every booking (full-route OR - // mid-corridor) draws from that one pool — a train full of import-to-DireDawa - // then correctly shows NO room for a DireDawa->Addis intercity booking on the - // leg it merely passes through, instead of over-promising the freed slots. - // Locomotive-derived schedules keep the leg-aware multi-edge corridor: their - // abstract slot/weight/length budget genuinely frees past an alight yard. - const stops = - physicalWagons != null && opts?.collapseForBuiltTrain - ? [schedule.originStationId, schedule.destinationStationId] - : await this.stopsForSchedule(schedule); + // Built trains keep the leg-aware multi-edge corridor too: the wagon + // planner consumes stock per edge (planWagonsWithStock legs), so a consist + // wagon serves disjoint legs — capacity freed past an alight yard is real. + const stops = await this.stopsForSchedule(schedule); const budget = new CorridorBudget(stops, limits.base, limits.tolerance); const allocated = (schedule.scheduleBookings ?? []) .map((sb) => sb.booking) @@ -3532,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 = @@ -3590,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/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 9393c520f..abd07c129 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -20,6 +20,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; expireLeftoverDayPool: jest.Mock; + expireLeftoverExportDay: jest.Mock; fillFromWaitingList: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; @@ -75,6 +76,7 @@ describe('BookingWindowService — window state machine', () => { hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), expireLeftoverDayPool: jest.fn().mockResolvedValue(0), + expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined), // No waiting booking fits by default, so conclude proceeds to reopen/DONE. fillFromWaitingList: jest.fn().mockResolvedValue(0), }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 02c5fb993..3b9bb25d3 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -229,6 +229,11 @@ export class BookingWindowService implements OnModuleInit { await this.bookingBatchService.setWindow(schedule.id, 'CLOSED'); schedule.bookingWindowStatus = 'CLOSED'; } + // Export has no conclude step: this close is the last moment the day's + // bookings could have boarded. Once every train on the route-day is + // shut, expire what is still waiting for this date (the sweep defers + // while a sibling train stays open). + await this.bookingBatchService.expireLeftoverExportDay(schedule.id); return true; } return false; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts new file mode 100644 index 000000000..a93a8afc6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/move-wagon-load.dto.ts @@ -0,0 +1,11 @@ +import { IsUUID } from 'class-validator'; + +export class MoveWagonLoadDto { + /** + * Where the source wagon's whole load goes: a train-set wagon slot (empty → + * move, loaded → swap the two loads) or an empty consist-only physical wagon + * of the built train (→ the slot repins onto it). + */ + @IsUUID() + targetWagonId!: string; +} 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/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index b35650e16..293fc8801 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -146,10 +146,8 @@ export class IntercityService { remaining: capacity?.budget.maxRemaining() ?? null, candidates: waiting.map((booking) => { const need = capacity?.needFor(booking) ?? null; - // legForYards, not legOf: on a built train the budget is a single - // whole-route edge (see intercityCapacity), so a mid-corridor booking - // must draw from that one pool via the whole-route fallback. On a - // locomotive-derived schedule it still resolves to the booking's own leg. + // legForYards: the booking draws only from ITS OWN leg's edges, with a + // whole-route fallback when its yards aren't on the budget's stop list. const leg = capacity?.budget.legForYards( booking.originYardId, booking.destinationYardId, @@ -215,11 +213,8 @@ export class IntercityService { continue; } const need = capacity.needFor(booking); - // legForYards, not legOf: a built train's budget is a single whole-route - // pool (mid-corridor wagons are committed for the whole trip and never - // reloaded), so the booking draws from that pool via the whole-route - // fallback; a locomotive-derived schedule still gets the booking's own - // leg, so it can still board a train that is full only on other legs. + // legForYards: charge only the edges this booking rides, so it can still + // board a train that is full only on other legs. const leg = budget.legForYards(booking.originYardId, booking.destinationYardId); if (!budget.fits(need, leg)) { rejected.push({ 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 463ae7ea8..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); @@ -253,12 +274,41 @@ export function minLocomotiveLimits( maxTrainLengthMeters: Math.min( ...locomotives.map((l) => num(l.maxTrainLengthMeters, Infinity) || Infinity), ), - // Weakest locomotive's tolerance governs the set, same as its caps. - overageToleranceTons: Math.min(...locomotives.map((l) => num(l.overageToleranceTons))), - overageToleranceMeters: Math.min(...locomotives.map((l) => num(l.overageToleranceMeters))), + // Weakest CONFIGURED tolerance governs the set — a locomotive with no + // tolerance set has no opinion, it does not zero out the others. + overageToleranceTons: minConfigured(locomotives.map((l) => l.overageToleranceTons)), + overageToleranceMeters: minConfigured(locomotives.map((l) => l.overageToleranceMeters)), }; } +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.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3e142f778..b1f79733e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -28,6 +28,7 @@ import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; import { PinWagonsDto } from "./dto/pin-wagons.dto"; +import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto"; import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; @@ -374,6 +375,20 @@ export class TrainSchedulingController { return this.trainSchedulingService.updateContainerItem(id, itemId, dto); } + @Post("schedules/:id/wagons/:wagonId/move-load") + @TrainSchedulingManage() + @ApiOperation({ + summary: + "Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", + }) + moveWagonLoad( + @Param("id", ParseUUIDPipe) id: string, + @Param("wagonId", ParseUUIDPipe) wagonId: string, + @Body() dto: MoveWagonLoadDto, + ) { + return this.trainSchedulingService.moveWagonLoad(id, wagonId, dto); + } + @Get("schedules/:id/unassigned-bookings") @TrainSchedulingView() @ApiOperation({ summary: "Get unassigned bookings for a schedule" }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index af1a9eb45..6757a6e21 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -80,7 +80,12 @@ const makeBooking = ( describe('TrainSchedulingService', () => { let service: TrainSchedulingService; - let dataSource: { getRepository: jest.Mock; transaction: jest.Mock; query: jest.Mock }; + let dataSource: { + getRepository: jest.Mock; + transaction: jest.Mock; + query: jest.Mock; + manager: { getRepository: jest.Mock }; + }; let bookingsRepository: Record; let locomotivesRepository: { findById: jest.Mock; findAll: jest.Mock }; let wagonTypesRepository: { findAll: jest.Mock }; @@ -91,11 +96,23 @@ describe('TrainSchedulingService', () => { let wagonAllocationBulkLoadsRepository: Record; beforeEach(() => { + // findGroupSiblings runs a query builder off dataSource.manager; default it + // to "no sibling schedules" so isolated unit tests don't need to wire it. + const emptySiblingQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; dataSource = { getRepository: jest.fn(), transaction: jest.fn(), // Raw-SQL helper lookups (e.g. builtTrainIdOfSchedule) default to "no rows". query: jest.fn().mockResolvedValue([]), + manager: { + getRepository: jest.fn(() => ({ + createQueryBuilder: jest.fn(() => emptySiblingQb), + })), + }, }; bookingsRepository = { findEligibleForScheduling: jest.fn(), @@ -110,9 +127,11 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { findByBookingIds: jest.fn(), + findByScheduleId: jest.fn().mockResolvedValue([]), createMany: jest.fn(), deleteByScheduleAndBooking: jest.fn(), }; @@ -327,7 +346,11 @@ describe('TrainSchedulingService', () => { }); it('allows preview when bookings are already on the target schedule', async () => { - const bookings = [makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20)]; + // A booking already pinned to the target schedule is exempt from the + // corridor/day/status gates — mark it so on the entity, matching the link row. + const bookings = [ + { ...makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20), trainScheduleId: 'sched-target' }, + ]; wagonTypesRepository.findAll.mockResolvedValue([nw5]); bookingsRepository.findByIdsForScheduling.mockResolvedValue(bookings); @@ -354,7 +377,10 @@ describe('TrainSchedulingService', () => { expect(result.valid).toBe(true); }); - it('allows preview when selected bookings are on different schedule dates', async () => { + it('flags a booking scheduled for a different day than the train departure', async () => { + // The old cross-booking "must share the same schedule date" rule is gone; + // the live rule is that every booking must match the departure day. b2 + // departs a day later, so it's the one flagged. const bookings = [ makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT', 20, '2026-06-20T08:00:00.000Z'), makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT', 10, '2026-06-21T14:00:00.000Z'), @@ -375,7 +401,8 @@ describe('TrainSchedulingService', () => { expect(result.violations).not.toContain( 'Selected bookings must share the same schedule date', ); - expect(result.valid).toBe(true); + expect(result.violations.some((v) => v.includes('different day'))).toBe(true); + expect(result.valid).toBe(false); }); it('rejects bookings that are not in schedulable status', async () => { @@ -408,6 +435,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }; const locomotive2 = { ...locomotive, id: 'loc-2', code: 'LOC-002' }; @@ -421,6 +450,12 @@ describe('TrainSchedulingService', () => { const trainScheduleRepo = { create: jest.fn().mockImplementation((value) => value), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), + // findGroupWindowAnchor looks for same-day sibling schedules; none here. + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), }; const trainSetRepo = { create: jest.fn().mockImplementation((value) => value), @@ -464,19 +499,21 @@ describe('TrainSchedulingService', () => { callback(manager), ); + // Departure must clear the import lead window (≥ importWindowLeadDays ahead + // of now), so use a comfortably-future date rather than a hardcoded one. + const futureDeparture = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(); const result = await service.createContainerTrainSchedule({ routeId: 'route-1', - scheduleDate: '2026-06-20T08:00:00.000Z', + scheduleDate: futureDeparture, locomotiveIds: ['loc-1', 'loc-2'], }); expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainSetLocomotiveRepo.save).toHaveBeenCalled(); - expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith( - { id: expect.objectContaining({ _type: 'in', _value: ['loc-1', 'loc-2'] }) }, - { status: 'ASSIGNED' }, - ); + // Advance scheduling locks locomotives but does NOT flip them to ASSIGNED — + // one locomotive may sit on several future schedules. + expect(lockedLocomotiveRepo.update).not.toHaveBeenCalled(); expect(result.id).toBe('schedule-1'); }); @@ -492,7 +529,7 @@ describe('TrainSchedulingService', () => { destinationYardId: 'yard-destination', status: 'PAID', bookingContainers: [], - cargoType: { code: 'COFFEE' }, + cargoType: { id: 'cargo-coffee', code: 'COFFEE', wagonTypes: [cw3] }, }; wagonTypesRepository.findAll.mockImplementation(async ({ where }: { where?: { code?: string } }) => { @@ -511,7 +548,9 @@ describe('TrainSchedulingService', () => { }); expect(result.valid).toBe(true); - expect(result.summary.wagonType).toBe('MIXED'); + // Mixed freight now labels the summary by the concrete wagon type codes it uses. + expect(result.summary.wagonType).toContain('NW5'); + expect(result.summary.wagonType).toContain('CW3'); expect(result.wagonPlan.length).toBeGreaterThan(2); expect(result.containerUnits).toHaveLength(2); }); @@ -536,9 +575,11 @@ describe('TrainSchedulingService', () => { }); it('rejects create when the locked locomotive is no longer available', async () => { + // Advance scheduling only hard-blocks OUT_OF_SERVICE locomotives; other + // non-AVAILABLE states (e.g. ASSIGNED) downgrade to a warning. const manager = { getRepository: jest.fn(() => ({ - findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), + findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'OUT_OF_SERVICE' }), })), }; @@ -551,6 +592,8 @@ describe('TrainSchedulingService', () => { originYardId: 'yard-origin', destinationYardId: 'yard-destination', isActive: true, + status: 'AVAILABLE', + direction: 'IMPORT', }), }; } @@ -907,7 +950,7 @@ describe('TrainSchedulingService', () => { }); describe('getAvailableLocomotivesForRoute', () => { - it('returns locomotives at the route origin yard', async () => { + it('returns every in-service locomotive, annotated with origin-yard presence', async () => { const routeId = 'route-export'; const originYardId = 'yard-addis'; const routeRepo = { @@ -915,6 +958,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Djibouti', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Djibouti' }, @@ -924,21 +968,21 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); + // Advance-scheduling picker: nothing is filtered by yard — every in-service + // locomotive is returned and annotated with whether it's at the origin yet. locomotivesRepository.findAll.mockResolvedValue([ { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, + { id: 'l3', code: 'FAR', status: 'ASSIGNED', currentYardId: 'yard-elsewhere' }, ]); const result = await service.getAvailableLocomotivesForRoute(routeId); - expect(locomotivesRepository.findAll).toHaveBeenCalledWith({ - where: { status: 'AVAILABLE', currentYardId: originYardId }, - order: { code: 'ASC' }, - }); - expect(result).toHaveLength(1); - expect(result[0].code).toBe('EXP'); + expect(result).toHaveLength(2); + expect(result.find((l) => l.code === 'EXP')?.atOriginYard).toBe(true); + expect(result.find((l) => l.code === 'FAR')?.atOriginYard).toBe(false); }); - it('returns all locomotives returned by the repository for domestic routes', async () => { + it('rejects intercity (domestic) routes — intercity scheduling is not offered', async () => { const routeId = 'route-domestic'; const originYardId = 'yard-addis'; const routeRepo = { @@ -946,6 +990,7 @@ describe('TrainSchedulingService', () => { id: routeId, name: 'Addis → Dire Dawa', isActive: true, + status: 'AVAILABLE', originYardId, originYard: { country: 'Ethiopia' }, destinationYard: { country: 'Ethiopia' }, @@ -955,14 +1000,10 @@ describe('TrainSchedulingService', () => { if ((entity as { name?: string })?.name === 'Route') return routeRepo; return { findOne: jest.fn(), update: jest.fn() }; }); - locomotivesRepository.findAll.mockResolvedValue([ - { id: 'l1', code: 'IMP', status: 'AVAILABLE', currentYardId: originYardId }, - { id: 'l2', code: 'EXP', status: 'AVAILABLE', currentYardId: originYardId }, - ]); - const result = await service.getAvailableLocomotivesForRoute(routeId); - - expect(result).toHaveLength(2); + await expect( + service.getAvailableLocomotivesForRoute(routeId), + ).rejects.toBeInstanceOf(BadRequestException); }); }); @@ -1081,4 +1122,172 @@ describe('TrainSchedulingService', () => { expect(html).not.toContain('EMPTY'); }); }); + + describe('moveWagonLoad — staff rearrange', () => { + const containerType = { + code: 'NX70', + supportedLoadTypes: ['CONTAINER'], + supportsContainer: true, + }; + let slotA: Record; + let slotB: Record; + let allocsByWagon: Record>>; + let allocRepo: { find: jest.Mock; update: jest.Mock }; + let slotRepo: { update: jest.Mock }; + let wagonRepo: { findOne: jest.Mock }; + + const makeSchedule = (over: Record = {}) => ({ + id: 'sched-1', + status: 'SCHEDULED', + trainSetId: 'ts-1', + trainSet: { trainId: 'train-1', wagons: [slotA, slotB] }, + ...over, + }); + + beforeEach(() => { + slotA = { + id: 'wA', + sequenceNo: 1, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + wagonType: containerType, + }; + slotB = { + id: 'wB', + sequenceNo: 2, + capacityTons: 61, + lengthMeters: 14, + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + wagonType: containerType, + }; + allocsByWagon = { + // 20ft pair (two allocations sharing wagon A) — must travel together. + wA: [ + { id: 'alloc-a1', trainSetWagonId: 'wA', bookingId: 'b1', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + { id: 'alloc-a2', trainSetWagonId: 'wA', bookingId: 'b2', allocatedWeightTons: 20, loadType: 'CONTAINER' }, + ], + // one 40ft on wagon B. + wB: [ + { id: 'alloc-b1', trainSetWagonId: 'wB', bookingId: 'b3', allocatedWeightTons: 25, loadType: 'CONTAINER' }, + ], + }; + + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(makeSchedule()); + allocRepo = { + find: jest.fn().mockImplementation(({ where }: { where: { trainSetWagonId: string } }) => + Promise.resolve(allocsByWagon[where.trainSetWagonId] ?? []), + ), + update: jest.fn().mockResolvedValue(undefined), + }; + slotRepo = { update: jest.fn().mockResolvedValue(undefined) }; + wagonRepo = { findOne: jest.fn().mockResolvedValue(null) }; + dataSource.getRepository.mockImplementation((entity: unknown) => { + if (entity === WagonBookingAllocation) return allocRepo; + if (entity === TrainSetWagon) return slotRepo; + if (entity === Wagon) return wagonRepo; + return { find: jest.fn().mockResolvedValue([]) }; + }); + dataSource.transaction.mockImplementation( + async (fn: (m: unknown) => Promise) => + fn({ getRepository: dataSource.getRepository }), + ); + jest + .spyOn( + service as never as { getTrainScheduleById: (id: string) => Promise }, + 'getTrainScheduleById' as never, + ) + .mockResolvedValue({ id: 'sched-1' } as never); + }); + + it('rejects moves on a dispatched train', async () => { + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + makeSchedule({ status: 'DISPATCHED' }), + ); + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('404s when the target is neither a slot nor a consist wagon of this train', async () => { + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'nope' }), + ).rejects.toThrow(/not part of this schedule/); + }); + + it('swaps two loaded wagons: every allocation crosses over, load fields swap', async () => { + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }); + + // The 20ft pair moved together onto wagon B… + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a1', { trainSetWagonId: 'wB' }); + expect(allocRepo.update).toHaveBeenCalledWith('alloc-a2', { trainSetWagonId: 'wB' }); + // …and the 40ft came back to wagon A. + expect(allocRepo.update).toHaveBeenCalledWith('alloc-b1', { trainSetWagonId: 'wA' }); + // Load-coupled slot fields follow their loads. + expect(slotRepo.update).toHaveBeenCalledWith('wB', { + assignedWeightTons: 40, + status: 'RESERVED', + boardYardId: 'yard-1', + alightYardId: null, + }); + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + assignedWeightTons: 25, + status: 'RESERVED', + boardYardId: null, + alightYardId: null, + }); + }); + + it('repins the slot onto an empty consist-only wagon (the 404 case)', async () => { + wagonRepo.findOne.mockResolvedValue({ + id: 'phys-9', + wagonTypeId: 'wt-1', + wagonNumber: 'WGN-9', + wagonType: { ...containerType, capacityTons: 70, lengthMeters: 14 }, + }); + + await service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'phys-9' }); + + expect(wagonRepo.findOne).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'phys-9', trainId: 'train-1' } }), + ); + // Repin: wagon identity moves onto the slot; allocations stay put. + expect(slotRepo.update).toHaveBeenCalledWith('wA', { + physicalWagonId: 'phys-9', + wagonTypeId: 'wt-1', + capacityTons: 70, + lengthMeters: 14, + }); + expect(allocRepo.update).not.toHaveBeenCalled(); + }); + + it('rejects a bulk load onto a wagon whose type only supports containers', async () => { + allocsByWagon.wA = [ + { id: 'alloc-bulk', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 50, loadType: 'BULK' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/cannot carry a bulk load/); + }); + + it('rejects when the incoming load exceeds the receiving wagon payload', async () => { + allocsByWagon.wA = [ + { id: 'alloc-heavy', trainSetWagonId: 'wA', bookingId: 'b9', allocatedWeightTons: 70, loadType: 'CONTAINER' }, + ]; + allocsByWagon.wB = []; + + await expect( + service.moveWagonLoad('sched-1', 'wA', { targetWagonId: 'wB' }), + ).rejects.toThrow(/over its/); + }); + }); }); 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 93d8bba56..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 @@ -77,6 +77,7 @@ import { TrainScheduleFreightType, } from './dto/list-train-schedules-query.dto'; import { PinWagonsDto } from './dto/pin-wagons.dto'; +import { MoveWagonLoadDto } from './dto/move-wagon-load.dto'; import { UpdateContainerItemDto } from './dto/update-container-item.dto'; import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; @@ -121,17 +122,21 @@ import { roundTons, sumWagonsRequired, type TrainLimitConfig, + maxEdgeConsistUsage, validateContainerPlacements, - validateMixedTrainLimits, + validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, } from './wagon-plan.util'; 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 { @@ -1538,8 +1543,21 @@ export class TrainSchedulingService { } } + // The rebuild below deletes EVERY schedule↔booking link row and recreates + // only what makes the new plan. Ride-along (intercity) bookings are linked + // OUTSIDE this flow — by acceptIntercity/allocate — and never appear in the + // workspace's picked ids, so planning from dto.bookingIds alone silently + // orphans them: PAID + SCHEDULED with no link and no wagon, invisible in + // every list. Every (re)assignment therefore re-plans the WHOLE train: + // the requested ids plus everything currently linked. + const linkedRows = + await this.trainScheduleBookingsRepository.findByScheduleId(scheduleId); + const allBookingIds = [ + ...new Set([...dto.bookingIds, ...linkedRows.map((row) => row.bookingId)]), + ]; + const previewDto = { - bookingIds: dto.bookingIds, + bookingIds: allBookingIds, scheduleDate: schedule.scheduledDepartureDate.toISOString(), originStationId: schedule.originStationId, destinationStationId: schedule.destinationStationId, @@ -1562,8 +1580,13 @@ export class TrainSchedulingService { // preview the wagon plan first, then lay containers into the plan's slots. // Without this the placement validator rejects container bookings outright // ("Container placements are required for container bookings"). + // Callers hand-pick placements only for the bookings they know about; the + // union above may have folded in linked ride-alongs those placements never + // covered. Auto-fill whatever units are missing (all of them when no + // placements were sent at all) so the placement validator doesn't reject + // container bookings the caller couldn't have placed. let containerPlacements = dto.containerPlacements; - if (!containerPlacements?.length) { + { const preview = await this.validateBookingsForScheduling( previewDto, freightType ?? null, @@ -1578,18 +1601,28 @@ export class TrainSchedulingService { ); if (containerBookings.length) { const units = expandBookingContainerUnits(containerBookings); - const slots = getContainerSlotSequenceNos(preview.wagonPlan); - const generated = autoFillPlacements(units, slots); - const missing = findMissingContainerNumberIssues(units, generated); - if (missing.length) { - throw new BadRequestException({ - message: `Booking validation failed: ${missing - .map((m) => m.issue) - .join('; ')}`, - violations: missing.map((m) => m.issue), - }); + const providedKeys = new Set( + (containerPlacements ?? []).map( + (p) => `${p.bookingContainerId}:${p.unitIndex}`, + ), + ); + const unplacedUnits = units.filter( + (u) => !providedKeys.has(`${u.bookingContainerId}:${u.unitIndex}`), + ); + if (unplacedUnits.length) { + const slots = getContainerSlotSequenceNos(preview.wagonPlan); + const generated = autoFillPlacements(unplacedUnits, slots); + const missing = findMissingContainerNumberIssues(unplacedUnits, generated); + if (missing.length) { + throw new BadRequestException({ + message: `Booking validation failed: ${missing + .map((m) => m.issue) + .join('; ')}`, + violations: missing.map((m) => m.issue), + }); + } + containerPlacements = [...(containerPlacements ?? []), ...generated]; } - containerPlacements = generated; } } @@ -1632,8 +1665,10 @@ export class TrainSchedulingService { // NW5 free) — the caller saw HTTP 200 and a green toast over a no-op. // A stock shortage is a physical impossibility, so forceAssign cannot // override it either. + // Linked ride-alongs count as requested too: silently dropping one here is + // exactly the delete-and-recreate orphan this method must never produce. const plannedIds = new Set(validation.bookings.map((b) => b.id)); - const droppedRequested = dto.bookingIds.filter((id) => !plannedIds.has(id)); + const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id)); if (droppedRequested.length) { const reasonById = new Map( validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]), @@ -1675,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`, ); } @@ -2780,13 +2826,12 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); - const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; const containerItems = allocation.containerItems ?? []; const firstContainer = containerItems[0]; @@ -2795,8 +2840,6 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} - ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -2878,8 +2921,6 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity - Customer Name - Customer ID Cargo Type Container No Chassis No @@ -2887,7 +2928,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} @@ -3852,25 +3893,24 @@ export class TrainSchedulingService { ); } + // Corridor-aware: a booking belongs on this train when its origin and + // destination lie on the schedule's stop list in order — sub-corridor + // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. The + // stop list is also what makes the wagon plan leg-aware below. + let stops = [dto.originStationId, dto.destinationStationId]; + if (targetScheduleId) { + const target = await this.trainSchedulesRepository.findById(targetScheduleId); + if (target) stops = await this.stopYardsForSchedule(target); + } if ( - await (async () => { - // Corridor-aware: a booking belongs on this train when its origin and - // destination lie on the schedule's stop list in order — sub-corridor - // bookings (Dire→Djibouti on an Addis→…→Djibouti train) are valid. - let stops = [dto.originStationId, dto.destinationStationId]; - if (targetScheduleId) { - const target = await this.trainSchedulesRepository.findById(targetScheduleId); - if (target) stops = await this.stopYardsForSchedule(target); + bookings.some((b) => { + if (targetScheduleId && b.trainScheduleId === targetScheduleId) { + return false; } - return bookings.some((b) => { - if (targetScheduleId && b.trainScheduleId === targetScheduleId) { - return false; - } - const fromIdx = stops.indexOf(b.originYardId); - const toIdx = stops.indexOf(b.destinationYardId); - return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; - }); - })() + const fromIdx = stops.indexOf(b.originYardId); + const toIdx = stops.indexOf(b.destinationYardId); + return fromIdx < 0 || toIdx < 0 || fromIdx >= toIdx; + }) ) { violations.push('Selected bookings must lie on the schedule route (origin before destination)'); } @@ -3956,7 +3996,22 @@ export class TrainSchedulingService { stock = { mode: 'YARD', remainingByTypeId, codesByTypeId }; } - const planned = planWagonsWithStock({ bookings, allowed, stock }); + // Leg-aware stock: each booking consumes wagons only on the edges it rides, + // so a ride-along on an empty leg never competes with cargo on a full one. + const legByBookingId = new Map( + bookings.flatMap((b) => { + const from = stops.indexOf(b.originYardId); + const to = stops.indexOf(b.destinationYardId); + return from >= 0 && to > from ? [[b.id, { from, to }] as const] : []; + }), + ); + const planned = planWagonsWithStock({ + bookings, + allowed, + stock, + legs: legByBookingId, + edgeCount: Math.max(1, stops.length - 1), + }); violations.push(...planned.configIssues); const fittingBookings = planned.fitting; const deferredBookings: DeferredBookingRow[] = planned.deferred; @@ -4018,10 +4073,11 @@ export class TrainSchedulingService { ).values(), ]; pushLimit( - validateMixedTrainLimits( + validateMixedTrainLimitsPerEdge( wagonPlan, plannedWagonTypes.length ? plannedWagonTypes : [{ lengthMeters: 14 }], trainLimits, + stops, ), ); if (requireContainerPlacements && resolvedMode !== 'BULK') { @@ -4040,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), ); @@ -4050,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) { @@ -4078,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', @@ -4099,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']); @@ -4160,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<{ @@ -6471,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. @@ -6810,12 +6861,45 @@ export class TrainSchedulingService { status: sb.booking?.status ?? null, schedulingStatus: sb.booking?.schedulingStatus ?? null, freightType: sb.booking?.freightType ?? null, + // Which leg of the corridor this booking rides — the workspace can't + // tell a ride-along (intercity) or sub-corridor booking from through + // cargo without it. + tradeDirection: sb.booking?.tradeDirection ?? null, + originYardId: sb.booking?.originYardId ?? null, + destinationYardId: sb.booking?.destinationYardId ?? null, + origin: + sb.booking?.originYard?.label ?? sb.booking?.originYard?.code ?? null, + destination: + sb.booking?.destinationYard?.label ?? + sb.booking?.destinationYard?.code ?? + null, + wagonsRequired: + sb.booking?.wagonsRequired != null + ? Number(sb.booking.wagonsRequired) + : null, + loadedAt: sb.booking?.loadedAt?.toISOString() ?? null, + arrivedAt: sb.booking?.arrivedAt?.toISOString() ?? null, // Loaded/unloaded is tracked on the schedule↔booking link, not the // booking itself — staff flip it per booking in the workspace before // dispatch. Defaults UNLOADED for links written before the column. loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded, wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId), })) ?? [], + // 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. @@ -6824,6 +6908,42 @@ export class TrainSchedulingService { }; } + /** Ordered corridor stops with labels, from the loaded route graph (no extra query). */ + private mapScheduleStops( + schedule: TrainSchedule, + ): Array<{ yardId: string; label: string }> { + const milestones = [...(schedule.route?.milestones ?? [])].sort( + (a, b) => a.sequenceNo - b.sequenceNo, + ); + const raw = milestones.length >= 2 + ? milestones.map((m) => ({ + yardId: m.yardId, + label: m.yard?.label ?? m.yard?.code ?? m.yardId, + })) + : [ + { + yardId: schedule.originStationId, + label: + schedule.originStation?.label ?? + schedule.originStation?.code ?? + schedule.originStationId, + }, + { + yardId: schedule.destinationStationId, + label: + schedule.destinationStation?.label ?? + schedule.destinationStation?.code ?? + schedule.destinationStationId, + }, + ]; + const seen = new Set(); + return raw.filter((stop) => { + if (!stop.yardId || seen.has(stop.yardId)) return false; + seen.add(stop.yardId); + return true; + }); + } + private isHoldActive(booking: Booking): boolean { if (!booking.holdExpiresAt) return false; return booking.holdExpiresAt.getTime() > Date.now(); @@ -6875,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, @@ -7001,7 +7124,7 @@ export class TrainSchedulingService { }; const limits = await this.resolveTrainLimitConfig( undefined, - schedule.trainSet.locomotive, + trainSetLocomotiveLimits(schedule.trainSet), ); let validation: Awaited>; @@ -7232,6 +7355,171 @@ export class TrainSchedulingService { return { id: itemId, containerNumber: dto.containerNumber ?? null }; } + /** + * Staff rearrange: relocate a wagon's ENTIRE load (all its allocations — + * a 40ft, a 20ft pair, or a bulk load) to another wagon of the same train. + * Whole-load moves keep every packing rule intact by construction (a valid + * load stays valid on any wagon whose type supports it), which is what lets + * a 20ft pair travel together and swap places with a 40ft, and lets bulk + * swap with containers. + * + * Three shapes, picked from the target: + * - target is an empty consist-only wagon (coupled on the built train, no + * slot row): REPIN — the source slot simply points at that physical wagon + * (type/capacity/length follow), and the wagon it left shows as empty. + * - target is an empty slot: allocations repoint to it and the load-coupled + * slot fields (assigned weight, status, board/alight leg) move across. + * - target is a loaded slot: the two loads swap wagons the same way. + * + * Validated per direction: the receiving wagon's type must support the + * incoming load type, and the incoming cargo must fit its rated payload. + */ + async moveWagonLoad( + scheduleId: string, + sourceWagonId: string, + dto: MoveWagonLoadDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (['DISPATCHED', 'ARRIVED'].includes(schedule.status)) { + throw new BadRequestException('Cannot rearrange loads on a dispatched train'); + } + if (sourceWagonId === dto.targetWagonId) { + return this.getTrainScheduleById(scheduleId); + } + + const slots = schedule.trainSet?.wagons ?? []; + const source = slots.find((w) => w.id === sourceWagonId); + if (!source) { + throw new NotFoundException('Source wagon is not part of this schedule'); + } + + const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + const loadAllocations = (trainSetWagonId: string) => + allocRepo.find({ where: { trainSetWagonId } }); + const sourceAllocs = await loadAllocations(source.id); + if (!sourceAllocs.length) { + throw new BadRequestException('Source wagon has no load to move'); + } + + // Target: a slot of this train set, or an empty consist-only wagon of the + // built train (physical wagon with no slot row yet). + const targetSlot = slots.find((w) => w.id === dto.targetWagonId) ?? null; + const consistWagon = targetSlot + ? null + : schedule.trainSet?.trainId + ? await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, + relations: { wagonType: true }, + }) + : null; + if (!targetSlot && !consistWagon) { + throw new NotFoundException('Target wagon is not part of this schedule'); + } + const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; + + const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ + ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), + ]; + const cargoOf = (allocs: WagonBookingAllocation[]) => + allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0); + const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) => + slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon'); + const checkReceives = ( + allocs: WagonBookingAllocation[], + label: string, + wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + capacityTons: number, + ) => { + const incoming = loadTypesOf(allocs); + // Unknown type or no declared support list → staff decides; don't block. + if (wagonType) { + const supported = (wagonType.supportedLoadTypes ?? []).map((t) => t.toUpperCase()); + for (const loadType of incoming) { + const ok = + supported.includes(loadType) || + (loadType === 'CONTAINER' && wagonType.supportsContainer) || + supported.length === 0; + if (!ok) { + throw new BadRequestException( + `Wagon ${label} (${wagonType.code ?? 'unknown type'}) cannot carry a ${loadType.toLowerCase()} load`, + ); + } + } + } + const cargo = cargoOf(allocs); + if (capacityTons > 0 && cargo > capacityTons + 0.001) { + throw new BadRequestException( + `Wagon ${label} would carry ${roundTons(cargo)}T — over its ${roundTons(capacityTons)}T payload`, + ); + } + }; + + // What the target must be able to receive… + checkReceives( + sourceAllocs, + wagonLabel(targetSlot, consistWagon), + targetSlot ? targetSlot.wagonType : consistWagon?.wagonType, + Number(targetSlot ? targetSlot.capacityTons : (consistWagon?.wagonType?.capacityTons ?? 0)), + ); + // …and, on a swap, what comes back to the source. + if (targetAllocs.length) { + checkReceives( + targetAllocs, + `#${source.sequenceNo}`, + source.wagonType, + Number(source.capacityTons), + ); + } + + await this.dataSource.transaction(async (manager) => { + const slotRepo = manager.getRepository(TrainSetWagon); + const allocs = manager.getRepository(WagonBookingAllocation); + + // Empty consist wagon: repin the loaded slot onto that physical wagon. + // Allocations and load fields stay put; only the wagon identity changes. + if (consistWagon) { + await slotRepo.update(source.id, { + physicalWagonId: consistWagon.id, + wagonTypeId: consistWagon.wagonTypeId, + capacityTons: roundTons(Number(consistWagon.wagonType?.capacityTons ?? source.capacityTons)), + lengthMeters: roundTons(Number(consistWagon.wagonType?.lengthMeters ?? source.lengthMeters)), + }); + return; + } + + const target = targetSlot as TrainSetWagon; + // Load-coupled slot fields travel with the load; wagon identity stays. + const loadFieldsOf = (slot: TrainSetWagon) => ({ + assignedWeightTons: slot.assignedWeightTons, + status: slot.status, + boardYardId: slot.boardYardId ?? null, + alightYardId: slot.alightYardId ?? null, + }); + const emptyLoadFields = { + assignedWeightTons: 0, + status: 'PLANNED', + boardYardId: null, + alightYardId: null, + }; + const sourceLoadFields = loadFieldsOf(source); + const targetLoadFields = targetAllocs.length ? loadFieldsOf(target) : emptyLoadFields; + + for (const alloc of sourceAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: target.id }); + } + for (const alloc of targetAllocs) { + await allocs.update(alloc.id, { trainSetWagonId: source.id }); + } + await slotRepo.update(target.id, sourceLoadFields); + await slotRepo.update(source.id, targetLoadFields); + }); + + return this.getTrainScheduleById(scheduleId); + } + async getUnassignedBookings(scheduleId: string): Promise { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { @@ -7380,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-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 5870d3785..778dc70dd 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -187,3 +187,115 @@ describe('applyWagonOrderReversal', () => { expect(plan.map((s) => s.wagonTypeId)).toEqual(['wt-a', 'wt-b', 'wt-c']); }); }); + +describe('planWagonsWithStock — leg-aware stock (intercity ride-along)', () => { + const allowed = { + byContainerTypeId: new Map([['ct-1', [nw6]]]), + byCargoTypeId: new Map(), + }; + // Corridor Gelan(0) → Adama(1) → Doraleh(2): edges 0 and 1. + const legs = (entries: Array<[string, { from: number; to: number }]>) => + new Map(entries); + + it('lets an intercity booking ride the empty leg of a train that is full on the other leg', () => { + // 1 wagon in stock. Export rides edge 1 only; intercity rides edge 0 only. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.deferred).toHaveLength(0); + expect(result.fitting.map((b) => b.id).sort()).toEqual([ + 'EXPORT-1', + 'INTERCITY-1', + ]); + // Two slots planned, but both drawn from the single physical wagon. + expect(result.plan).toHaveLength(2); + }); + + it('still defers when the legs overlap and stock is exhausted', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + // Both ride edge 0 — they compete for the one wagon. + ['EXPORT-1', { from: 0, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.fitting.map((b) => b.id)).toEqual(['EXPORT-1']); + expect(result.deferred).toHaveLength(1); + expect(result.deferred[0]!.reference).toBe('INTERCITY-1'); + expect(result.deferred[0]!.reason).toContain('Train has no free NW6 wagon left'); + }); + + it('never packs bookings with different legs into the same wagon slot', () => { + // Two 20ft units with room to share one wagon by TEU — but disjoint legs + // must open separate slots (each with its own leg), not one mixed slot. + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 2]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + legs: legs([ + ['EXPORT-1', { from: 1, to: 2 }], + ['INTERCITY-1', { from: 0, to: 1 }], + ]), + edgeCount: 2, + }); + + expect(result.plan).toHaveLength(2); + const bookingsPerSlot = result.plan.map((s) => + [...new Set(s.allocations.map((a) => a.bookingId))].sort(), + ); + expect(bookingsPerSlot).toEqual([['EXPORT-1'], ['INTERCITY-1']]); + }); + + it('behaves exactly like the whole-route planner when no legs are given', () => { + const result = planWagonsWithStock({ + bookings: [ + containerBooking('EXPORT-1', 1, 1), + containerBooking('INTERCITY-1', 1, 1), + ], + allowed, + stock: { + mode: 'TRAIN', + remainingByTypeId: new Map([[nw6.id, 1]]), + codesByTypeId: new Map([[nw6.id, nw6.code]]), + }, + }); + + // One wagon, two 20ft bookings: they TEU-share the single slot (legacy). + expect(result.deferred).toHaveLength(0); + expect(result.plan).toHaveLength(1); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index 6a3c1c49f..699d7a432 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -58,8 +58,18 @@ type OpenSlot = { /** Kind purity: a bulk wagon carries ONE cargo type at a time. */ cargoTypeId: string | null; freeCapacityTons: number; + /** + * Corridor leg this slot rides (`"from-to"` stop indexes). Bookings only + * share a slot when their legs are identical — mixing corridors in one slot + * would degrade it to a whole-route slot (see stampSlotLegs) and silently + * re-occupy edges the cargo never rides. + */ + legKey: string; }; +/** Stop-index range a booking occupies: edges `from..to-1` of the corridor. */ +export type BookingLeg = { from: number; to: number }; + type PlacementProblem = { kind: 'config' | 'stock'; message: string; @@ -87,7 +97,7 @@ const slotFromWagonType = (wagonType: WagonType, kind: SlotLoadType): WagonPlanS const shortageFor = ( booking: Booking, candidates: WagonType[], - remaining: Map, + availableOf: (wagonTypeId: string) => number, ): BookingWagonShortage => { const wagonsNeeded = booking.freightType === 'BULK' @@ -100,7 +110,7 @@ const shortageFor = ( ) : Math.max(1, containerWagonsForLines(booking.bookingContainers ?? [])); const wagonsAvailable = candidates.reduce( - (sum, wt) => sum + (remaining.get(wt.id) ?? 0), + (sum, wt) => sum + availableOf(wt.id), 0, ); return { @@ -140,14 +150,53 @@ export function planWagonsWithStock(params: { bookings: Booking[]; allowed: AllowedWagonTypeMap; stock: WagonStock; + /** + * Leg-aware stock: booking id → the stop-index range it rides. When given + * (with `edgeCount`), a wagon type's stock is consumed PER CORRIDOR EDGE, so + * the same physical wagon can serve an intercity booking on Gelan→Adama and + * an export booking on Adama→Doraleh — disjoint legs never compete for + * stock. Omitted → one edge, byte-identical to the old whole-route behavior. + */ + legs?: Map; + edgeCount?: number; }): FlexPlanResult { - const { bookings, allowed, stock } = params; - const remaining = new Map(stock.remainingByTypeId); + const { bookings, allowed, stock, legs } = params; + const edgeCount = Math.max(1, params.edgeCount ?? 1); const openSlots: OpenSlot[] = []; const fitting: Booking[] = []; const deferred: DeferredBookingRow[] = []; const configIssues = new Set(); + const legFor = (booking: Booking): BookingLeg => { + const leg = legs?.get(booking.id); + if (!leg || leg.from < 0 || leg.to > edgeCount || leg.from >= leg.to) { + return { from: 0, to: edgeCount }; + } + return leg; + }; + const legKeyOf = (leg: BookingLeg) => `${leg.from}-${leg.to}`; + + // Wagons of a type in use per corridor edge. A type is available for a leg + // when its busiest edge WITHIN that leg still has stock spare — the max over + // edges is the number of physical wagons the type needs simultaneously. + const usedPerEdge = new Map(); + const usedRow = (wagonTypeId: string): number[] => { + let row = usedPerEdge.get(wagonTypeId); + if (!row) { + row = new Array(edgeCount).fill(0); + usedPerEdge.set(wagonTypeId, row); + } + return row; + }; + const availableFor = (wagonTypeId: string, leg: BookingLeg): number => { + const total = stock.remainingByTypeId.get(wagonTypeId) ?? 0; + const row = usedPerEdge.get(wagonTypeId); + if (!row) return total; + let busiest = 0; + for (let e = leg.from; e < leg.to; e += 1) busiest = Math.max(busiest, row[e] ?? 0); + return total - busiest; + }; + const noStockMessage = (candidates: WagonType[]): string => { const codes = candidates.map((wt) => wt.code).join('/'); return stock.mode === 'TRAIN' @@ -155,13 +204,14 @@ export function planWagonsWithStock(params: { : `No available ${codes} wagon at the yard`; }; - /** Open a new wagon of one of the candidate types, consuming stock. */ + /** Open a new wagon of one of the candidate types, consuming stock on the leg's edges. */ const openSlot = ( candidates: WagonType[], kind: SlotLoadType, cargoTypeId: string | null, + leg: BookingLeg, ): OpenSlot | PlacementProblem => { - const inStock = candidates.filter((wt) => (remaining.get(wt.id) ?? 0) > 0); + const inStock = candidates.filter((wt) => availableFor(wt.id, leg) > 0); if (!inStock.length) { return { kind: 'stock', message: noStockMessage(candidates), candidates }; } @@ -170,22 +220,26 @@ export function planWagonsWithStock(params: { const chosen = [...inStock].sort((a, b) => kind === 'BULK' ? Number(b.capacityTons) - Number(a.capacityTons) || - (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0) - : (remaining.get(b.id) ?? 0) - (remaining.get(a.id) ?? 0), + availableFor(b.id, leg) - availableFor(a.id, leg) + : availableFor(b.id, leg) - availableFor(a.id, leg), )[0]; - remaining.set(chosen.id, (remaining.get(chosen.id) ?? 0) - 1); + const row = usedRow(chosen.id); + for (let e = leg.from; e < leg.to; e += 1) row[e] = (row[e] ?? 0) + 1; const open: OpenSlot = { slot: slotFromWagonType(chosen, kind), teuUsed: 0, kind, cargoTypeId, freeCapacityTons: Number(chosen.capacityTons), + legKey: legKeyOf(leg), }; openSlots.push(open); return open; }; const tryPlaceBooking = (booking: Booking): PlacementProblem | null => { + const leg = legFor(booking); + const legKey = legKeyOf(leg); if (booking.freightType === 'CONTAINER') { const units = expandBookingContainerUnits([booking]); if (!units.length) { @@ -209,11 +263,12 @@ export function planWagonsWithStock(params: { let target = openSlots.find( (open) => open.kind === 'CONTAINER' && + open.legKey === legKey && allowedIds.has(open.slot.wagonTypeId) && open.teuUsed + teu <= MAX_TEU_SLOTS_PER_WAGON, ); if (!target) { - const openedSlot = openSlot(candidates, 'CONTAINER', null); + const openedSlot = openSlot(candidates, 'CONTAINER', null, leg); if ('message' in openedSlot) return openedSlot; target = openedSlot; } @@ -246,6 +301,7 @@ export function planWagonsWithStock(params: { for (const open of openSlots) { if (remainingWeight <= 0) break; if (open.kind !== 'BULK') continue; + if (open.legKey !== legKey) continue; if (open.cargoTypeId !== cargoTypeId) continue; if (!allowedIds.has(open.slot.wagonTypeId)) continue; if (open.freeCapacityTons <= 0) continue; @@ -263,7 +319,7 @@ export function planWagonsWithStock(params: { } while (remainingWeight > 0 || !placedAnywhere) { - const openedSlot = openSlot(candidates, 'BULK', cargoTypeId); + const openedSlot = openSlot(candidates, 'BULK', cargoTypeId, leg); if ('message' in openedSlot) return openedSlot; const take = roundTons(Math.min(openedSlot.freeCapacityTons, remainingWeight)); addAllocation( @@ -282,7 +338,9 @@ export function planWagonsWithStock(params: { for (const booking of sortBookingsForScheduling(bookings)) { // Snapshot so a booking that doesn't fully fit leaves no half-placed wagons. - const remainingSnapshot = new Map(remaining); + const usedSnapshot = new Map( + [...usedPerEdge.entries()].map(([typeId, row]) => [typeId, [...row]]), + ); const slotCountSnapshot = openSlots.length; const slotStateSnapshot = openSlots.map((open) => ({ teuUsed: open.teuUsed, @@ -299,8 +357,8 @@ export function planWagonsWithStock(params: { } // Roll back this booking's partial placements. - remaining.clear(); - for (const [key, value] of remainingSnapshot) remaining.set(key, value); + usedPerEdge.clear(); + for (const [key, value] of usedSnapshot) usedPerEdge.set(key, value); openSlots.length = slotCountSnapshot; openSlots.forEach((open, index) => { const snap = slotStateSnapshot[index]; @@ -315,11 +373,14 @@ export function planWagonsWithStock(params: { }); if (problem.kind === 'config') configIssues.add(problem.message); - // remaining is rolled back here, so the shortage counts the stock this + // Usage is rolled back here, so the shortage counts the stock this // booking actually saw — not what its own partial placement consumed. + const bookingLeg = legFor(booking); const shortage = problem.kind === 'stock' && problem.candidates?.length - ? shortageFor(booking, problem.candidates, remaining) + ? shortageFor(booking, problem.candidates, (wagonTypeId) => + Math.max(0, availableFor(wagonTypeId, bookingLeg)), + ) : null; deferred.push({ id: booking.id, 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 bb5ce890c..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 @@ -525,6 +525,80 @@ export function validateMixedTrainLimits( ); } +/** + * Leg-aware limit check: with a real stop list, a slot only counts on the + * edges it actually rides (boardYardId→alightYardId; null = the schedule's + * own endpoint). Each edge is validated as its own consist, so an intercity + * wagon on Gelan→Adama never counts against a train that is full only on + * Adama→Doraleh. Two stops (or fewer) degrade to the whole-train check. + */ +export function validateMixedTrainLimitsPerEdge( + wagonPlan: WagonPlanSlot[], + wagonTypes: Array>, + limits: TrainLimitConfig | undefined, + stops: string[], +): string[] { + if (stops.length <= 2) return validateMixedTrainLimits(wagonPlan, wagonTypes, limits); + const spans = slotSpans(wagonPlan, stops); + const violations = new Set(); + for (let edge = 0; edge < stops.length - 1; edge += 1) { + const active = wagonPlan.filter( + (_, i) => spans[i].from <= edge && edge < spans[i].to, + ); + if (!active.length) continue; + for (const violation of validateMixedTrainLimits(active, wagonTypes, limits)) { + violations.add(violation); + } + } + 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/vehicles/vehicles.driver-guard.spec.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts new file mode 100644 index 000000000..cb810abaf --- /dev/null +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.driver-guard.spec.ts @@ -0,0 +1,44 @@ +import { ConflictException } from '@nestjs/common'; + +import { VehiclesService } from './vehicles.service'; + +// One driver ⇒ one truck: create/update must refuse a driver already assigned +// to another (non-deleted) vehicle until they are detached. +describe('VehiclesService driver assignment guard', () => { + const otherTruck = { id: 'v2', plateNumber: '3-11111', assignedDriverId: 'd1' }; + + const makeService = (findOne: jest.Mock) => + new VehiclesService( + { findOne, create: jest.fn((x) => x), save: jest.fn(async (x) => x) } as any, + { record: jest.fn() } as any, + ); + + it('rejects create when the driver is on another truck', async () => { + // First findOne = plate uniqueness (null), second = driver holder. + const findOne = jest.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(otherTruck); + const svc = makeService(findOne); + await expect( + svc.create({ plateNumber: '3-22222', vehicleType: 'TRUCK', assignedDriverId: 'd1' } as any), + ).rejects.toThrow(ConflictException); + }); + + it('rejects update when reassigning a driver still attached elsewhere', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: null }) // findById + .mockResolvedValueOnce(otherTruck); // driver holder + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).rejects.toThrow( + ConflictException, + ); + }); + + it('allows update that keeps the same driver on the same truck', async () => { + const findOne = jest + .fn() + .mockResolvedValueOnce({ id: 'v1', plateNumber: '3-22222', assignedDriverId: 'd1' }); + const svc = makeService(findOne); + await expect(svc.update('v1', { assignedDriverId: 'd1' } as any)).resolves.toBeDefined(); + expect(findOne).toHaveBeenCalledTimes(1); // guard skipped — no holder lookup + }); +}); diff --git a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts index 25260e86f..98d38cbce 100644 --- a/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts +++ b/apps/edr-freight-api/src/modules/vehicles/vehicles.service.ts @@ -20,6 +20,25 @@ export class VehiclesService { private readonly history: FleetHistoryService, ) {} + /** + * A driver holds one truck at a time — reassignment requires detaching them + * from their current truck first. + * ponytail: app-level guard only (race window); add a partial unique index on + * assigned_driver_id if concurrent fleet edits ever become real. + */ + private async assertDriverUnassigned(driverId: string, exceptVehicleId?: string): Promise { + const holder = await this.vehicleRepo.findOne({ + where: exceptVehicleId + ? { assignedDriverId: driverId, id: Not(exceptVehicleId) } + : { assignedDriverId: driverId }, + }); + if (holder) { + throw new ConflictException( + `This driver is already assigned to truck ${holder.plateNumber ?? holder.code ?? holder.id} — detach the driver from that truck first`, + ); + } + } + async create(dto: CreateVehicleDto): Promise { const existing = await this.vehicleRepo.findOne({ where: { plateNumber: dto.plateNumber }, @@ -31,6 +50,10 @@ export class VehiclesService { ); } + if (dto.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId); + } + const registrationNumber = `REG-${dto.vehicleType}-${Date.now()}`; const vehicle = this.vehicleRepo.create({ ...dto, @@ -121,6 +144,10 @@ export class VehiclesService { } } + if (dto.assignedDriverId && dto.assignedDriverId !== vehicle.assignedDriverId) { + await this.assertDriverUnassigned(dto.assignedDriverId, id); + } + const prev = { assignedDriverId: vehicle.assignedDriverId, assignedDriverName: vehicle.assignedDriverName, 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.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 2373cb6d8..0174ef3e9 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -486,6 +486,21 @@ export class WarehouseInventoryController { return this.handoverService.list(bookingId); } + @Post('handovers/:handoverId/sign') + @ApiOperation({ summary: 'Customer signs one handover (EDR last-mile: one signature per truck)' }) + signHandover( + @Param('handoverId', ParseUUIDPipe) handoverId: string, + @Body() dto: ApproveDeliveryDto, + @Request() req: { user?: { id?: string; sub?: string } }, + @CurrentUser() user: TCurrentUser, + ) { + return this.inventoryService.signHandover( + handoverId, + user?.id ?? req.user?.id ?? req.user?.sub, + dto.signerName, + ); + } + @Post('bookings/:bookingId/request-handover-signature') @ApiOperation({ summary: 'Ask the customer to sign the handover (creates one if none, then notifies)' }) requestHandoverSignature(@Param('bookingId', ParseUUIDPipe) bookingId: string) { @@ -513,9 +528,16 @@ export class WarehouseInventoryController { } @Get('bookings/:bookingId/handover-document') - @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking)' }) - async bookingHandoverDocument(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Res() res: Response) { - const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking(bookingId); + @ApiOperation({ summary: 'View import goods handover document PDF (resolved by booking; ?handoverId= for the per-truck variant)' }) + async bookingHandoverDocument( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @Res() res: Response, + @Query('handoverId') handoverId?: string, + ) { + const { filename, buffer } = await this.inventoryService.handoverDocumentForBooking( + bookingId, + handoverId || undefined, + ); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="${filename}"`); res.setHeader('Content-Length', buffer.length); @@ -534,6 +556,12 @@ export class WarehouseInventoryController { return this.inventoryService.bookingContainerWeights(bookingId); } + @Get('bookings/:bookingId/location') + @ApiOperation({ summary: "Warehouse location of a booking's inventory (customer portal)" }) + bookingLocation(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.inventoryService.bookingLocation(bookingId); + } + @Post(':id/deliver') @BookingStaff(FREIGHT_PERMS.warehouseInventory.deliver) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) 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 c45764bbd..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 @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; import { Cron, CronExpression } from '@nestjs/schedule'; import { Between, @@ -25,6 +26,7 @@ import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; +import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; import { @@ -409,6 +411,7 @@ export class WarehouseInventoryService { private readonly signatures: SignaturesService, private readonly handover: HandoverService, private readonly inbox: NotificationInboxService, + private readonly events: EventEmitter2, ) {} /** @@ -1027,6 +1030,28 @@ export class WarehouseInventoryService { return this.findAll({ ...filter, status: 'READY_FOR_LOADING' }); } + /** + * Warehouse location rows for one booking, trimmed for the customer portal: + * no staff guard on the route, so only location fields leave the API — + * never notes, fees or inspection internals. + */ + async bookingLocation(bookingId: string) { + const items = await this.inventoryRepository.findAll({ + where: { bookingId }, + relations: { warehouse: true, yard: true, zone: true }, + order: { createdAt: 'DESC' }, + }); + return items.map((i) => ({ + id: i.id, + bookingId: i.bookingId, + status: i.status, + arrivedAt: i.arrivedAt ?? null, + warehouse: i.warehouse ? { id: i.warehouse.id, name: i.warehouse.name, code: i.warehouse.code } : null, + yard: i.yard ? { id: i.yard.id, name: i.yard.name, code: i.yard.code } : null, + zone: i.zone ? { id: i.zone.id, name: i.zone.name, code: i.zone.code } : null, + })); + } + async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { relations: { warehouse: { facility: true }, yard: true, zone: true }, @@ -1553,6 +1578,14 @@ export class WarehouseInventoryService { notes: `Bulk received (${dto.direction})`, truckEntrance, }); + + // Validate capacity before saving + const weight = Number(booking.weight) || 0; + const containerCount = booking.freightType === 'CONTAINER' ? containerQuantity : 0; + this.assertCapacity('Warehouse', warehouse, weight, 0, containerCount); + this.assertCapacity('Yard', yard, weight, 0, containerCount); + this.assertCapacity('Zone', zone, weight, 0, containerCount); + const saved = await manager.getRepository(WarehouseInventory).save( manager.getRepository(WarehouseInventory).create({ warehouseId: dto.warehouseId, @@ -1560,7 +1593,7 @@ export class WarehouseInventoryService { zoneId: dto.zoneId, bookingId, quantity: booking.freightType === 'CONTAINER' ? containerQuantity : 1, - weight: Number(booking.weight) || 0, + weight, grnNumber, status: 'RECEIVED', arrivedAt: now, @@ -1568,6 +1601,9 @@ export class WarehouseInventoryService { }), ); + // Update warehouse/yard/zone capacity counters + await this.applyCapacityDelta(manager, dto, weight, 0, containerCount); + // Receiving the booking flags every container unit as received into the // port (self-haul export: the delivering truck's goods are now in) so // staff can raise the per-container GRN over what's received. @@ -2468,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; @@ -2986,6 +3032,29 @@ export class WarehouseInventoryService { ); } + // A truck leaves with the containers ASSIGNED to it — never another + // truck's. Enforced whenever the truck has an assigned load on file + // (customer self-haul or EDR last-mile). + if (dto.containerNumber && dto.truckPlateNumber?.trim()) { + const selectedNumbers = dto.containerNumber + .split(/[,;\n]+/) + .map((n) => n.trim().toUpperCase()) + .filter(Boolean); + const assigned = await this.truckAssignedContainers( + item.bookingId, + dto.truckPlateNumber.trim(), + ); + if (assigned.length && selectedNumbers.length) { + const foreign = selectedNumbers.filter((n) => !assigned.includes(n)); + if (foreign.length) { + throw new BadRequestException( + `Container${foreign.length > 1 ? 's' : ''} ${foreign.join(', ')} ` + + `not assigned to truck ${dto.truckPlateNumber.trim()} — each truck may only carry out its own assigned containers`, + ); + } + } + } + // Authoritative weight match: the truck's net (gross − tare) must equal the // total VGM cargo weight of the containers selected as loaded on it. // Skipped when the operator chose not to weigh (containers only). @@ -3148,7 +3217,7 @@ export class WarehouseInventoryService { // EDR last-mile: this truck is leaving — record its exit and the load it // actually took. net_weight_tons drives the bulk drawdown (booking VGM // minus everything already hauled away). - await manager.query( + const [edrDeparted] = (await manager.query( `UPDATE freight.last_mile_vehicle_assignments va SET departed_at = COALESCE($3::timestamptz, NOW()), arrived_at = COALESCE(va.arrived_at, NOW()), @@ -3162,7 +3231,8 @@ export class WarehouseInventoryService { AND v.id = va.vehicle_id AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2)) AND va.departed_at IS NULL - AND va.deleted_at IS NULL`, + AND va.deleted_at IS NULL + RETURNING va.id`, [ item.bookingId, dto.truckPlateNumber.trim(), @@ -3170,7 +3240,31 @@ export class WarehouseInventoryService { grossTons, netTons, ], - ); + )) as [Array<{ id: string }>, unknown]; + // EDR last-mile: the handover is generated the moment the truck exits + // (with its exit paper) — one per truck — and the customer is asked to + // sign it from the portal. Booking-level fallback when the plate matched + // no live assignment (e.g. exit re-recorded) but the booking is EDR-hauled. + for (const row of edrDeparted) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim(), edrAssignmentId: row.id }, + manager, + ); + } + if (!edrDeparted.length) { + const [lm]: Array<{ id: string }> = await manager.query( + `SELECT id FROM freight.last_mile WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [item.bookingId], + ); + if (lm) { + await this.handover.ensureForDepartedEdrTruck( + item.bookingId, + { truckPlate: dto.truckPlateNumber.trim() }, + manager, + ); + } + } // Customer self-haul: the same exit record on the customer's own truck. // Without it a self-haul bulk booking never draws down — hauled tonnage // summed to zero and the booking could take unlimited trucks. Matched by @@ -3220,11 +3314,43 @@ export class WarehouseInventoryService { // the transaction and fire-and-forget: notifying must never fail the exit. if (isTruckLeaving && item.bookingId) { void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons); + } else if (item.bookingId) { + // Gate-in: same single-path hook for the arrival side (self-haul + EDR). + void this.notifyTruckArrival(item.bookingId, dto.truckPlateNumber?.trim() ?? null); } return this.findById(id); } + /** Best-effort truck-arrival notification (gate-in), mirror of the departure one. */ + private async notifyTruckArrival(bookingId: string, plateNumber: string | null): Promise { + try { + const [booking]: Array<{ companyId: string | null; reference: string | null }> = + await this.dataSource.query( + `SELECT company_id AS "companyId", reference + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking?.companyId) return; + const ref = booking.reference ?? bookingId; + const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck'; + const body = `${truck} has arrived at the warehouse for booking ${ref}.`; + await this.inbox.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Truck arrived at the warehouse', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' }, + }); + await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body); + } catch (err) { + this.logger.warn(`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`); + } + } + /** * Best-effort truck-departure notification to the booking's company across * every channel: in-app (portal inbox) + SMS + email. Never throws — a missing @@ -3265,6 +3391,17 @@ export class WarehouseInventoryService { } } + /** + * customer_truck_assignments.gross_weight_kg holds TONNES for gate-out + * recorded exits but real KG for legacy departTruck rows. Exit papers always + * print tonnes — normalise on read. + */ + // ponytail: >1000 heuristic (no truck hauls 1000+ t, no weighbridge reads <1000 kg); + // migrate the column to tonnes if it ever bites. + private grossAsTons(value: number): number { + return value > 1000 ? Math.round(value) / 1000 : value; + } + async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, @@ -3324,7 +3461,40 @@ export class WarehouseInventoryService { grossWeightKg: string | number | null; departedAt: string | null; } | null = null; - if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { + // The exit-inspection note written at gate-out names the truck doing THIS + // exit — resolve by its plate first. The item's own container may not be on + // the departing truck at all (trucks pick containers freely per trip). + const notePlates = [...String(row?.notes ?? '').matchAll(/Truck Plate:\s*(\S+)/gi)]; + const exitPlate = notePlates.length ? notePlates[notePlates.length - 1][1] : null; + if (row?.tradeDirection === 'IMPORT' && row?.bookingId && exitPlate) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + string_agg(DISTINCT c.container_number, ', ' ORDER BY c.container_number) AS "containerNumbers", + COALESCE(( + SELECT SUM(bcu.vgm_tons) + FROM freight.customer_truck_containers cc + JOIN freight.booking_container_units bcu + ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + AND bc.booking_id = a.booking_id + WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL + ), 0) AS "truckWeightTons" + FROM freight.customer_truck_assignments a + LEFT JOIN freight.customer_truck_containers c + ON c.assignment_id = a.id AND c.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.gross_weight_kg, a.departed_at + LIMIT 1`, + [row.bookingId, exitPlate], + ); + truck = truckRow ?? null; + } + if (!truck && row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) { const [truckRow] = await this.dataSource.query( `SELECT a.plate_number AS "plateNumber", a.driver_name AS "driverName", @@ -3354,6 +3524,26 @@ export class WarehouseInventoryService { ); truck = truckRow ?? null; } + // Bulk self-haul (no container to match) or an unmatched container: the exit + // paper is still PER TRUCK — use the latest departed customer truck and its + // weighed gross, never the booking's declared total. + if (!truck && row?.tradeDirection === 'IMPORT' && row?.bookingId) { + const [truckRow] = await this.dataSource.query( + `SELECT a.plate_number AS "plateNumber", + a.driver_name AS "driverName", + a.truck_type AS "truckType", + a.gross_weight_kg AS "grossWeightKg", + a.departed_at AS "departedAt", + NULL AS "containerNumbers", + a.net_weight_tons AS "truckWeightTons" + FROM freight.customer_truck_assignments a + WHERE a.booking_id = $1 AND a.deleted_at IS NULL AND a.departed_at IS NOT NULL + ORDER BY a.departed_at DESC + LIMIT 1`, + [row.bookingId], + ); + truck = truckRow ?? null; + } const bookingReference = row?.bookingReference || 'N/A'; const reference = @@ -3368,7 +3558,9 @@ export class WarehouseInventoryService { customerName: row?.customerName ?? null, freightType: row?.freightType ?? null, tradeDirection: row?.tradeDirection ?? null, - containerNumber: row?.containerNumber ?? null, + // Per-truck exit: list every container leaving on THIS truck, not just + // the inventory item's own container. + containerNumber: truck?.containerNumbers ?? row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, quantity: Number(row?.quantity ?? 0), weight: Number(row?.weight ?? 0), @@ -3382,12 +3574,12 @@ export class WarehouseInventoryService { truckDriverName: truck?.driverName ?? null, truckType: truck?.truckType ?? null, truckGateOut: truck?.departedAt ?? null, - // Prefer the weighed gross captured on departure; fall back to the summed - // container VGM when the truck hasn't been weighed yet. - truckWeightKg: truck - ? Number(truck.grossWeightKg ?? 0) > 0 - ? Number(truck.grossWeightKg) - : Number(truck.truckWeightTons ?? 0) * 1000 + // Per-truck load in tonnes: the summed VGM of the containers on this truck + // (recorded net for bulk); the weighed gross only as fallback. + truckWeightTons: truck + ? Number(truck.truckWeightTons ?? 0) > 0 + ? Number(truck.truckWeightTons) + : this.grossAsTons(Number(truck.grossWeightKg ?? 0)) : null, }); @@ -3505,6 +3697,32 @@ export class WarehouseInventoryService { })); } + /** + * Container numbers assigned to a truck (by plate) on this booking, from both + * haulage paths: customer self-haul (customer_truck_containers) and EDR + * last-mile (last_mile_vehicle_containers / legacy scalar). Uppercased. + */ + private async truckAssignedContainers(bookingId: string, plate: string): Promise { + const rows: Array<{ cn: string | null }> = await this.dataSource.query( + `SELECT UPPER(cc.container_number) AS cn + FROM freight.customer_truck_assignments a + JOIN freight.customer_truck_containers cc + ON cc.assignment_id = a.id AND cc.deleted_at IS NULL + WHERE a.booking_id = $1 AND UPPER(a.plate_number) = UPPER($2) AND a.deleted_at IS NULL + UNION + SELECT UPPER(COALESCE(vc.container_number, va.container_number)) AS cn + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.vehicles v ON v.id = va.vehicle_id + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))`, + [bookingId, plate], + ); + return rows.map((r) => r.cn).filter((n): n is string => Boolean(n)); + } + /** * The booking's containers with their VGM cargo weight (tonnes), keyed by * container number. Drives the truck-leaving exit weighing: the selected @@ -3560,10 +3778,17 @@ export class WarehouseInventoryService { ); if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id); - const containers: Array<{ containerNumber: string; goods: string | null }> = + const containers: Array<{ containerNumber: string; goods: string | null; vgmTons: string | null }> = await this.dataSource.query( `SELECT c.container_number AS "containerNumber", - COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods + COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods, + (SELECT SUM(u.vgm_tons) + FROM freight.booking_container_units u + JOIN freight.booking_container bc + ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL + WHERE u.container_number = c.container_number + AND bc.booking_id = c.booking_id + AND u.deleted_at IS NULL) AS "vgmTons" FROM freight.customer_truck_containers c JOIN freight.bookings b ON b.id = c.booking_id LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id @@ -3572,6 +3797,9 @@ export class WarehouseInventoryService { [assignmentId], ); + // Truck load in tonnes: summed container VGM; the weighed gross only as + // fallback (bulk trucks carry no containers). + const vgmSum = containers.reduce((s, c) => s + (Number(c.vgmTons) || 0), 0); const html = this.buildTruckExitPaperHtml({ reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`, bookingReference: truck.bookingReference, @@ -3579,7 +3807,7 @@ export class WarehouseInventoryService { plateNumber: truck.plateNumber, driverName: truck.driverName, truckType: truck.truckType, - grossWeightKg: Number(truck.grossWeightKg ?? 0), + grossWeightKg: vgmSum > 0 ? vgmSum : this.grossAsTons(Number(truck.grossWeightKg ?? 0)), gateOut: truck.departedAt, containers, }); @@ -3869,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); @@ -3896,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, @@ -3906,8 +4135,215 @@ export class WarehouseInventoryService { }; } - /** Handover PDF resolved by booking (for the portal, which only has bookingId). */ - async handoverDocumentForBooking(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { + /** + * Customer signs ONE handover from the portal (EDR last-mile: one per truck). + * When the last one is signed — and every EDR truck has left the warehouse — + * the delivery completes automatically: inventory + cargo delivered, last-mile + * leg DELIVERED (trucks freed), booking completed ("shipment delivered"). + */ + async signHandover( + handoverId: string, + userId?: string, + signerName?: string, + ): Promise<{ + handoverId: string; + bookingId: string; + signedAt: string | null; + signerDisplayName: string; + allSigned: boolean; + }> { + if (!userId) { + throw new BadRequestException('Authentication is required to sign the handover'); + } + const name = signerName?.trim(); + if (!name) { + 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; + truckPlate: string | null; + mileType: string; + edrAssignmentId: string | null; + }> = await this.dataSource.query( + `SELECT booking_id AS "bookingId", reference, truck_plate AS "truckPlate", + mile_type AS "mileType", edr_assignment_id AS "edrAssignmentId" + FROM freight.booking_handovers + WHERE id = $1 AND deleted_at IS NULL`, + [handoverId], + ); + if (!h) throw new NotFoundException(`Handover ${handoverId} not found`); + // Self-haul stays a single booking-level signature via approve-delivery, + // which also enforces inspection-passed + truck-arrived. Per-truck signing + // is an EDR last-mile flow only. + if (h.mileType !== 'EDR_LAST_MILE') { + throw new BadRequestException( + 'This handover is signed through Approve delivery, not per truck', + ); + } + + // Same gate as approve-delivery: storage/demurrage must be settled first. + const [inv]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY updated_at DESC NULLS LAST, created_at DESC LIMIT 1`, + [h.bookingId], + ); + if (inv) await this.invoices.assertClearanceAllowed(inv.id); + + const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null); + const allSigned = await this.handover.isFullySigned(h.bookingId); + + if (inv) { + await this.activityLog.record({ + activityType: 'INVENTORY_RELEASED', + inventoryId: inv.id, + warehouseId: inv.warehouseId, + description: `Customer signed handover ${h.reference}${h.truckPlate ? ` (truck ${h.truckPlate})` : ''} as ${name}`, + performedBy: name, + }); + } + + // EDR last-mile delivers PER TRUCK: this signature confirms receipt of the + // goods THIS truck carried, so only its containers become DELIVERED now. + // (Self-haul keeps the single booking-level handover + manual Deliver.) + if (h.mileType === 'EDR_LAST_MILE') { + try { + await this.deliverEdrTruckContainers(h, name); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver after handover sign failed for ${h.bookingId}: ${(err as Error).message}`, + ); + } + } + + if (allSigned) { + void this.completeEdrDeliveryIfReady(h.bookingId, name).catch((err: Error) => + this.logger.warn(`Auto-complete after handover sign failed for ${h.bookingId}: ${err.message}`), + ); + } + + return { + handoverId, + bookingId: h.bookingId, + signedAt: signed.signedAt ? new Date(signed.signedAt).toISOString() : null, + signerDisplayName: name, + allSigned, + }; + } + + /** + * EDR last-mile auto-completion: once every handover is signed and every EDR + * truck has departed, deliver the remaining inventory, mark the last-mile leg + * DELIVERED and complete the booking. Self-haul bookings keep their manual + * Deliver flow (no last_mile record ⇒ no-op). + */ + private async completeEdrDeliveryIfReady(bookingId: string, signerName: string): Promise { + const [lm]: Array<{ id: string; status: string }> = await this.dataSource.query( + `SELECT id, status FROM freight.last_mile + WHERE booking_id = $1 AND deleted_at IS NULL LIMIT 1`, + [bookingId], + ); + if (!lm) return; + + const [pending]: Array<{ notDeparted: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS "notDeparted" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [bookingId], + ); + if (Number(pending?.notDeparted ?? 0) > 0) return; + if (!(await this.handover.isFullySigned(bookingId))) return; + + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT id FROM freight.warehouse_inventory + WHERE booking_id = $1 AND status = 'READY_FOR_PICKUP' AND deleted_at IS NULL`, + [bookingId], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: 'Auto-delivered on customer handover signature', + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn(`Auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`); + } + } + + if (lm.status !== 'DELIVERED') { + try { + await this.lastMileService.update(lm.id, { status: 'DELIVERED' } as UpdateLastMileDto); + } catch (err) { + this.logger.warn(`Auto-deliver of last-mile ${lm.id} failed: ${(err as Error).message}`); + } + } + + // Booking → COMPLETED ("shipment delivered" notification) — owned by the + // bookings module; evented to avoid a warehouses→bookings service dependency. + this.events.emit('import.handover.completed', { bookingId }); + } + + /** + * EDR last-mile per-truck delivery: the customer signed THIS truck's handover, + * so only the container items that truck carried become DELIVERED. Bulk cargo + * (no container rows) is delivered by completeEdrDeliveryIfReady once every + * truck is signed off. + */ + private async deliverEdrTruckContainers( + h: { bookingId: string; edrAssignmentId: string | null; truckPlate: string | null }, + signerName: string, + ): Promise { + if (!h.edrAssignmentId && !h.truckPlate) return; + const items: Array<{ id: string }> = await this.dataSource.query( + `SELECT DISTINCT inv.id + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + LEFT JOIN freight.vehicles v ON v.id = va.vehicle_id + JOIN freight.containers c + ON c.container_number = COALESCE(vc.container_number, va.container_number) + AND c.deleted_at IS NULL + JOIN freight.warehouse_inventory inv + ON inv.container_id = c.id AND inv.booking_id = l.booking_id AND inv.deleted_at IS NULL + WHERE l.booking_id = $1 + AND va.deleted_at IS NULL + AND inv.status = 'READY_FOR_PICKUP' + AND (va.id = $2::uuid + OR ($2::uuid IS NULL + AND (UPPER(v.power_plate_no) = UPPER($3) OR UPPER(v.plate_number) = UPPER($3))))`, + [h.bookingId, h.edrAssignmentId, h.truckPlate ?? ''], + ); + for (const it of items) { + try { + await this.deliver(it.id, { + receiverName: signerName, + remarks: `Auto-delivered on customer handover signature${h.truckPlate ? ` (truck ${h.truckPlate})` : ''}`, + performedBy: signerName, + } as DeliverInventoryDto); + } catch (err) { + this.logger.warn( + `Per-truck auto-deliver of inventory ${it.id} failed: ${(err as Error).message}`, + ); + } + } + } + + /** + * Handover PDF resolved by booking (for the portal, which only has bookingId). + * With `handoverId` the document is rendered for that specific handover — the + * per-truck EDR last-mile variant (truck plate + that truck's signature state). + */ + async handoverDocumentForBooking( + bookingId: string, + handoverId?: string, + ): Promise<{ filename: string; buffer: Buffer }> { const [inv]: Array<{ id: string }> = await this.dataSource.query( `SELECT id FROM freight.warehouse_inventory WHERE booking_id = $1 AND deleted_at IS NULL @@ -3918,7 +4354,29 @@ export class WarehouseInventoryService { if (!inv) { throw new NotFoundException(`No warehouse inventory found for booking ${bookingId}`); } - return this.handoverDocument(inv.id); + if (!handoverId) return this.handoverDocument(inv.id); + + const [h]: Array<{ + reference: string; + truckPlate: string | null; + signedAt: string | null; + signerName: string | null; + }> = await this.dataSource.query( + `SELECT reference, truck_plate AS "truckPlate", + signed_at AS "signedAt", signer_name AS "signerName" + FROM freight.booking_handovers + WHERE id = $1 AND booking_id = $2 AND deleted_at IS NULL`, + [handoverId, bookingId], + ); + if (!h) { + throw new NotFoundException(`Handover ${handoverId} not found for booking ${bookingId}`); + } + return this.handoverDocument(inv.id, { + reference: h.reference, + truckPlate: h.truckPlate, + signedAt: h.signedAt ? new Date(h.signedAt) : null, + signerName: h.signerName, + }); } /** Resolve the primary warehouse-inventory item for a booking (most recent). */ @@ -3946,7 +4404,15 @@ export class WarehouseInventoryService { return this.releaseDocument(await this.primaryInventoryIdForBooking(bookingId)); } - async handoverDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { + async handoverDocument( + id: string, + perTruck?: { + reference: string; + truckPlate: string | null; + signedAt: Date | null; + signerName: string | null; + }, + ): Promise<{ filename: string; buffer: Buffer }> { const [row] = await this.dataSource.query( `SELECT inv.id, inv.booking_id AS "bookingId", @@ -4033,12 +4499,14 @@ export class WarehouseInventoryService { const bookingReference = row.bookingReference || row.bookingId || 'N/A'; const reference = + perTruck?.reference || this.extractHandoverDocumentLine(row.notes, 'Handover Reference') || `HND-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}`; const generatedAtValue = this.extractHandoverDocumentLine(row.notes, 'Generated At'); const generatedAt = generatedAtValue ? new Date(generatedAtValue) : new Date(); const handedOverAt = Number.isNaN(generatedAt.getTime()) ? new Date() : generatedAt; - if (!generatedAtValue) { + // Per-truck renders must not stamp their reference into the shared item notes. + if (!generatedAtValue && !perTruck) { await this.inventoryRepository.update(id, { notes: this.replaceHandoverDocumentNote(row.notes, this.buildHandoverDocumentNote(reference, handedOverAt)), }); @@ -4072,7 +4540,16 @@ export class WarehouseInventoryService { releaseDate: row.releaseDate ? new Date(row.releaseDate) : null, trainSchedule: row.trainSchedule ?? null, lastMileDeliveryAddress: row.lastMileDeliveryAddress ?? null, - customerApproval: this.extractCustomerDeliveryApproval(row.notes), + truckPlate: perTruck?.truckPlate ?? null, + customerApproval: perTruck + ? perTruck.signedAt + ? { + approvedAt: perTruck.signedAt.toISOString(), + signerDisplayName: perTruck.signerName ?? '-', + signatureImageUrl: null, + } + : null + : this.extractCustomerDeliveryApproval(row.notes), }); return { @@ -4131,6 +4608,42 @@ export class WarehouseInventoryService { ); } } + // EDR last-mile delivers per truck: a container item only needs the truck + // CARRYING IT to have left; bulk (no container) waits for every truck. + if (item.containerId) { + const [own]: Array<{ pending: string }> = await this.dataSource.query( + `SELECT COUNT(*) FILTER (WHERE va.departed_at IS NULL) AS pending + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + LEFT JOIN freight.last_mile_vehicle_containers vc + ON vc.assignment_id = va.id AND vc.deleted_at IS NULL + JOIN freight.containers c ON c.id = $2 AND c.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL + AND COALESCE(vc.container_number, va.container_number) = c.container_number`, + [item.bookingId, item.containerId], + ); + if (Number(own?.pending ?? 0) > 0) { + throw new BadRequestException( + 'Deliver is available only after the EDR truck carrying this container has left', + ); + } + } else { + const [lm]: Array<{ total: string; left: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE va.departed_at IS NOT NULL) AS "left" + FROM freight.last_mile_vehicle_assignments va + JOIN freight.last_mile l ON l.id = va.last_mile_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND va.deleted_at IS NULL`, + [item.bookingId], + ); + const lmTotal = Number(lm?.total ?? 0); + const lmLeft = Number(lm?.left ?? 0); + if (lmTotal > 0 && lmLeft < lmTotal) { + throw new BadRequestException( + `Deliver is available only after every assigned EDR truck has left (${lmLeft} of ${lmTotal} so far)`, + ); + } + } } const receiverName = dto.receiverName.trim(); @@ -4225,6 +4738,12 @@ export class WarehouseInventoryService { } }); + // "Approve delivery" nudge: on Deliver the customer is reminded to sign any + // handover still unsigned (per truck for EDR last-mile). Fire-and-forget. + if (item.bookingId) { + void this.handover.notifyUnsignedForBooking(item.bookingId).catch(() => undefined); + } + return this.findById(id); } @@ -4798,7 +5317,7 @@ export class WarehouseInventoryService { truckDriverName?: string | null; truckType?: string | null; truckGateOut?: string | null; - truckWeightKg?: number | null; + truckWeightTons?: number | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -4825,8 +5344,8 @@ export class WarehouseInventoryService { ['Quantity', data.quantity], [ data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight', - `${(data.truckPlateNumber && data.truckWeightKg - ? data.truckWeightKg + `${(data.truckPlateNumber && data.truckWeightTons + ? data.truckWeightTons : data.weight ).toLocaleString()} t`, ], @@ -4952,10 +5471,11 @@ export class WarehouseInventoryService { releaseDate: Date | null; trainSchedule: string | null; lastMileDeliveryAddress: string | null; + truckPlate?: string | null; customerApproval: { approvedAt: string; signerDisplayName: string; - signatureImageUrl: string; + signatureImageUrl: string | null; } | null; }): string { const esc = (value: unknown) => @@ -5001,6 +5521,7 @@ export class WarehouseInventoryService { ['Release Order', data.releaseOrderReference], ['Release Date', fmt(data.releaseDate)], ['Last-mile Delivery Address', data.lastMileDeliveryAddress], + ...(data.truckPlate ? [['Delivering Truck Plate', data.truckPlate]] : []), ]; const approval = data.customerApproval; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts index fdfbc36be..5f4205815 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; -import { BookingStaff } from '../../common/booking-guards'; +import { BookingStaff, StaffReference } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -10,8 +10,9 @@ import { WarehouseZonesService } from './warehouse-zones.service'; @ApiTags('warehouse-yards') @ApiBearerAuth() +// No class-level guard: the two reference GETs are open to any signed-in +// staff (StaffReference), every other route carries its own permission. @Controller('warehouse-yards') -@BookingStaff(FREIGHT_PERMS.warehouseYards.view) export class WarehouseYardsController { constructor( private readonly yardsService: WarehouseYardsService, @@ -19,12 +20,14 @@ export class WarehouseYardsController { ) {} @Get() + @StaffReference() @ApiOperation({ summary: 'List all warehouse yards' }) findAll() { return this.yardsService.findAll(); } @Get(':id') + @StaffReference() @ApiOperation({ summary: 'Get warehouse yard by ID' }) findOne(@Param('id', ParseUUIDPipe) id: string) { return this.yardsService.findById(id); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts index 3279e9092..5b5e2b227 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-yards.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto'; import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto'; @@ -44,6 +44,7 @@ export class WarehouseYardsService { // Ensure the parent warehouse exists. await this.warehousesService.findById(warehouseId); await this.assertCodeUnique(warehouseId, dto.code.trim()); + await this.assertCapacityWithinWarehouse(warehouseId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.yardsRepository.create({ warehouseId, @@ -69,14 +70,22 @@ export class WarehouseYardsService { await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed warehouse limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinWarehouse(existing.warehouseId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.yardsRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -97,4 +106,39 @@ export class WarehouseYardsService { throw new ConflictException(`Yard code ${code} already exists in this warehouse`); } } + + private async assertCapacityWithinWarehouse( + warehouseId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeYardId?: string, + ): Promise { + const warehouse = await this.warehousesService.findById(warehouseId); + const yards = await this.findByWarehouse(warehouseId); + + // Sum existing yard capacities, excluding the yard being updated if provided + const otherYards = excludeYardId ? yards.filter((y) => y.id !== excludeYardId) : yards; + const totalExistingWeight = otherYards.reduce((sum, y) => sum + (y.capacityWeight ?? 0), 0); + const totalExistingContainers = otherYards.reduce((sum, y) => sum + (y.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && warehouse.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > warehouse.capacityWeight) { + throw new BadRequestException( + `Total yard weight capacity (${totalWeight}t) exceeds warehouse limit (${warehouse.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && warehouse.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > warehouse.capacityContainers) { + throw new BadRequestException( + `Total yard container capacity (${totalContainers}) exceeds warehouse limit (${warehouse.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts index b4ae2e0de..367a5a75e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-zones.service.ts @@ -1,4 +1,4 @@ -import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto'; import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto'; @@ -43,6 +43,7 @@ export class WarehouseZonesService { // Ensure the parent yard exists. await this.yardsService.findById(yardId); await this.assertCodeUnique(yardId, dto.code.trim()); + await this.assertCapacityWithinYard(yardId, dto.capacityWeight ?? null, dto.capacityContainers ?? null); return this.zonesRepository.create({ yardId, @@ -68,14 +69,22 @@ export class WarehouseZonesService { await this.assertCodeUnique(existing.yardId, dto.code.trim(), id); } + const newCapacityWeight = dto.capacityWeight ?? existing.capacityWeight ?? null; + const newCapacityContainers = dto.capacityContainers ?? existing.capacityContainers ?? null; + + // Validate updated capacity doesn't exceed yard limits + if (newCapacityWeight !== (existing.capacityWeight ?? null) || newCapacityContainers !== (existing.capacityContainers ?? null)) { + await this.assertCapacityWithinYard(existing.yardId, newCapacityWeight, newCapacityContainers, id); + } + const status = dto.status ?? existing.status; const updated = await this.zonesRepository.update(id, { name: dto.name?.trim() ?? existing.name, code: dto.code?.trim() ?? existing.code, type: dto.type ?? existing.type, - capacityWeight: dto.capacityWeight ?? existing.capacityWeight, - capacityContainers: dto.capacityContainers ?? existing.capacityContainers, + capacityWeight: newCapacityWeight, + capacityContainers: newCapacityContainers, maxWeight: dto.maxWeight ?? existing.maxWeight, maxVolume: dto.maxVolume ?? existing.maxVolume, status, @@ -96,4 +105,39 @@ export class WarehouseZonesService { throw new ConflictException(`Zone code ${code} already exists in this yard`); } } + + private async assertCapacityWithinYard( + yardId: string, + newCapacityWeight: number | null, + newCapacityContainers: number | null, + excludeZoneId?: string, + ): Promise { + const yard = await this.yardsService.findById(yardId); + const zones = await this.findByYard(yardId); + + // Sum existing zone capacities, excluding the zone being updated if provided + const otherZones = excludeZoneId ? zones.filter((z) => z.id !== excludeZoneId) : zones; + const totalExistingWeight = otherZones.reduce((sum, z) => sum + (z.capacityWeight ?? 0), 0); + const totalExistingContainers = otherZones.reduce((sum, z) => sum + (z.capacityContainers ?? 0), 0); + + // Check weight capacity + if (newCapacityWeight !== null && yard.capacityWeight != null) { + const totalWeight = totalExistingWeight + newCapacityWeight; + if (totalWeight > yard.capacityWeight) { + throw new BadRequestException( + `Total zone weight capacity (${totalWeight}t) exceeds yard limit (${yard.capacityWeight}t)`, + ); + } + } + + // Check container capacity + if (newCapacityContainers !== null && yard.capacityContainers != null) { + const totalContainers = totalExistingContainers + newCapacityContainers; + if (totalContainers > yard.capacityContainers) { + throw new BadRequestException( + `Total zone container capacity (${totalContainers}) exceeds yard limit (${yard.capacityContainers})`, + ); + } + } + } } diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index e6ea89fbe..7ebcfb89c 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -304,4 +304,6 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [ { key: "djibouti_gl", name: { en: "Djibouti GL" }, rank: 3, permissionKeys: [...POSITION_PERMISSION_PRESETS.djiboutiGl] }, { key: "marketer", name: { en: "Marketer" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.marketer] }, { key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] }, + { key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] }, + { key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] }, ]; 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 396ac95db..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, @@ -804,6 +867,48 @@ export const POSITION_PERMISSION_PRESETS = { ...ROLE_PERMISSION_PRESETS.operationsOfficer, FREIGHT_PERMS.allocation.manage, ]), + // Operations Chief: full operational authority — the entire freight + // permission catalog (all CRUD across bookings, contracts, scheduling, + // fleet, warehouse, mile, finance, settings, staff). + operationsChief: dedupe([...BOOKING_RULE_ENGINE_PERMISSION_KEYS]), + // Dispatcher: full CRUD on warehouse management (incl. import/export/intercity + // inventory flows) and fleet management, plus truck dispatch on the mile legs + // and operational context. The ONE carve-out: allocation & fee rules stay + // VIEW-ONLY — a dispatcher never creates/updates/deletes those rules. + dispatcher: dedupe([ + // Warehouse management — full CRUD. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), + // View-only on the rules that govern allocation and fees. + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + // Fleet management — full CRUD. + ...Object.values(FREIGHT_PERMS.fleet), + FREIGHT_PERMS.fleetDashboard.view, + ...Object.values(FREIGHT_PERMS.fleetReports), + ...Object.values(FREIGHT_PERMS.vehicles), + ...Object.values(FREIGHT_PERMS.drivers), + ...Object.values(FREIGHT_PERMS.tracking), + ...Object.values(FREIGHT_PERMS.fuel), + ...Object.values(FREIGHT_PERMS.maintenance), + ...Object.values(FREIGHT_PERMS.locomotives), + ...Object.values(FREIGHT_PERMS.wagons), + ...Object.values(FREIGHT_PERMS.trains), + ...Object.values(FREIGHT_PERMS.routes), + ...Object.values(FREIGHT_PERMS.containers), + ...Object.values(FREIGHT_PERMS.cargoes), + // Truck dispatch on the EDR mile legs + operational context. + ...Object.values(FREIGHT_PERMS.firstMile), + ...Object.values(FREIGHT_PERMS.lastMile), + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.bookings.operations, + ]), } as const; /** Derive the module bucket from the resource segment of a permission key. */ 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 86f5bf6e6..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -146,11 +146,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Overview", href: "/dashboard/overview", icon: , + permission: FREIGHT_PERMS.overview.view, }, { label: "Customers", href: "/dashboard/customers", icon: , + permission: FREIGHT_PERMS.customers.view, }, { label: "Contracts", @@ -162,6 +164,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Bookings", href: "/dashboard/booking-requests", icon: , + permission: FREIGHT_PERMS.bookings.view, }, // Operations hub: clearance-document review for contracts WITHOUT // customs clearing (contract-level for one-time, per-booking for general). @@ -187,6 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Support", href: "/dashboard/support", icon: , + permission: FREIGHT_PERMS.support.view, }, ...demoItems, ], @@ -267,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], }, // { @@ -291,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", @@ -375,31 +379,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Imports", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Import Overview", href: "/dashboard/import-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Arrival Queue", href: "/dashboard/arrival-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=IMPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Inventory Inquiry", href: "/dashboard/inventory-inquiry", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -407,41 +417,49 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Exports", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, children: [ { label: "Export Overview", href: "/dashboard/export-warehouse", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loading Queue", href: "/dashboard/loading-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Loaded Inventory", href: "/dashboard/loaded-inventory", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Dispatch Queue", href: "/dashboard/dispatch-queue", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Djibouti Unloading", href: "/dashboard/export-djibouti-unloading", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Interchange Documents", href: "/dashboard/interchange-documents", icon: , + permission: FREIGHT_PERMS.interchangeDocuments.view, }, { label: "Terminal Inventory", href: "/dashboard/warehouse-inventory?direction=EXPORT", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -449,11 +467,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Intercity", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, children: [ { label: "Intercity Cargo", href: "/dashboard/intercity", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, ], }, @@ -465,6 +485,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Warehouse Dashboard", href: "/dashboard/warehouse-dashboard", icon: , + permission: FREIGHT_PERMS.warehouseDashboard.view, }, { // Yard-wide, not per-direction: the gate sees import and export @@ -472,21 +493,28 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Trucks on Site", href: "/dashboard/trucks-on-site", icon: , + permission: FREIGHT_PERMS.warehouseInventory.view, }, { label: "Warehouses", href: "/dashboard/warehouses", icon: , + permission: FREIGHT_PERMS.warehouses.view, }, { label: "Allocation & Fees", href: "/dashboard/warehouse-rules", icon: , + permission: [ + FREIGHT_PERMS.warehouseAllocationRules.view, + FREIGHT_PERMS.warehouseFeeRules.view, + ], }, { label: "Fee Invoices", href: "/dashboard/warehouse-fee-invoices", icon: , + permission: FREIGHT_PERMS.warehouseFeeInvoices.view, }, ], }, @@ -520,13 +548,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), - { - label: "Contract validity", - href: "/dashboard/configuration/contract-validity-periods", - }, { label: "Train scheduling rules", href: "/dashboard/configuration/train-scheduling-rules", + permission: FREIGHT_PERMS.trainScheduling.rulesManage, }, ], }, @@ -541,6 +566,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Staff", href: "/user-management", icon: , + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], }, ], }, @@ -550,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 => @@ -585,21 +626,34 @@ const filterSidebarByPermission = ( return keys.some((key) => hasFreightPermission(user, key)); }; - const itemAllowed = (item: SidebarItem): boolean => { - // GL positions are locked to their single clearance page. - if (etGl) return isEtClearanceItem(item); - if (djGl) return isDjClearanceItem(item); - - // Everyone else: hide the GL-only clearance pages entirely. - if (isClearanceItem(item)) return false; - - return permissionAllowed(item); - }; + // Recursive: children are filtered first; a group (item with children) stays + // only while it still has visible children — so parents without their own + // permission key never leak a whole subtree the user cannot open. + const filterItems = (items: SidebarItem[]): SidebarItem[] => + items + .map((item) => + item.children + ? { ...item, children: filterItems(item.children) } + : item, + ) + .filter((item) => { + if (etGl || djGl) { + // GL positions are locked to their single clearance page (parents + // survive only as the path to that page). + const isTarget = etGl ? isEtClearanceItem : isDjClearanceItem; + return isTarget(item) || (item.children?.length ?? 0) > 0; + } + // Everyone else: hide the GL-only clearance pages entirely. + if (isClearanceItem(item)) return false; + if (!permissionAllowed(item)) return false; + if (item.children) return item.children.length > 0; + return true; + }); return sections .map((section) => ({ ...section, - items: section.items.filter(itemAllowed), + items: filterItems(section.items), })) .filter((section) => section.items.length > 0); }; @@ -672,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 ; } @@ -708,7 +766,7 @@ const App = () => { } /> {/* } /> */} } /> - } /> + } /> } /> ); @@ -744,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> { + } @@ -1063,7 +1135,7 @@ const App = () => { + } @@ -1071,7 +1143,7 @@ const App = () => { + } @@ -1079,7 +1151,7 @@ const App = () => { + } @@ -1087,7 +1159,7 @@ const App = () => { + } @@ -1095,7 +1167,7 @@ const App = () => { + } @@ -1103,7 +1175,7 @@ const App = () => { + } @@ -1111,7 +1183,7 @@ const App = () => { + } @@ -1119,7 +1191,7 @@ const App = () => { + } @@ -1205,7 +1277,7 @@ const App = () => { + } @@ -1293,7 +1365,7 @@ const App = () => { + } @@ -1301,7 +1373,7 @@ const App = () => { + } @@ -1309,7 +1381,7 @@ const App = () => { + } @@ -1317,7 +1389,7 @@ const App = () => { + } @@ -1325,7 +1397,7 @@ const App = () => { + } @@ -1333,7 +1405,7 @@ const App = () => { + } @@ -1341,7 +1413,7 @@ const App = () => { + } @@ -1349,7 +1421,7 @@ const App = () => { + } @@ -1416,14 +1488,14 @@ const App = () => { } /> - } - /> + /> */} } /> Pickup date setPickupDate(e.target.value)} /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index 7896da730..cc2bfb0fb 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -14,6 +14,8 @@ import { } from "lucide-react"; import type { Freight } from "@edr/types"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import { contractsService } from "@/services/contracts.service"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; @@ -48,8 +50,19 @@ export function ContractActionsToolbar({ onReviewClearance, }: ContractActionsToolbarProps) { const navigate = useNavigate(); + const { user } = useAuth(); const { status } = contract; + // Intake permissions are split per freight type: an accept:bulk holder must + // not see the accept button on a container contract (API enforces the same). + const arm = contract.freightType === "BULK" ? "bulk" : "container"; + const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]); + const mayRequestChanges = hasPermission( + user, + FREIGHT_PERMS.contracts.requestChanges[arm], + ); + const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]); + const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("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" ? ( <>