diff --git a/.gitignore b/.gitignore index cadb36cea..316f08dc3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ e2e-ui-report/ test-results/ playwright-report/ blob-report/ +RUNNING_LOCALLY.md diff --git a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts index af9b9428c..48eadb6c7 100644 --- a/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-rate-schedule.builder.ts @@ -59,7 +59,7 @@ const TRIGGER_ROUTE_LABELS: Partial> = { REEFER: 'Reefer (refrigerated) surcharge', WITH_RETURN: 'Empty-container return service', SHIPPING_LINE: 'Shipping line handling', - CONSOLIDATION: 'Container consolidation (extra document)', + CONSOLIDATION: 'Penalty (container consolidation)', LASHING: 'Cargo lashing and securing', CANCELLATION: 'Booking cancellation fee', DEMURRAGE: 'Demurrage / wagon detention', diff --git a/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts new file mode 100644 index 000000000..983aa6e64 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2810000000000-AddMaintenanceServiceItem.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Named service items for KM-based maintenance ("oil change", "tires", …). + * The coarse maintenance_type enum (PREVENTIVE/…) allowed only one interval + * per type per vehicle, so oil and tire intervals could not coexist. Interval + * identity becomes (vehicle, maintenance_type, service_item); schedules carry + * the item so completion re-finds the right interval for auto-scheduling. + */ +export class AddMaintenanceServiceItem2810000000000 implements MigrationInterface { + name = 'AddMaintenanceServiceItem2810000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules ADD COLUMN IF NOT EXISTS service_item varchar(120);`, + ); + // Re-key interval uniqueness on (vehicle, type, item). COALESCE folds the + // item-less legacy rows into one slot; soft-deleted rows are ignored. + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type_item" + ON freight.maintenance_intervals (vehicle_id, maintenance_type, COALESCE(service_item, '')) + WHERE deleted_at IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight."UQ_maintenance_intervals_vehicle_type_item";`, + ); + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_maintenance_intervals_vehicle_type" + ON freight.maintenance_intervals (vehicle_id, maintenance_type); + `); + await queryRunner.query( + `ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS service_item;`, + ); + await queryRunner.query( + `ALTER TABLE freight.maintenance_intervals DROP COLUMN IF EXISTS service_item;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts new file mode 100644 index 000000000..17902ff0d --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2820000000000-CustomsClearanceRouteScope.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the customs clearance service fee to a direction + route. + * + * The fee was a single global flat rate; the business sells it per lane — + * "import clearance, Djibouti → Adama, 300 USD". CUSTOMS_CLEARANCE rates now + * carry trade_direction + the yard pair, and contract pricing matches on them + * strictly (no route-less fallback). + * + * Existing route-less clearance rates cannot be backfilled (no way to know + * which lane each was meant for) — retired exactly like the base-freight + * retirement in AddRateYardScope: SUPERSEDED + soft-deleted, kept for + * snapshot history. + */ +export class CustomsClearanceRouteScope2820000000000 implements MigrationInterface { + name = 'CustomsClearanceRouteScope2820000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired (their lanes were never recorded); down only + // restores the pre-customs constraint shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN "trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts new file mode 100644 index 000000000..26c512cfc --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2830000000000-ReturnSurchargeRouteScope.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Scope the empty-container return surcharge to a direction + route + + * container type, like base freight (import-only for now — the box only goes + * back to the port on imports). + * + * Existing route-less RETURN_SURCHARGE rates cannot be backfilled — retired + * (SUPERSEDED + soft-deleted) exactly like base freight and customs clearance + * were, kept readable for snapshot history. Route-scoped replacements must be + * re-entered; a booking that asks for return with no matching rate hard-blocks. + */ +export class ReturnSurchargeRouteScope2830000000000 implements MigrationInterface { + name = 'ReturnSurchargeRouteScope2830000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'RETURN_SURCHARGE' + AND (origin_yard_id IS NULL OR destination_yard_id IS NULL); + `); + + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" IN ('CUSTOMS_CLEARANCE', 'WITH_RETURN') + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + // Retired rates stay retired; down only restores the customs-era shape. + await queryRunner.query( + `ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope";`, + ); + await queryRunner.query(` + ALTER TABLE freight.rates + ADD CONSTRAINT "CK_rates_yard_scope" CHECK ( + deleted_at IS NOT NULL + OR status = 'SUPERSEDED' + OR CASE + WHEN ("trigger" = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY')) + OR "trigger" = 'CUSTOMS_CLEARANCE' + THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL + ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL + END + ); + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts new file mode 100644 index 000000000..9fd854b94 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2860000000000-DropClearanceFeePrepay.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * The customs clearance service fee is no longer prepaid via its own + * `clearance`-source invoice — it is billed as a CUSTOMS_CLEARANCE line on the + * booking invoice, together with the freight (see BookingPricingService). + * + * - Contracts/bookings parked at the payment gate move straight to the + * document step (the gate no longer exists — nothing could ever pay them). + * - Open (unpaid) clearance invoices are expired; PAID ones stay as history. + * NOTE: a ONE_TIME customs contract that already PAID its prepaid fee but + * has not booked yet will be billed the fee again on its booking invoice — + * accepted for dev data; reverses the old AddClearanceFeePayment migration. + * - clearance_fee_paid_at columns are dropped from contracts and bookings. + */ +export class DropClearanceFeePrepay2860000000000 implements MigrationInterface { + name = 'DropClearanceFeePrepay2860000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.contracts + SET status = 'AWAITING_CLEARANCE_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.contracts + SET clearance_status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE clearance_status = 'AWAITING_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.bookings + SET status = 'AWAITING_DOCUMENTS', updated_at = now() + WHERE status = 'AWAITING_CLEARANCE_PAYMENT'; + `); + await queryRunner.query(` + UPDATE freight.invoices + SET status = 'EXPIRED', updated_at = now() + WHERE source = 'clearance' + AND status IN ('DRAFT', 'ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'); + `); + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS clearance_fee_paid_at;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Moved rows and expired invoices stay — only the columns come back. + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS clearance_fee_paid_at timestamptz;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts new file mode 100644 index 000000000..be01e7825 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2870000000000-CustomsClearancePerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customs clearance fees are now sold per cargo kind: container fees name a + * container type (billed PER_CONTAINER / PER_WAGON), bulk fees carry no type + * (billed PER_TON / PER_WAGON). The old one-FLAT-fee-per-route shape cannot be + * mapped to a kind — retired (SUPERSEDED + soft-deleted) exactly like the + * base-freight and return-surcharge reshapes, kept readable for snapshot + * history. Per-kind replacements must be re-entered; a customs contract or + * booking without a matching fee hard-blocks. Contracts that already froze a + * FLAT snapshot keep billing it (legacy honoured at booking pricing). + */ +export class CustomsClearancePerKind2870000000000 implements MigrationInterface { + name = 'CustomsClearancePerKind2870000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND rate_type = 'CUSTOMS_CLEARANCE' + AND rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts new file mode 100644 index 000000000..9d311c60f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2880000000000-LashingPerKind.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now sold per cargo kind, like the customs clearance fee: + * container rates name a container type (PER_CONTAINER / PER_WAGON), bulk + * rates carry no type (PER_TON / PER_WAGON). The old flat-per-booking shape + * cannot be mapped to a kind — retired (SUPERSEDED + soft-deleted), kept + * readable for snapshot history. Per-kind replacements must be re-entered; + * an unconfigured lashing rate simply bills nothing (lenient, like + * hazard/reefer). Matched on trigger, not rate_type — CONSOLIDATION rates + * share the LASHING rate_type and must survive. + */ +export class LashingPerKind2880000000000 implements MigrationInterface { + name = 'LashingPerKind2880000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'LASHING' + AND rate_unit = 'FLAT'; + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-kind rates instead. + } +} diff --git a/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts new file mode 100644 index 000000000..8904c6194 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2890000000000-LashingBulkOnlyPerDirection.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Lashing is now BULK-only and sold per trade direction (IMPORT / EXPORT), + * optionally narrowed to one leaf commodity. Rates that no longer fit — + * container-scoped, or carrying no direction — cannot be mapped and are + * retired (SUPERSEDED + soft-deleted), kept readable for snapshot history. + * Matched on trigger, not rate_type (CONSOLIDATION shares rate_type LASHING). + */ +export class LashingBulkOnlyPerDirection2890000000000 implements MigrationInterface { + name = 'LashingBulkOnlyPerDirection2890000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.rates + SET status = 'SUPERSEDED', + deleted_at = now(), + updated_at = now() + WHERE deleted_at IS NULL + AND "trigger" = 'LASHING' + AND (container_type_id IS NOT NULL + OR trade_direction IS NULL + OR trade_direction NOT IN ('IMPORT', 'EXPORT')); + `); + } + + public async down(): Promise { + // Retired rates stay retired — re-enter per-direction bulk rates instead. + } +} diff --git a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts index cf324a501..cc7507dd6 100644 --- a/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts +++ b/apps/edr-freight-api/src/modules/backoffice/dto/create-organization-user.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsBoolean, IsEmail, IsObject, IsOptional, IsString, MinLength } from "class-validator"; +import { Type } from "class-transformer"; +import { IsBoolean, IsEmail, IsOptional, IsString, MinLength, ValidateNested } from "class-validator"; class CreateOrganizationUserNameDto { @ApiProperty() @@ -29,7 +30,8 @@ export class CreateOrganizationUserDto { phoneNumber?: string; @ApiProperty({ type: CreateOrganizationUserNameDto }) - @IsObject() + @ValidateNested() + @Type(() => CreateOrganizationUserNameDto) name!: CreateOrganizationUserNameDto; @ApiProperty({ required: false, default: false }) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts index 3d8222d63..82bb1144e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-invoice.service.ts @@ -16,7 +16,6 @@ import { InvoiceLineInput, } from "../billing/billing.service"; import { Invoice } from "../billing/entities/invoice.entity"; -import { CLEARANCE_BOOKING_INVOICE_TYPE } from "../contracts/clearance-fee.service"; import { FirstMileService } from "../first-mile/first-mile.service"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import { PriceLineItemDto } from "./dto/generate-price-response.dto"; @@ -121,8 +120,7 @@ export class BookingInvoiceService { } /** - * Expire the booking's currently-open invoices (freight PREPAID and the - * per-shipment clearance fee) when the booking is + * Expire the booking's currently-open freight (PREPAID) invoice when the booking is * cancelled or rejected — the counterpart to the pay-window-expiry path * (which also calls {@link BillingService.expirePayable}). Stops a terminated * booking from leaving a payable invoice open. No-op when the booking has no @@ -133,15 +131,6 @@ export class BookingInvoiceService { bookingId: string, manager?: EntityManager, ): Promise { - // The per-shipment clearance fee (GENERAL contracts) bills this same booking - // id under its own source/type — retire it alongside the freight invoice, or - // a cancelled shipment keeps a payable clearance invoice open. - await this.billing.expirePayable( - Freight.InvoiceSource.Clearance, - bookingId, - CLEARANCE_BOOKING_INVOICE_TYPE, - manager, - ); return this.billing.expirePayable( Freight.InvoiceSource.Booking, bookingId, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index fecd9111a..667abe842 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -56,6 +56,7 @@ describe('BookingPricingService — domestic corridor', () => { ratesService as never, exchangeService as never, { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + {} as never, ); }); @@ -240,3 +241,235 @@ describe('BookingPricingService — domestic corridor', () => { expect(result.blocked[0]).toContain('rate is configured'); }); }); + +describe('BookingPricingService — customs clearance fee billed on the booking price', () => { + const DJ = 'yard-dj'; + + const containerFee20: Rate = { + id: 'rate-cc-20', + rateType: 'CUSTOMS_CLEARANCE', + trigger: 'CUSTOMS_CLEARANCE', + currency: 'USD', + rateValue: 100, + rateUnit: 'PER_CONTAINER', + status: 'LIVE', + containerTypeId: 'ct-20', + tradeDirection: 'IMPORT', + originYardId: DJ, + destinationYardId: DIRE, + } as Rate; + + const bulkFeePerTon: Rate = { + ...containerFee20, + id: 'rate-cc-bulk', + rateValue: 5, + rateUnit: 'PER_TON', + containerTypeId: null, + } as Rate; + + const emptyEval = { + priorityScore: 0, + appliedModifiers: [], + containerWeightResults: [], + warnings: [], + hardBlocked: [], + requiresDirectorApproval: false, + }; + + const makeService = (opts: { + snapshots?: unknown[]; + liveRates?: Rate[]; + wagonCapacity?: number; + }) => + new BookingPricingService( + { + calculateWagonCount: jest.fn().mockResolvedValue(0), + findContractRateSnapshots: jest.fn().mockResolvedValue(opts.snapshots ?? []), + } as never, + { evaluate: jest.fn().mockResolvedValue(emptyEval) } as never, + { + findById: jest.fn(async (id: string) => ({ + id, + sizeFt: id === 'ct-40' ? 40 : 20, + isReefer: false, + code: id === 'ct-40' ? 'C40' : 'C20', + })), + } as never, + { findLiveRates: jest.fn().mockResolvedValue(opts.liveRates ?? []) } as never, + { getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never, + { validate20ftPairing: jest.fn().mockResolvedValue([]) } as never, + { + findById: jest.fn().mockResolvedValue({ + wagonTypes: + opts.wagonCapacity !== undefined + ? [{ capacityTons: opts.wagonCapacity }] + : [], + }), + } as never, + ); + + const containerBooking = (overrides: Record = {}) => + ({ + id: 'b-cc', + freightType: 'CONTAINER', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, wagonsRequired: 2 }, + ], + ...overrides, + }) as unknown as Booking; + + const bulkBooking = (overrides: Record = {}) => + ({ + id: 'b-cc-bulk', + freightType: 'BULK', + tradeDirection: 'IMPORT', + paymentCurrency: 'USD', + customsClearingEnabled: true, + cargoTypeId: 'cargo-1', + cargoTotalWeightVgm: 120, + originYardId: DJ, + destinationYardId: DIRE, + bookingContainers: [], + ...overrides, + }) as unknown as Booking; + + it('bills a container booking per box at its own container type fee', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line).toBeDefined(); + expect(line!.unit).toBe('PER_CONTAINER'); + expect(line!.quantity).toBe(4); + expect(line!.amount).toBe(400); + }); + + it('bills a PER_WAGON container fee on the wagons the boxes occupy (two 20ft share one)', async () => { + const service = makeService({ + liveRates: [{ ...containerFee20, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(containerBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); + expect(line!.amount).toBe(200); + }); + + it('hard-blocks a container type with no fee configured (never free clearance)', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(containerBooking()); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(true); + }); + + it('bills a bulk booking per ton at the route bulk fee', async () => { + const service = makeService({ liveRates: [bulkFeePerTon] }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_TON'); + expect(line!.quantity).toBe(120); + expect(line!.amount).toBe(600); + }); + + it('the fee scoped to the booking commodity wins over the catch-all', async () => { + const service = makeService({ + liveRates: [ + { ...bulkFeePerTon, id: 'rate-cc-catchall', rateValue: 5 } as Rate, + { + ...bulkFeePerTon, + id: 'rate-cc-sugar', + rateValue: 9, + cargoTypeId: 'cargo-1', + } as Rate, + ], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unitAmount).toBe(9); // commodity rate, not the 5 USD catch-all + expect(line!.amount).toBe(1080); + }); + + it('bills a PER_WAGON bulk fee on ceil(tons ÷ wagon capacity)', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON', rateValue: 50 } as Rate], + wagonCapacity: 60, + }); + const result = await service.computePriceForBooking(bulkBooking()); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('PER_WAGON'); + expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon + expect(line!.amount).toBe(100); + }); + + it('blocks a PER_WAGON bulk fee when no wagon capacity is configured', async () => { + const service = makeService({ + liveRates: [{ ...bulkFeePerTon, rateUnit: 'PER_WAGON' } as Rate], + }); + const result = await service.computePriceForBooking(bulkBooking()); + + expect(result.hardBlocked.some((m) => m.includes('wagon'))).toBe(true); + }); + + it('prefers the contract frozen per-size snapshot over the live rate', async () => { + const service = makeService({ + liveRates: [containerFee20], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE_20FT', + unitPrice: 80, + currency: 'USD', + unitOfMeasure: 'per_container', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-1' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE_20FT'); + expect(line!.amount).toBe(320); // 4 × frozen 80, not live 100 + }); + + it('honours a legacy FLAT snapshot once for the whole container booking', async () => { + const service = makeService({ + liveRates: [], + snapshots: [ + { + rateCode: 'CUSTOMS_CLEARANCE', + unitPrice: 500, + currency: 'USD', + unitOfMeasure: 'flat', + isClearance: true, + }, + ], + }); + const result = await service.computePriceForBooking( + containerBooking({ contractId: 'c-legacy' }), + ); + + const line = result.lineItems.find((l) => l.code === 'CUSTOMS_CLEARANCE'); + expect(line!.unit).toBe('FLAT'); + expect(line!.amount).toBe(500); + expect(result.hardBlocked.some((m) => m.includes('customs clearance'))).toBe(false); + }); + + it('adds no fee line when customs clearing is disabled', async () => { + const service = makeService({ liveRates: [containerFee20] }); + const result = await service.computePriceForBooking( + containerBooking({ customsClearingEnabled: false }), + ); + + expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 32aa1a880..89825e100 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { CargoTypesService } from '../rule-engine/services/cargo-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { RatesService } from '../rule-engine/services/rates.service'; import { Rate } from '../rule-engine/entities/rate.entity'; @@ -10,7 +11,10 @@ import { BookingEvaluationInput, RuleEngineService, } from '../rule-engine/rule-engine.service'; -import { containersPerWagonForSize } from '../rule-engine/container-type.util'; +import { + containersPerWagonForSize, + wagonsPerUnitForSize, +} from '../rule-engine/container-type.util'; import { BookingsRepository } from './bookings.repository'; import { wagonRemainder } from './consolidation.service'; import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto'; @@ -76,6 +80,7 @@ export class BookingPricingService { private readonly ratesService: RatesService, private readonly exchangeService: ExchangeService, private readonly containerValidationService: ContainerValidationService, + private readonly cargoTypesService: CargoTypesService, ) {} async generatePrice(bookingId: string): Promise { @@ -165,8 +170,16 @@ export class BookingPricingService { const usdAmount = mod.calculatedAmount; const rate = rateById.get(mod.rateId); - const unit = rate?.rateUnit ?? 'FLAT'; - const unitUsd = rate ? Number(rate.rateValue) : usdAmount; + // Derived/route-matched charges (import overweight, empty-container + // return) carry their own unit price + billing unit — bill and display + // those, not whatever the referenced rate row says. + const isDerived = mod.unitPriceUsd != null; + const unit = mod.billingUnit ?? rate?.rateUnit ?? 'FLAT'; + const unitUsd = isDerived + ? Number(mod.unitPriceUsd) + : rate + ? Number(rate.rateValue) + : usdAmount; // Per-unit count: FLAT and PER_INVOICE are billed once (qty 1); an // explicit trigger (e.g. overweight tons) wins when present; otherwise // derive from total ÷ unit price (the live unit price — a count, not a @@ -182,11 +195,11 @@ export class BookingPricingService { // H15: bill the frozen contract surcharge rate (already in the booking // currency) when this code has a snapshot; else keep the live amount. - const frozen = this.frozenRateByCode( - frozenRates, - mod.surchargeCode, - paymentCurrency, - ); + // Derived charges skip the snapshot — import overweight prices off the + // route's container freight, never a frozen OVERWEIGHT_PER_TON value. + const frozen = isDerived + ? null + : this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency); const unitAmount = frozen ? Number(frozen.unitPrice) : isEtbBooking @@ -215,6 +228,23 @@ export class BookingPricingService { if (rate) usedRatesMap.set(rate.id, rate); } + // Customs clearance service fee (Path B) — billed HERE, on the booking + // invoice with the freight; no separate prepaid clearance invoice. Sold per + // cargo kind: container bookings bill each container type's own fee (per + // box or per wagon), bulk bookings the route's bulk fee (per ton or per + // wagon). Frozen contract snapshots win over live rates; a customs booking + // with nothing configured hard-blocks — clearance never ships for free. + const clearanceBlocked: string[] = []; + if (booking.customsClearingEnabled) { + const clearance = await this.customsClearanceLines(booking, frozenRates, liveRates); + for (const line of clearance.lineItems) { + lineItems.push(line); + total += line.amount; + } + for (const rate of clearance.usedRates) usedRatesMap.set(rate.id, rate); + clearanceBlocked.push(...clearance.blocked); + } + // Overweight detail for the customer: map the engine's per-line results back // to the booking's container lines (same order) for code + weights. maxAllowed // is derived from the line total minus the excess the engine computed. @@ -252,7 +282,7 @@ export class BookingPricingService { appliedModifiers: ruleResult.appliedModifiers, priorityScore: ruleResult.priorityScore, warnings: [...ruleResult.warnings, ...baseWarnings], - hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked], + hardBlocked: [...ruleResult.hardBlocked, ...baseBlocked, ...clearanceBlocked], overweightLines, }; } @@ -313,6 +343,8 @@ export class BookingPricingService { hazardousQuantity: Number(bc.hazardousQuantity ?? 0), reeferQuantity: Number(bc.reeferQuantity ?? 0), returnQuantity: Number(bc.returnQuantity ?? 0), + // Wagon share per box — a PER_WAGON empty-return rate bills on it. + wagonsPerUnit: wagonsPerUnitForSize(ct.sizeFt), }, perWagon: containersPerWagonForSize(ct.sizeFt), quantity: qty, @@ -330,6 +362,13 @@ export class BookingPricingService { ), ) : 0; + // Bulk wagon estimate for PER_WAGON kind-scoped surcharges (lashing). + // Deliberately NOT totalWagons — that would shift wagon-count priority + // scoring for bulk bookings. + const bulkWagons = + booking.freightType === 'BULK' + ? ((await this.bulkWagonCount(booking)) ?? 0) + : 0; // Consolidation is system-managed: the CONSOLIDATION surcharge fires whenever // a container type leaves a wagon partially filled. Aggregate by type first — @@ -369,6 +408,8 @@ export class BookingPricingService { isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, totalWagons, // Bulk tonnage scales PER_TON surcharges (e.g. the bulk reefer surcharge). // Container freight carries 0 here — its surcharges scale by container count. @@ -376,6 +417,7 @@ export class BookingPricingService { booking.freightType === 'BULK' ? Number(booking.cargoTotalWeightVgm ?? 0) : 0, + bulkWagons, containers, }; } @@ -884,6 +926,191 @@ export class BookingPricingService { return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency); } + /** + * Customs clearance service fee lines for a customs booking (Path B), billed + * with the freight. Container bookings bill each container line at its own + * container type's fee — PER_CONTAINER × boxes or PER_WAGON × the wagons the + * line occupies (two 20ft share one). Bulk bookings bill the route's type-less + * fee — PER_TON × tonnage or PER_WAGON × wagons the bulk occupies. Frozen + * contract snapshots (CUSTOMS_CLEARANCE_20FT / _40FT / CUSTOMS_CLEARANCE) + * win over live rates; contracts frozen before the per-kind model carry one + * FLAT CUSTOMS_CLEARANCE snapshot, honoured once for the whole booking. + */ + private async customsClearanceLines( + booking: Booking, + frozenRates: Map | null, + liveRates: Rate[], + ): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[]; blocked: string[] }> { + const lineItems: PriceLineItemDto[] = []; + const usedRates: Rate[] = []; + const blocked: string[] = []; + const currency = booking.paymentCurrency; + const isEtb = currency === 'ETB'; + const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; + const convert = (usd: number): number => (isEtb ? Math.round(usd * usdToEtb) : usd); + + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CUSTOMS_CLEARANCE' && + r.currency === 'USD' && + r.tradeDirection === booking.tradeDirection && + r.originYardId === booking.originYardId && + r.destinationYardId === booking.destinationYardId, + ); + const missingRateMessage = (scope: string): string => + `No customs clearance service fee is configured for ${scope} on this ` + + 'origin → destination. Ask EDR to configure the CUSTOMS_CLEARANCE rate for this route.'; + + if (booking.freightType === 'CONTAINER') { + // Legacy short-circuit: an old contract froze one flat fee — bill it once. + const hasPerSizeSnapshot = + frozenRates?.has('CUSTOMS_CLEARANCE_20FT') || + frozenRates?.has('CUSTOMS_CLEARANCE_40FT'); + const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + if (legacyFlat && !hasPerSizeSnapshot) { + const amount = Number(legacyFlat.unitPrice); + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service', + amount, + unitAmount: amount, + unit: 'FLAT', + quantity: 1, + currency, + }); + } + return { lineItems, usedRates, blocked }; + } + + for (const bc of booking.bookingContainers ?? []) { + if (!bc.containerTypeId) continue; + const qty = Number(bc.quantity || 0); + if (!(qty > 0)) continue; + let sizeFt = 0; + try { + sizeFt = + Number((await this.containerTypesService.findById(bc.containerTypeId)).sizeFt) || 0; + } catch { + // unknown type — falls through to the live per-type lookup below + } + const frozen = sizeFt + ? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency) + : null; + const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage(`${sizeFt || '?'}ft containers`)); + continue; + } + const unit = frozen + ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) + : live!.rateUnit; + const unitAmount = frozen + ? Number(frozen.unitPrice) + : convert(Number(live!.rateValue)); + const billedQty = + unit === 'PER_WAGON' ? Math.ceil(qty * wagonsPerUnitForSize(sizeFt)) : qty; + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (!(amount > 0)) continue; + lineItems.push({ + code: sizeFt ? `CUSTOMS_CLEARANCE_${sizeFt}FT` : 'CUSTOMS_CLEARANCE', + description: `Customs clearance service${sizeFt ? ` (${sizeFt}ft)` : ''}`, + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + // Bulk — one fee for the whole booking. The bulk snapshot and the legacy + // flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee. + // Live lookup: the rate scoped to the booking's commodity wins; a + // commodity-less rate (legacy) is the catch-all fallback. + const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency); + const live = + (booking.cargoTypeId + ? onLeg.find( + (r) => !r.containerTypeId && r.cargoTypeId === booking.cargoTypeId, + ) + : undefined) ?? onLeg.find((r) => !r.containerTypeId && !r.cargoTypeId); + if (!frozen && !live) { + blocked.push(missingRateMessage('bulk cargo')); + return { lineItems, usedRates, blocked }; + } + const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit; + const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue)); + let billedQty = 1; + if (unit === 'PER_TON') { + billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0)); + } else if (unit === 'PER_WAGON') { + const wagons = await this.bulkWagonCount(booking); + if (wagons == null) { + blocked.push( + 'The bulk customs clearance fee is per wagon, but this cargo type has ' + + 'no wagon type with a capacity configured — the wagon count cannot ' + + 'be derived. Ask EDR to configure the cargo type’s wagon types.', + ); + return { lineItems, usedRates, blocked }; + } + billedQty = wagons; + } + const amount = unit === 'FLAT' ? unitAmount : unitAmount * billedQty; + if (amount > 0) { + lineItems.push({ + code: 'CUSTOMS_CLEARANCE', + description: 'Customs clearance service (bulk)', + amount, + unitAmount, + unit, + quantity: unit === 'FLAT' ? 1 : billedQty, + currency, + }); + if (live && !frozen) usedRates.push(live); + } + return { lineItems, usedRates, blocked }; + } + + /** Snapshot unit-of-measure → the rate unit the billing math applies. */ + private rateUnitFromSnapshot(unitOfMeasure: string): string { + switch (unitOfMeasure) { + case 'per_wagon': + return 'PER_WAGON'; + case 'per_ton': + return 'PER_TON'; + case 'per_container': + return 'PER_CONTAINER'; + default: + return 'FLAT'; + } + } + + /** + * Wagons a bulk booking occupies — ceil(tons ÷ rated capacity), using the + * largest-capacity wagon type its cargo type allows. Null when the chain is + * unconfigured (no cargo type, no wagon types, no capacity). + * ponytail: pricing-time estimate off the biggest allowed wagon; scheduling + * may stock a smaller type and use more wagons. + */ + private async bulkWagonCount(booking: Booking): Promise { + const tons = Number(booking.cargoTotalWeightVgm ?? 0); + if (!(tons > 0) || !booking.cargoTypeId) return null; + try { + const cargo = await this.cargoTypesService.findById(booking.cargoTypeId); + const capacity = Math.max( + 0, + ...(cargo.wagonTypes ?? []).map((w) => Number(w.capacityTons) || 0), + ); + if (!(capacity > 0)) return null; + return Math.max(1, Math.ceil(tons / capacity)); + } catch { + return null; + } + } + private lineItemsSignature(items: PriceLineItemDto[]): string { return JSON.stringify( [...items] diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 36705daa0..ec6e466b2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -605,11 +605,6 @@ export class BookingTransitionService { files: Express.Multer.File[], ): Promise { const booking = await this.bookingsService.findById(bookingId); - if (booking.status === "AWAITING_CLEARANCE_PAYMENT") { - throw new ConflictException( - "The customs clearance service fee for this shipment has not been paid yet — pay it from the portal to unlock document upload.", - ); - } assertBookingStatus(booking, [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index c93c61f13..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)", }) 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 da324786d..d40433f4e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -442,6 +442,8 @@ export class BookingsService { isReefer?: boolean; isGovernment?: boolean; shippingLineId?: string | null; + originYardId?: string | null; + destinationYardId?: string | null; bulkTons?: number; containers: CreateBookingContainerDto[]; }): Promise { @@ -487,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, @@ -808,6 +812,8 @@ export class BookingsService { isReefer: dto.isReefer, isGovernment, shippingLineId: dto.shippingLineId, + originYardId: dto.originYardId, + destinationYardId: dto.destinationYardId, bulkTons: dto.cargoTotalWeightVgm, containers, }); @@ -1018,6 +1024,8 @@ export class BookingsService { isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined, + originYardId: dto.originYardId ?? existing.originYardId, + destinationYardId: dto.destinationYardId ?? existing.destinationYardId, bulkTons: dto.cargoTotalWeightVgm ?? Number(existing.cargoTotalWeightVgm ?? 0), containers, }); diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 2aa3ee8c2..821b9c9f6 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -45,7 +45,6 @@ export const BOOKING_STATUSES = [ 'CONTRACT_ACTIVE', 'CONTRACT_CLOSED', // Post counter-sign document-clearance gate (GL workflow). - 'AWAITING_CLEARANCE_PAYMENT', // clearance fee invoiced, unpaid — docs locked 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', @@ -521,10 +520,6 @@ export class Booking extends BaseEntity { @Column({ name: 'clearance_current_phase', type: 'varchar', length: 40, nullable: true }) clearanceCurrentPhase?: string | null; - /** When the prepaid customs clearance service fee settled (GENERAL + customs). */ - @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) - clearanceFeePaidAt?: Date | null; - @Column({ name: 'duty_required', type: 'boolean', nullable: true }) dutyRequired?: boolean | null; diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 43d70ce19..8e3be4d1a 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -11,13 +11,22 @@ import { HttpCode, HttpStatus, UseInterceptors, + UseGuards, UploadedFiles, BadRequestException, + NotFoundException, } from "@nestjs/common"; import { AnyFilesInterceptor } from "@nestjs/platform-express"; import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; import { CurrentUser } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; +import { JwtGuard } from "@tria-plc/api-common/modules/auth/services/jwt.guard"; +import { BookingStaff } from "../../common/booking-guards"; +import { + assertFreightPermission, + hasFreightPermission, +} from "../../common/freight-permission.util"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { FilesService } from "../files/files.service"; import { CompaniesService } from "./companies.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -59,6 +68,23 @@ interface CurrentIamUser { phoneNumber?: string; } +/** + * Which permission a status write needs. Approving/reactivating is a different + * authority from suspending, but both arrive on the same route with the target + * in the BODY — a route-level guard can't tell them apart, so the handlers + * assert against this map instead. + * + * Keyed by string so it serves both `CompanyStatus` and `ProfileStatus` + * (a superset: it adds `rejected`). + */ +const STATUS_PERM: Record = { + active: FREIGHT_PERMS.customers.verify, + pending: FREIGHT_PERMS.customers.verify, + rejected: FREIGHT_PERMS.customers.verify, + suspended: FREIGHT_PERMS.customers.deactivate, + blacklisted: FREIGHT_PERMS.customers.deactivate, +}; + @ApiTags("Companies") @Controller("companies") export class CompaniesController { @@ -410,7 +436,7 @@ export class CompaniesController { // Used by backoffice @Post() - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.create) @ApiOperation({ summary: "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", @@ -421,12 +447,14 @@ export class CompaniesController { } @Get("stats") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Company counts by status (KPI strip)" }) async getStats(): Promise { return this.companiesService.getCompanyStats(); } @Get() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List companies (paginated, filterable)" }) async findAll( @Query() query: ListCompaniesQueryDto, @@ -436,6 +464,7 @@ export class CompaniesController { } @Get(":id") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get company by ID" }) async findById( @Param("id", ParseUUIDPipe) id: string, @@ -446,30 +475,77 @@ export class CompaniesController { return dto; } + /** + * Edits fields AND carries `status`, so it spans two authorities. The route + * guard is one-of (a status-only caller must get in); the asserts below are + * what actually authorize: touching `status` needs the permission + * {@link STATUS_PERM} maps it to, touching anything else needs + * `customers:update`. Both checks are required — without the second, a + * caller holding only `customers:deactivate` could rename the company. + */ @Patch(":id") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.update, + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company" }) async update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, + @CurrentUser() user: TCurrentUser, ): Promise { + const { status, ...fields } = dto; + if (status) assertFreightPermission(user, STATUS_PERM[status]); + if (Object.keys(fields).length > 0) { + assertFreightPermission(user, FREIGHT_PERMS.customers.update); + } const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } @Delete(":id") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.deactivate) @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } + /** + * Dual-audience: staff read any customer's documents, and the portal reads + * its OWN during onboarding (`companiesService.getDocuments`). So the route + * is authenticated-only and the split happens here — same shape as + * `GET /contracts/:id`. Gating it on a staff permission alone would 403 every + * customer on their own documents. + * + * The staff arm is one-of because two pages consume it: the customer detail + * page (`customers:view`) and the contract-request detail page, whose route + * is gated on `contracts:view` — a contract reviewer without the customer + * permission still needs the applicant's documents. + */ @Get(":companyId/documents") + @UseGuards(JwtGuard) @ApiOperation({ summary: "List documents uploaded for a company" }) async listDocuments( @Param("companyId", ParseUUIDPipe) companyId: string, + @CurrentUser() user: TCurrentUser, ) { + const isStaff = [ + FREIGHT_PERMS.customers.view, + FREIGHT_PERMS.contracts.view, + FREIGHT_PERMS.bookings.view, + ].some((p) => hasFreightPermission(user, p)); + + if (!isStaff) { + const { company } = await this.companiesService.getCompanyInfoByUserId( + user.id, + ); + // Hidden as NotFound rather than Forbidden so company ids can't be probed. + if (company.id !== companyId) { + throw new NotFoundException(`Company ${companyId} not found`); + } + } const files = await this.filesService.findByResource(companyId, "companies"); return Promise.all( files.map(async (f) => ({ @@ -490,7 +566,7 @@ export class CompaniesController { } @Post("documents/:fileId/request-change") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Ask the customer to correct one uploaded document", description: @@ -532,14 +608,23 @@ export class CompaniesController { return this.companiesService.uploadCompanyDocuments(companyId, files, user.id); } + /** + * Approve / reject / suspend / blacklist all arrive here with the target in + * the body, so authorization is per-status via {@link STATUS_PERM} rather + * than on the route (the guard is only the one-of gate). + */ @Patch("company-profiles/:profileId/status") - @FreightAdmin() + @BookingStaff([ + FREIGHT_PERMS.customers.verify, + FREIGHT_PERMS.customers.deactivate, + ]) @ApiOperation({ summary: "Update a company profile's approval status" }) async updateCompanyProfileStatus( - @CurrentUser() user: CurrentIamUser, + @CurrentUser() user: TCurrentUser, @Param("profileId", ParseUUIDPipe) profileId: string, @Body() dto: UpdateCompanyProfileStatusDto, ): Promise { + assertFreightPermission(user, STATUS_PERM[dto.status]); const profile = await this.companiesService.setCompanyProfileStatus( profileId, dto.status, @@ -550,7 +635,7 @@ export class CompaniesController { } @Get(":companyId/change-requests") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List a company's profile change requests" }) async listChangeRequests( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -560,7 +645,7 @@ export class CompaniesController { } @Post("change-requests/:id/approve") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Approve a pending profile change request (applies the changes)", }) @@ -576,7 +661,7 @@ export class CompaniesController { } @Post("change-requests/:id/reject") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.verify) @ApiOperation({ summary: "Reject a pending profile change request with a note", }) @@ -594,7 +679,7 @@ export class CompaniesController { } @Post(":companyId/profiles") - @FreightAdmin() + @BookingStaff(FREIGHT_PERMS.customers.update) @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -608,6 +693,7 @@ export class CompaniesController { } @Get(":companyId/profiles") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( @Param("companyId", ParseUUIDPipe) companyId: string, @@ -618,6 +704,7 @@ export class CompaniesController { } @Get("profile/user/:userId") + @BookingStaff(FREIGHT_PERMS.customers.view) @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( @Param("userId", ParseUUIDPipe) userId: string, diff --git a/apps/edr-freight-api/src/modules/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 96357c8c4..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)) @@ -537,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) => @@ -551,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, @@ -590,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); 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 e2e433c70..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 @@ -20,8 +20,13 @@ import { MinioService } from '../minio/minio.service'; import { FileRecord } from '../files/entities/file.entity'; 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'; @@ -31,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'; @@ -157,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, ) {} @@ -239,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) { @@ -535,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( @@ -554,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( @@ -565,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); @@ -628,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); @@ -643,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, @@ -1143,17 +1159,11 @@ export class ContractTransitionService { const cycleNumber = (contract.clearanceCycleNumber ?? 0) + 1; const cycle = await this.contractsRepository.openCycle(contractId, cycleNumber); await this.milestoneService.seedPreBookingMilestones(contract, cycle.id); - // Path B prepay gate: the customs clearance service fee is invoiced here - // and must settle before the document step opens (the paid event advances - // to AWAITING_CLEARANCE_DOCUMENTS). Path A (self-clearance) has no GL fee. - if (await this.clearanceFeeService.gateApplies(contract)) { - await this.clearanceFeeService.issueForContract(contract); - updates.status = 'AWAITING_CLEARANCE_PAYMENT'; - updates.clearanceStatus = 'AWAITING_PAYMENT'; - } else { - updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; - updates.clearanceStatus = 'AWAITING_DOCUMENTS'; - } + // No prepay gate: the customs clearance service fee (Path B) is billed on + // the booking invoice together with the freight, so the document step + // opens immediately. + updates.status = 'AWAITING_CLEARANCE_DOCUMENTS'; + updates.clearanceStatus = 'AWAITING_DOCUMENTS'; updates.clearanceCycleNumber = cycleNumber; } else { // No contract-level clearance gate — DOMESTIC, or any GENERAL contract diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 266a00044..ba80cc035 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -34,7 +34,11 @@ import { import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { ContractDocumentHistoryService } from './contract-document-history.service'; -import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + FREIGHT_PERMS, + bothFreightTypes, + forFreightType, +} from '../../seed/freight-permissions.registry'; import { assertFreightPermission, hasFreightPermission, @@ -184,7 +188,10 @@ export class ContractsController { @CurrentUser() user: TCurrentUser, ) { if (dto.isGovernment) { - assertFreightPermission(user, FREIGHT_PERMS.contracts.staffAccept); + assertFreightPermission( + user, + forFreightType(FREIGHT_PERMS.contracts.staffAccept, dto.freightType), + ); } return this.contractsService.create(dto, files ?? [], user?.id); } @@ -337,23 +344,32 @@ export class ContractsController { } @Post(':id/staff/accept') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + // One-of guard; the service then requires the arm matching the contract's freight type. + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept)) @ApiOperation({ summary: 'Staff accept → set validity window + start approval chain' }) staffAccept( @Param('id', ParseUUIDPipe) id: string, @Body() dto: AcceptContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.staffAccept( id, resolveAuthUserId(user), dto.validityDays, dto.documentSnapshot, + user, ); } + // Readable by anyone who may view the contract: the draft carries + // `editableByMe`, and the approval chain's approvers (identified by position + // type, not by staff_accept) must be able to fetch it to learn it is their + // turn. Gating this on staff_accept hid the edit dialog from every approver. @Get(':id/document/draft') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) @ApiOperation({ summary: 'Editable contract-document draft (this contract\'s snapshot, or the live template) for the accept/edit dialog', @@ -376,8 +392,15 @@ export class ContractsController { return this.documentHistory.list(id); } + // Coarse gate only. WHO may actually edit is turn-based, not a static + // permission, so `updateContractDocument` -> `assertDocumentEditable` is the + // real boundary: it admits only the approver whose step is currently pending + // (edit rights hand off down the chain on each approval). @Put(':id/document/articles') - @BookingStaff(FREIGHT_PERMS.contracts.staffAccept) + @BookingStaff([ + FREIGHT_PERMS.contracts.view, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ]) @ApiOperation({ summary: 'Edit this contract\'s document articles only (per-contract; never touches the six shared templates)', @@ -396,29 +419,35 @@ export class ContractsController { } @Post(':id/staff/request-changes') - @BookingStaff(FREIGHT_PERMS.contracts.requestChanges) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges)) @ApiOperation({ summary: 'Staff return contract for customer updates' }) requestChanges( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RequestChangesDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.transitionService.requestChanges( id, dto.note, resolveAuthUserId(user), + user, ); } @Post(':id/staff/reject') - @BookingStaff(FREIGHT_PERMS.contracts.reject) + @BookingStaff(bothFreightTypes(FREIGHT_PERMS.contracts.reject)) @ApiOperation({ summary: 'Staff reject contract' }) reject( @Param('id', ParseUUIDPipe) id: string, @Body() dto: RejectContractDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { - return this.transitionService.reject(id, dto.reason, resolveAuthUserId(user)); + return this.transitionService.reject( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); } @Post(':id/approval-steps/:stepId/approve') diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 050417a45..bb12648a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -22,7 +22,6 @@ import { ContractsController } from './contracts.controller'; import { ContractsService } from './contracts.service'; import { ContractsRepository } from './contracts.repository'; import { ContractPricingService } from './contract-pricing.service'; -import { ClearanceFeeService } from './clearance-fee.service'; import { ContractNotifierService } from './contract-notifier.service'; import { ContractTransitionService } from './contract-transition.service'; import { ContractClearanceService } from './contract-clearance.service'; @@ -107,7 +106,6 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum ContractsService, ContractsRepository, ContractPricingService, - ClearanceFeeService, ContractNotifierService, ContractTransitionService, ContractDocumentHistoryService, diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts index 52fcd437f..244a2816d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract-rate-snapshot.entity.ts @@ -46,8 +46,8 @@ export class ContractRateSnapshot extends BaseEntity { conditionalOn?: string | null; /** - * Customs clearance service fee line — billed up front via a clearance - * invoice, excluded from shipment booking totals. + * Customs clearance service fee line — billed on the booking invoice + * together with the freight (no separate prepaid clearance invoice). */ @Column({ name: 'is_clearance', type: 'boolean', default: false }) isClearance!: boolean; diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index 4838d3f52..b8635199d 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -25,7 +25,6 @@ export const CONTRACT_STATUSES = [ 'SIGNED_CUSTOMER', 'FULLY_EXECUTED', 'CONTRACT_ACTIVE', - 'AWAITING_CLEARANCE_PAYMENT', // Path B — clearance fee invoiced, unpaid 'AWAITING_CLEARANCE_DOCUMENTS', 'CLEARANCE_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', @@ -85,7 +84,6 @@ export type ContractKindValue = (typeof CONTRACT_KINDS)[number]; export const CONTRACT_CLEARANCE_STATUSES = [ 'NOT_APPLICABLE', - 'AWAITING_PAYMENT', // Path B — clearance service fee must be paid first 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY_FOR_BOOKING', // Path B — GL may create the booking @@ -217,10 +215,6 @@ export class Contract extends BaseEntity { @Column({ name: 'clearance_cycle_number', type: 'int', default: 0 }) clearanceCycleNumber!: number; - /** When the prepaid customs clearance service fee settled (Path B ONE_TIME). */ - @Column({ name: 'clearance_fee_paid_at', type: 'timestamptz', nullable: true }) - clearanceFeePaidAt?: Date | null; - @Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true }) pricingBreakdown?: Record | null; diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts index 03797f3b7..61a13744e 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.controller.ts @@ -54,11 +54,4 @@ export class InterchangeDocumentsController { dispute(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DisputeInterchangeDocumentDto) { return this.service.dispute(id, dto); } - - @Patch(':id/cancel') - @BookingStaff(FREIGHT_PERMS.interchangeDocuments.cancel) - @ApiOperation({ summary: 'Cancel a draft/generated interchange document' }) - cancel(@Param('id', ParseUUIDPipe) id: string) { - return this.service.cancel(id); - } } diff --git a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts index f91e942c0..e5fb5dc4d 100644 --- a/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts +++ b/apps/edr-freight-api/src/modules/interchange-documents/interchange-documents.service.ts @@ -203,11 +203,12 @@ export class InterchangeDocumentsService { async dispute(id: string, dto: DisputeInterchangeDocumentDto): Promise { const document = await this.findOne(id); - // A dispute can only be raised on a live handover — a GENERATED or already - // ACKNOWLEDGED document. CANCELLED and already-DISPUTED are terminal here. - if (!['GENERATED', 'ACKNOWLEDGED'].includes(document.status)) { + // A dispute can only be raised BEFORE the handover is acknowledged — an + // acknowledged document is settled. DISPUTED itself is terminal and + // read-only: the registered dispute cannot be re-raised or overwritten. + if (document.status !== 'GENERATED') { throw new BadRequestException( - `Interchange document in ${document.status} status cannot be disputed (must be GENERATED or ACKNOWLEDGED)`, + `Interchange document in ${document.status} status cannot be disputed (must be GENERATED — an acknowledged handover is settled, a registered dispute is read-only)`, ); } await this.dataSource.getRepository(InterchangeDocument).update(id, { @@ -217,15 +218,6 @@ export class InterchangeDocumentsService { return this.findOne(id); } - async cancel(id: string): Promise { - const document = await this.findOne(id); - if (!['DRAFT', 'GENERATED'].includes(document.status)) { - throw new BadRequestException(`Interchange document ${document.status} cannot be cancelled`); - } - await this.dataSource.getRepository(InterchangeDocument).update(id, { status: 'CANCELLED' }); - return this.findOne(id); - } - private async getScheduleSnapshot(scheduleId: string): Promise { const [schedule] = await this.dataSource.query( `SELECT ts.id, diff --git a/apps/edr-freight-api/src/modules/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 index 095123c09..640c3e26e 100644 --- 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 @@ -1,15 +1,18 @@ import { BaseEntity } from '@edr/api-common'; -import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; +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/type needs maintenance. - * Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km). + * 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']) -@Unique(['vehicleId', 'maintenanceType']) export class MaintenanceInterval extends BaseEntity { @Column({ name: 'vehicle_id', type: 'uuid' }) vehicleId!: string; @@ -21,6 +24,10 @@ export class MaintenanceInterval extends BaseEntity { @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; 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 7d3ab7a95..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; 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-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts index eded86274..d884cdf55 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { MaintenanceType } from './entities/maintenance-schedule.entity'; @@ -14,27 +14,44 @@ export class MaintenanceIntervalRepository extends BaseRepository { + /** + * 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 }, + 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' }, + 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 existing = await this.getByVehicleAndType(vehicleId, maintenanceType); + const item = serviceItem?.trim() || null; + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType, item); if (existing) { await this.intervalRepository.update(existing.id, { @@ -50,6 +67,7 @@ export class MaintenanceIntervalRepository extends BaseRepository { + 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 9324ad694..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, @@ -51,6 +56,27 @@ export class MaintenanceController { 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.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts index 8e58702ac..b417e72cf 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -61,6 +61,7 @@ export class MaintenanceRepository extends BaseRepository { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: Date; nextDueDate: Date | null; @@ -71,12 +72,15 @@ export class MaintenanceRepository extends BaseRepository { 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 DISTINCT ON (s.vehicle_id) + 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", 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 283ca5eea..dd9da55a8 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -8,7 +8,12 @@ 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() @@ -107,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, @@ -122,8 +131,12 @@ export class MaintenanceService { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); - // If completed, schedule the next maintenance based on interval - if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) { + // 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) { @@ -135,52 +148,75 @@ 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 { - // Get maintenance interval for this type 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; - // Calculate next due based on KM interval - if (interval.intervalKm && interval.intervalKm > 0) { - const nextDueKm = completedKm + Number(interval.intervalKm); + const nextDueKm = intervalKm > 0 ? completedKm + intervalKm : undefined; + const nextDueDate = + intervalDays > 0 + ? new Date(now.getTime() + intervalDays * 24 * 60 * 60 * 1000) + : undefined; - // Create next scheduled maintenance - const nextSchedule = this.scheduleRepository.create({ + 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, - description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`, + serviceItem: completed.serviceItem ?? interval.serviceItem ?? null, + description: `${label}${interval.description || completed.description} (next due: ${due})`, scheduledDate: now, nextDueKm, + nextDueDate, status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - - // Calculate next due based on date interval - if (interval.intervalDays && interval.intervalDays > 0) { - const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000); - - // If no KM-based next maintenance was created, use date-based - if (!interval.intervalKm) { - const nextSchedule = this.scheduleRepository.create({ - vehicleId: completed.vehicleId, - maintenanceType: completed.maintenanceType, - description: completed.description, - scheduledDate: now, - nextDueDate, - status: MaintenanceStatus.SCHEDULED, - }); - await this.scheduleRepository.save(nextSchedule); - } - } + }), + ); } catch (err) { this.logger.error( `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, 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/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 41971bbf1..4f9d06870 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -10,6 +10,7 @@ import { const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const; const CURRENCIES = ['USD'] as const; export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const; +export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const; export class CreateRateDto { @ApiProperty({ enum: RATE_APPLIES_TO, description: 'Friendly category the rate applies to' }) @@ -47,6 +48,15 @@ export class CreateRateDto { @IsIn([...INTERCITY_KINDS]) intercityKind?: string; + @ApiPropertyOptional({ + enum: CARGO_KINDS, + description: + 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', + }) + @IsOptional() + @IsIn([...CARGO_KINDS]) + cargoKind?: string; + @ApiPropertyOptional({ description: 'FK to yards.id — origin of the leg this rate prices. Required for base freight (bulk/container/intercity), rejected for surcharges and first/last mile.', diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts index 30241ddaa..d22bd84a7 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/list-rule-engine-query.dto.ts @@ -125,6 +125,22 @@ export class ListRatesQueryDto extends PaginationQueryDto { @IsString() @MaxLength(50) rateType?: string; + + @ApiPropertyOptional({ + description: 'Filter by rate category — comma-separated appliesTo values (e.g. "CONTAINER" or "FIRST_MILE,LAST_MILE").', + }) + @IsOptional() + @IsString() + @MaxLength(100) + appliesTo?: string; + + @ApiPropertyOptional({ + description: 'Filter by surcharge trigger — comma-separated trigger values (e.g. "CUSTOMS_CLEARANCE" or "HAZARDOUS,REEFER").', + }) + @IsOptional() + @IsString() + @MaxLength(200) + trigger?: string; } export class ListWeightLimitRulesQueryDto extends PaginationQueryDto { diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 78e2eb724..3519a0c06 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -13,6 +13,8 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity'; export function allowedRateUnits(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; + /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ + cargoKind?: 'CONTAINER' | 'BULK' | null; }): RateUnit[] { const { appliesTo, trigger } = input; @@ -29,16 +31,20 @@ export function allowedRateUnits(input: { case 'DEMURRAGE': return ['PER_CONTAINER', 'PER_TON']; case 'WITH_RETURN': - // Container-only empty-return service — bills per returned container. - return ['PER_CONTAINER', 'FLAT']; + // Container-only empty-return service — per returned container, per + // wagon the empties ride back on, or a flat fee. + return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; case 'CANCELLATION': return ['FLAT', 'PER_INVOICE']; case 'CUSTOMS_CLEARANCE': - // Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL). - return ['FLAT']; + // Sold per cargo kind: container fees bill per box or per wagon, bulk + // fees per ton or per wagon. Billed on the booking invoice. + return input.cargoKind === 'BULK' + ? ['PER_TON', 'PER_WAGON'] + : ['PER_CONTAINER', 'PER_WAGON']; case 'LASHING': - // Flat cargo-securing fee, billed once per booking. - return ['FLAT']; + // Bulk-only cargo securing — per ton or per wagon. + return ['PER_TON', 'PER_WAGON']; case 'CONSOLIDATION': return ['PER_CONTAINER', 'FLAT']; case 'SHIPPING_LINE': @@ -74,6 +80,7 @@ export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: Rate export function isRateUnitAllowed(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; + cargoKind?: 'CONTAINER' | 'BULK' | null; unit: RateUnit; }): boolean { return allowedRateUnits(input).includes(input.unit); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts index 48a948784..bdec72e46 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/rates.repository.ts @@ -117,6 +117,21 @@ export class RatesRepository implements IRatesRepository { if (query.rateType) { qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType }); } + // Category tabs on the admin page: comma-separated appliesTo / trigger + // lists, ANDed together (e.g. appliesTo=OTHER + trigger=CUSTOMS_CLEARANCE). + const csv = (v?: string) => + (v ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const appliesTo = csv(query.appliesTo); + if (appliesTo.length > 0) { + qb.andWhere('rate.appliesTo IN (:...appliesTo)', { appliesTo }); + } + const triggers = csv(query.trigger); + if (triggers.length > 0) { + qb.andWhere('rate.trigger IN (:...triggers)', { triggers }); + } if (query.search) { qb.andWhere( '(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts index 85c7db4a0..a736d4b05 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.spec.ts @@ -76,3 +76,349 @@ describe('RuleEngineService — requested service without a configured surcharge expect(result.hardBlocked[0]).toContain('reefer'); }); }); + +describe('RuleEngineService — overweight surcharge by trade direction', () => { + const baseImportRate: Rate = { + id: 'rate-import-20', + rateType: 'CONTAINER_IMPORT', + trigger: 'ALWAYS', + rateValue: 1000, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + const configuredOverweight: Rate = { + id: 'rate-ow', + rateType: 'OVERWEIGHT_PER_TON', + trigger: 'OVERWEIGHT', + rateValue: 10, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { + findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + // One 20ft at 25 t against a 20 t limit → 5 t excess. + const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection, + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 }, + ], + }); + + it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => { + const result = await service.evaluate(overweightInput('IMPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + // 1000 / (2 × 20) = 25 USD/t on 5 excess tons. + expect(ow[0].unitPriceUsd).toBe(25); + expect(ow[0].calculatedAmount).toBe(125); + expect(ow[0].triggerValue).toBe(5); + expect(ow[0].rateId).toBe(baseImportRate.id); + }); + + it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => { + const result = await service.evaluate(overweightInput('EXPORT')); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(1); + expect(ow[0].rateId).toBe(configuredOverweight.id); + // 5 excess tons × the configured 10 USD/t. + expect(ow[0].calculatedAmount).toBe(50); + expect(ow[0].unitPriceUsd).toBeUndefined(); + }); + + it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => { + const result = await service.evaluate({ + ...overweightInput('IMPORT'), + destinationYardId: 'yard-elsewhere', + }); + const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON'); + expect(ow).toHaveLength(0); + }); +}); + +describe('RuleEngineService — empty-container return per route + container type', () => { + const returnRate20: Rate = { + id: 'rate-return-20', + rateType: 'RETURN_SURCHARGE', + trigger: 'WITH_RETURN', + rateValue: 20, + rateUnit: 'PER_CONTAINER', + currency: 'USD', + status: 'LIVE', + containerTypeId: 'ct-20', + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + } as Rate; + + let service: RuleEngineService; + + beforeEach(() => { + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + }); + + const returnInput = (overrides: Partial): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + totalWagons: 1, + originYardId: 'yard-dj', + destinationYardId: 'yard-adama', + containers: [ + { + containerTypeId: 'ct-20', + quantity: 4, + vgmPerUnitTons: 10, + totalVgmTons: 40, + returnQuantity: 2, + }, + ], + ...overrides, + }); + + it('bills the route + type matched rate on the opted-in count', async () => { + const result = await service.evaluate(returnInput({})); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(result.hardBlocked).toHaveLength(0); + expect(ret).toHaveLength(1); + expect(ret[0].rateId).toBe(returnRate20.id); + expect(ret[0].triggerValue).toBe(2); + expect(ret[0].calculatedAmount).toBe(40); + expect(ret[0].billingUnit).toBe('PER_CONTAINER'); + }); + + it('hard-blocks when the booking route has no matching return rate', async () => { + const result = await service.evaluate( + returnInput({ destinationYardId: 'yard-elsewhere' }), + ); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + expect( + result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'), + ).toHaveLength(0); + }); + + it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => { + const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' })); + expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true); + }); + + it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => { + // Same service, but the return rate is sold per wagon: 4× 20ft return = + // 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20. + service = new RuleEngineService( + { findById: jest.fn().mockResolvedValue(null) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { + findActiveByContainerTypeId: jest + .fn() + .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]), + } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { + findLiveRates: jest + .fn() + .mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const result = await service.evaluate( + returnInput({ + containers: [ + { + containerTypeId: 'ct-20', + quantity: 4, + vgmPerUnitTons: 10, + totalVgmTons: 40, + returnQuantity: 4, + wagonsPerUnit: 0.5, + }, + ], + }), + ); + + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(ret).toHaveLength(1); + expect(ret[0].triggerValue).toBe(2); + expect(ret[0].calculatedAmount).toBe(40); + expect(ret[0].billingUnit).toBe('PER_WAGON'); + }); + + it('legacy booking-level flag bills every container at its type rate', async () => { + const result = await service.evaluate( + returnInput({ + withReturn: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, + ], + }), + ); + const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'); + expect(ret).toHaveLength(1); + expect(ret[0].triggerValue).toBe(4); + expect(ret[0].calculatedAmount).toBe(80); + }); +}); + +describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => { + const lashingBulkImport: Rate = { + id: 'rate-lash-bulk', + rateType: 'LASHING', + trigger: 'LASHING', + rateValue: 2, + rateUnit: 'PER_TON', + currency: 'USD', + status: 'LIVE', + containerTypeId: null, + cargoTypeId: null, + tradeDirection: 'IMPORT', + originYardId: null, + destinationYardId: null, + } as Rate; + + const buildService = (rates: Rate[]): RuleEngineService => + new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue(rates) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + + const bulkInput = (overrides: Partial = {}): BookingEvaluationInput => ({ + serviceTypeId: 'svc-1', + paymentCurrency: 'USD', + tradeDirection: 'IMPORT', + isHazardous: false, + cargoTypeId: 'cargo-sugar', + totalWagons: 0, + bulkTons: 100, + bulkWagons: 3, + containers: [], + ...overrides, + }); + + const lashingMods = (result: Awaited>) => + result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING'); + + it('bulk lashing bills per ton on the direction-matched rate', async () => { + const result = await buildService([lashingBulkImport]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods).toHaveLength(1); + expect(mods[0].triggerValue).toBe(100); + expect(mods[0].calculatedAmount).toBe(200); + expect(mods[0].billingUnit).toBe('PER_TON'); + }); + + it('a rate for the other direction never bills', async () => { + const result = await buildService([ + { ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate, + ]).evaluate(bulkInput()); + expect(lashingMods(result)).toHaveLength(0); + }); + + it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => { + const result = await buildService([ + { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate, + ]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods[0].triggerValue).toBe(3); + expect(mods[0].calculatedAmount).toBe(75); + }); + + it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => { + const result = await buildService([ + lashingBulkImport, + { ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate, + ]).evaluate(bulkInput()); + const mods = lashingMods(result); + expect(mods).toHaveLength(1); + expect(mods[0].unitPriceUsd).toBe(7); + expect(mods[0].calculatedAmount).toBe(700); + }); + + it('container bookings never incur lashing (bulk-only service)', async () => { + const result = await buildService([lashingBulkImport]).evaluate( + bulkInput({ + cargoTypeId: null, + hasLashing: true, + containers: [ + { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 }, + ], + }), + ); + expect(lashingMods(result)).toHaveLength(0); + }); + + it('no lashing charge when the cargo does not need lashing', async () => { + const service = new RuleEngineService( + { + findById: jest + .fn() + .mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }), + } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, + { findAllActive: jest.fn().mockResolvedValue([]) } as never, + { findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never, + { findById: jest.fn().mockResolvedValue(null) } as never, + {} as never, + ); + const result = await service.evaluate(bulkInput()); + expect(lashingMods(result)).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3a4c4b778..3ad97bb53 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -48,6 +48,12 @@ export interface BookingContainerEvalInput { hazardousQuantity?: number; reeferQuantity?: number; returnQuantity?: number; + /** + * Wagon fraction one container of this line occupies (40ft = 1, 20ft = 0.5). + * Lets a PER_WAGON empty-return rate bill the wagons the returned empties + * ride back on. Missing ⇒ one wagon per container. + */ + wagonsPerUnit?: number; } export interface BookingEvaluationInput { @@ -67,6 +73,12 @@ export interface BookingEvaluationInput { isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; + /** + * The booking's rail leg. Import overweight derives its per-ton price from + * this route's own container freight rate, so the engine needs the yards. + */ + originYardId?: string | null; + destinationYardId?: string | null; /** * Booking's cargo type needs EDR-provided lashing/securing (cargoType * hasLashing = true). Fires the flat LASHING surcharge. Resolved by the @@ -80,6 +92,12 @@ export interface BookingEvaluationInput { * container freight, which is scaled by container count instead. */ bulkTons?: number; + /** + * Wagons a BULK booking occupies (ceil(tons ÷ wagon capacity)), resolved by + * the pricing service. Scales PER_WAGON kind-scoped surcharges (lashing); + * 0/undefined when unknown — those charges then bill nothing. + */ + bulkWagons?: number; containers: BookingContainerEvalInput[]; } @@ -91,6 +109,15 @@ export interface AppliedCargoModifier { triggerValue: number | null; calculatedAmount: number; currency: string; + /** + * Effective per-unit USD price when it differs from the rate row's own value + * — set by derived charges (import overweight: base freight ÷ 2×limit) so + * the breakdown shows the real per-ton figure, not the base container price. + * Any modifier carrying it also bypasses frozen contract snapshots. + */ + unitPriceUsd?: number | null; + /** Display unit for a unitPriceUsd modifier (e.g. PER_TON for overweight). */ + billingUnit?: string; } export interface ContainerWeightResult { @@ -165,12 +192,16 @@ export class RuleEngineService { ...(await this.capacityViolations(input.containers, input.tradeDirection)), ); + // Per-container-line weight limit (maxVgmTons), index-aligned with + // containerWeightResults — the derived import overweight divides by it. + const lineMaxVgmTons: Array = []; for (const container of input.containers) { const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( container.containerTypeId, input.tradeDirection, ); const rule = rules[0]; + lineMaxVgmTons.push(rule ? Number(rule.maxVgmTons) : null); let isOverweight = container.isOverweight ?? false; let excess = container.overweightExcessTons ?? null; @@ -273,13 +304,6 @@ export class RuleEngineService { input.containers.some((c) => Number(c.reeferQuantity ?? 0) > 0), label: 'refrigerated (reefer) cargo', }, - { - trigger: 'WITH_RETURN', - wanted: - truthy(input.withReturn) || - input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0), - label: 'empty-container return', - }, ]; for (const svc of requestedServices) { if (svc.wanted && !surchargeRates.some((r) => r.trigger === svc.trigger)) { @@ -292,6 +316,17 @@ export class RuleEngineService { } for (const rate of surchargeRates) { + // Import overweight never bills the configured rate — its per-ton price + // derives from the route's base container freight (see below). + if (rate.trigger === 'OVERWEIGHT' && input.tradeDirection === 'IMPORT') { + continue; + } + // Empty-container return is sold per route + container type — billed by + // the route-matched block below, never by this route-agnostic loop. + if (rate.trigger === 'WITH_RETURN') continue; + // Lashing is sold per cargo kind + container type — billed by the + // kind-aware block below, never by this generic loop. + if (rate.trigger === 'LASHING') continue; const triggered = this.matchesTrigger(rate.trigger, { isHazardous: input.isHazardous, hasReefer, @@ -384,6 +419,25 @@ export class RuleEngineService { }); } + if (input.tradeDirection === 'IMPORT') { + appliedModifiers.push( + ...this.derivedImportOverweight( + input, + containerWeightResults, + lineMaxVgmTons, + liveRates, + ), + ); + } + + const withReturn = this.withReturnCharges(input, liveRates); + appliedModifiers.push(...withReturn.modifiers); + hardBlocked.push(...withReturn.blocked); + + if (hasLashing) { + appliedModifiers.push(...this.lashingCharges(input, liveRates)); + } + return { priorityScore, appliedModifiers, @@ -394,6 +448,186 @@ export class RuleEngineService { }; } + /** + * Import overweight — derived, never configured. Each overweight container + * line bills its excess tons at (its own base import freight on the booking's + * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → + * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. + * Note: derives from the LIVE route rate even for frozen-rate contract + * bookings — the frozen snapshot has no route-scoped container price to + * divide. + */ + private derivedImportOverweight( + input: BookingEvaluationInput, + weightResults: ContainerWeightResult[], + lineMaxVgmTons: Array, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + if (!input.originYardId || !input.destinationYardId) return modifiers; + + for (let i = 0; i < weightResults.length; i++) { + const wr = weightResults[i]; + const excess = Number(wr?.overweightExcessTons ?? 0); + const maxVgm = Number(lineMaxVgmTons[i] ?? 0); + if (!wr?.isOverweight || !(excess > 0) || !(maxVgm > 0)) continue; + + // Same precedence as base freight pricing: the rate scoped to this + // container type wins over the route's catch-all rate. + const onLeg = liveRates.filter( + (r) => + r.rateType === 'CONTAINER_IMPORT' && + r.currency === 'USD' && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + const base = + onLeg.find((r) => r.containerTypeId === wr.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + // No base rate → the base-freight line hard-blocks this booking anyway. + if (!base) continue; + + const perTon = Number(base.rateValue) / (2 * maxVgm); + const amount = excess * perTon; + if (!(amount > 0)) continue; + + modifiers.push({ + rateId: base.id, + surchargeCode: 'OVERWEIGHT_PER_TON', + triggerValue: excess, + calculatedAmount: amount, + currency: base.currency, + unitPriceUsd: perTon, + billingUnit: 'PER_TON', + }); + } + return modifiers; + } + + /** + * Empty-container return — sold per direction + route + container type, like + * base freight. Each container line that opted in (returnQuantity, or every + * container when only the legacy booking-level flag is set) bills the + * route-matched WITH_RETURN rate for its own container type; a line with no + * matching rate hard-blocks the booking instead of shipping the service for + * free. Rates are import-only for now, so an export booking that asks for + * return blocks too. + * ponytail: bills the LIVE route rate, not a frozen contract snapshot — one + * RETURN_SURCHARGE snapshot code can't hold per-size route prices. + */ + private withReturnCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): { modifiers: AppliedCargoModifier[]; blocked: string[] } { + const modifiers: AppliedCargoModifier[] = []; + const blocked: string[] = []; + const bookingLevel = truthy(input.withReturn); + const wanted = + bookingLevel || input.containers.some((c) => Number(c.returnQuantity ?? 0) > 0); + if (!wanted) return { modifiers, blocked }; + + const onLeg = liveRates.filter( + (r) => + r.trigger === 'WITH_RETURN' && + r.currency === 'USD' && + r.tradeDirection === input.tradeDirection && + r.originYardId === input.originYardId && + r.destinationYardId === input.destinationYardId, + ); + + for (const container of input.containers) { + const qty = + Number(container.returnQuantity ?? 0) > 0 + ? Number(container.returnQuantity) + : bookingLevel + ? Number(container.quantity || 0) + : 0; + if (!(qty > 0)) continue; + + const rate = + onLeg.find((r) => r.containerTypeId === container.containerTypeId) ?? + onLeg.find((r) => !r.containerTypeId); + if (!rate) { + blocked.push( + 'No empty-container return rate is configured for this container ' + + 'type on this route (return is import-only) — remove the return ' + + 'option or ask EDR to configure its rate for this origin → destination.', + ); + continue; + } + + const rateValue = Number(rate.rateValue); + // PER_WAGON bills the wagons the returned empties occupy (two 20ft share + // one wagon), PER_CONTAINER the boxes themselves, FLAT once per line. + const billed = + rate.rateUnit === 'PER_WAGON' + ? Math.ceil(qty * (container.wagonsPerUnit ?? 1)) + : qty; + const amount = rate.rateUnit === 'FLAT' ? rateValue : billed * rateValue; + if (!(amount > 0)) continue; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: rate.rateUnit === 'FLAT' ? qty : billed, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + } + + // Same block deduplicated — several lines missing the rate is one problem. + return { modifiers, blocked: [...new Set(blocked)] }; + } + + /** + * Cargo securing / lashing — BULK only, sold per trade direction, optionally + * narrowed to one leaf commodity (the commodity-scoped rate wins over the + * commodity-wide catch-all). Bills PER_TON × tonnage or PER_WAGON × the + * wagons the bulk occupies. Container bookings never incur lashing, and an + * unconfigured rate simply bills nothing — same leniency as hazard/reefer. + */ + private lashingCharges( + input: BookingEvaluationInput, + liveRates: Rate[], + ): AppliedCargoModifier[] { + const modifiers: AppliedCargoModifier[] = []; + if (input.containers.length > 0) return modifiers; // bulk-only service + + const onDirection = liveRates.filter( + (r) => + r.trigger === 'LASHING' && + r.currency === 'USD' && + !r.containerTypeId && + r.tradeDirection === input.tradeDirection, + ); + const rate = + (input.cargoTypeId + ? onDirection.find((r) => r.cargoTypeId === input.cargoTypeId) + : undefined) ?? onDirection.find((r) => !r.cargoTypeId); + if (!rate) return modifiers; + + const billedQty = + rate.rateUnit === 'PER_TON' + ? Math.max(0, Number(input.bulkTons ?? 0)) + : rate.rateUnit === 'PER_WAGON' + ? Math.max(0, Number(input.bulkWagons ?? 0)) + : 1; + const rateValue = Number(rate.rateValue); + const amount = rate.rateUnit === 'FLAT' ? rateValue : billedQty * rateValue; + if (!(amount > 0)) return modifiers; + modifiers.push({ + rateId: rate.id, + surchargeCode: this.surchargeCode(rate), + triggerValue: rate.rateUnit === 'FLAT' ? 1 : billedQty, + calculatedAmount: amount, + currency: rate.currency, + unitPriceUsd: rateValue, + billingUnit: rate.rateUnit, + }); + return modifiers; + } + /** * Messages for container lines whose total weight exceeds the hard capacity * ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts index 6c3adce66..be7c8984c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.spec.ts @@ -101,6 +101,23 @@ describe('RateChangeRequestsService', () => { expect(request.payload).toEqual({ rateValue: 200 }); }); + it('carries a re-routed leg — a yard-only edit is a real change', async () => { + const { service } = build({ + rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }), + }); + + const request = await service.submit({ + rateId: 'rate-1', + update: { + rateValue: 100, + originYardId: 'yard-a', + destinationYardId: 'yard-c', + }, + }); + + expect(request.payload).toEqual({ destinationYardId: 'yard-c' }); + }); + it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => { const { service } = build(); await expect( diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts index 357c67f95..36c55dad3 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rate-change-requests.service.ts @@ -33,6 +33,10 @@ const DIFFABLE_FIELDS = [ 'tradeDirection', 'containerTypeId', 'cargoTypeId', + // The leg a route-scoped rate prices. Missing here, a re-routed LIVE rate + // diffed to nothing and the submit was refused as "nothing changed". + 'originYardId', + 'destinationYardId', ] as const; /** diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 488865f38..700d6366c 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -69,18 +69,19 @@ export class RatesService { appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], requestedUnit: Rate['rateUnit'] | undefined, + cargoKind?: 'CONTAINER' | 'BULK' | null, ): Rate['rateUnit'] { // Overweight is per-ton, full stop — the admin form hides the unit field // for it and omits rateUnit from the payload entirely. if (trigger === 'OVERWEIGHT') return 'PER_TON'; - const allowed = allowedRateUnits({ appliesTo, trigger }); + const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind }); if (!requestedUnit) { throw new BadRequestException( `Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`, ); } - if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) { + if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) { throw new BadRequestException( `Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`, ); @@ -93,6 +94,19 @@ export class RatesService { return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo); } + /** + * Rates sold per direction + route. Base freight always; customs clearance + * and empty-container return are the surcharges that are too — their fee + * depends on the lane (and, for returns, the container type). + */ + private isRouteScoped(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean { + return ( + this.isBaseFreight(appliesTo, trigger) || + trigger === 'CUSTOMS_CLEARANCE' || + trigger === 'WITH_RETURN' + ); + } + /** * Which country each end of the leg must sit in, given what the rate is for. * The railway only sells three shapes: import lands at the Djibouti ports and @@ -126,7 +140,7 @@ export class RatesService { destinationYardId?: string | null; }): Promise { const { appliesTo, trigger, tradeDirection } = input; - if (!this.isBaseFreight(appliesTo, trigger)) { + if (!this.isRouteScoped(appliesTo, trigger)) { return { originYardId: null, destinationYardId: null }; } @@ -134,7 +148,7 @@ export class RatesService { const destinationYardId = input.destinationYardId ?? null; if (!originYardId || !destinationYardId) { throw new BadRequestException( - 'Base freight rates are priced per leg — pick both an origin and a destination yard.', + 'This rate is priced per leg — pick both an origin and a destination yard.', ); } if (originYardId === destinationYardId) { @@ -174,11 +188,76 @@ export class RatesService { trigger: Rate['trigger']; tradeDirection: string | null; intercityKind: string | null; + cargoKind: string | null; containerTypeId: string | null; cargoTypeId: string | null; }): void { - const { appliesTo, trigger, tradeDirection, intercityKind } = input; + const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; + if (trigger === 'CUSTOMS_CLEARANCE') { + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + ); + } + // Sold per cargo kind: a container fee names the container type it covers + // (20ft and 40ft price differently); a bulk fee carries no type at all — + // that absence is what marks it as the bulk fee. + if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { + throw new BadRequestException( + 'A customs clearance rate must say whether it covers containers or bulk.', + ); + } + if (cargoKind === 'CONTAINER' && !containerTypeId) { + throw new BadRequestException( + 'A container customs clearance rate must name the container type it covers.', + ); + } + if (cargoKind === 'BULK' && containerTypeId) { + throw new BadRequestException( + 'A bulk customs clearance rate cannot be scoped to a container type.', + ); + } + // The bulk customs fee names the commodity it covers (sugar and + // fertilizer clear differently). + if (cargoKind === 'BULK' && !cargoTypeId) { + throw new BadRequestException( + 'A bulk customs clearance rate must name the bulk cargo type it covers.', + ); + } + if (cargoKind === 'CONTAINER' && cargoTypeId) { + throw new BadRequestException( + 'A container customs clearance rate cannot be scoped to a bulk cargo type.', + ); + } + return; + } + if (trigger === 'LASHING') { + // Bulk-only cargo securing, sold per direction. May narrow to one leaf + // commodity (specific wins over the commodity-wide catch-all). + if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { + throw new BadRequestException( + 'A lashing rate must say whether it covers IMPORT or EXPORT.', + ); + } + if (containerTypeId) { + throw new BadRequestException( + 'Lashing is bulk-only — it cannot be scoped to a container type.', + ); + } + return; + } + if (trigger === 'WITH_RETURN') { + // Returning the empty box only exists on imports (the box goes back to + // the port) — export return rates are rejected until the business sells + // that. + if (tradeDirection !== 'IMPORT') { + throw new BadRequestException( + 'An empty container return rate is import-only for now.', + ); + } + return; + } if (!this.isBaseFreight(appliesTo, trigger)) return; if (appliesTo === 'INTERCITY') { @@ -247,13 +326,35 @@ export class RatesService { const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. + // Exceptions: customs clearance and empty-container return keep direction + + // container type — both are sold per lane (and per container type). const isSurcharge = trigger !== 'ALWAYS'; - const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null); - const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null); + const cargoKind = + trigger === 'CUSTOMS_CLEARANCE' + ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) + : null; + const containerTypeId = + trigger === 'WITH_RETURN' || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') + ? (dto.containerTypeId ?? null) + : isSurcharge + ? null + : (dto.containerTypeId ?? null); + const cargoTypeId = + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + trigger === 'LASHING' + ? (dto.cargoTypeId ?? null) + : isSurcharge + ? null + : (dto.cargoTypeId ?? null); // Intercity never leaves Ethiopia, so it has no trade direction to store — // its yard pair already says where it runs. const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' ? null : (dto.tradeDirection ?? null); + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + ? (dto.tradeDirection ?? null) + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : (dto.tradeDirection ?? null); const intercityKind = dto.intercityKind ?? null; this.assertScopeCoherent({ @@ -261,6 +362,7 @@ export class RatesService { trigger, tradeDirection, intercityKind, + cargoKind, containerTypeId, cargoTypeId, }); @@ -282,6 +384,7 @@ export class RatesService { appliesTo, trigger, dto.rateUnit as Rate['rateUnit'] | undefined, + cargoKind, ); await this.assertNoDuplicatePattern({ @@ -376,22 +479,42 @@ export class RatesService { if (dto.appliesTo) updates.appliesTo = appliesTo; if (dto.trigger) updates.trigger = trigger; - const containerTypeId = isSurcharge + // A patch that leaves the cargo kind unsaid keeps the one the rate already + // has — read back off its container scope (container fees carry the type). + const cargoKind = + trigger !== 'CUSTOMS_CLEARANCE' + ? null + : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? + (existing.containerTypeId ? 'CONTAINER' : 'BULK')); + + const keepsContainerType = + !isSurcharge || + trigger === 'WITH_RETURN' || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); + const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined ? dto.containerTypeId : existing.containerTypeId; - const cargoTypeId = isSurcharge + const keepsCargoType = + !isSurcharge || + (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + trigger === 'LASHING'; + const cargoTypeId = !keepsCargoType ? null : dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - isSurcharge || appliesTo === 'INTERCITY' - ? null - : dto.tradeDirection !== undefined + trigger === 'CUSTOMS_CLEARANCE' || trigger === 'WITH_RETURN' || trigger === 'LASHING' + ? dto.tradeDirection !== undefined ? dto.tradeDirection - : existing.tradeDirection; + : existing.tradeDirection + : isSurcharge || appliesTo === 'INTERCITY' + ? null + : dto.tradeDirection !== undefined + ? dto.tradeDirection + : existing.tradeDirection; updates.containerTypeId = containerTypeId ?? null; updates.cargoTypeId = cargoTypeId ?? null; @@ -407,6 +530,7 @@ export class RatesService { trigger, tradeDirection: updates.tradeDirection, intercityKind, + cargoKind, containerTypeId: updates.containerTypeId, cargoTypeId: updates.cargoTypeId, }); @@ -438,7 +562,7 @@ export class RatesService { // Re-validate the unit against the (possibly changed) shape; overweight is // forced to PER_TON. const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit; - updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit); + updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind); // Guard the pattern uniqueness for the new identity, ignoring this row. await this.assertNoDuplicatePattern({ diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index e42cbdcea..582face93 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2504,27 +2504,37 @@ export class WarehouseInventoryService { }); if (result.unloadedCount > 0) { - let document = await this.interchangeDocuments.generateFromSchedule({ - scheduleId, - direction: 'EXPORT', - handoverLocation: schedule.destinationName ?? 'Djibouti Port', - handoverFrom: 'EDR', - handoverTo: 'Djibouti Port Operator', - portOperatorName: 'Doraleh Multipurpose Port', - generatedBy: performedBy ?? 'EDR Operations', - remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', - }); - if (document.status !== 'ACKNOWLEDGED') { - document = await this.interchangeDocuments.acknowledge(document.id, { - acknowledgedBy: 'Djibouti Port Operator', - remarks: 'Auto acknowledged after Djibouti export unloading.', + // Best-effort: the unload is already committed — a paperwork failure must + // not fail the response (it did once: items unloaded, request 500'd, and + // the document only appeared after a manual retry days later). The doc + // backfills on any retry since already-unloaded items count as unloaded. + try { + let document = await this.interchangeDocuments.generateFromSchedule({ + scheduleId, + direction: 'EXPORT', + handoverLocation: schedule.destinationName ?? 'Djibouti Port', + handoverFrom: 'EDR', + handoverTo: 'Djibouti Port Operator', + portOperatorName: 'Doraleh Multipurpose Port', + generatedBy: performedBy ?? 'EDR Operations', + remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.', }); + if (document.status !== 'ACKNOWLEDGED') { + document = await this.interchangeDocuments.acknowledge(document.id, { + acknowledgedBy: 'Djibouti Port Operator', + remarks: 'Auto acknowledged after Djibouti export unloading.', + }); + } + result.interchangeDocument = { + id: document.id, + documentNo: document.documentNo, + status: document.status, + }; + } catch (err) { + this.logger.warn( + `Export interchange document generation failed for schedule ${scheduleId}: ${(err as Error).message} — rerun the Djibouti unloading to regenerate it`, + ); } - result.interchangeDocument = { - id: document.id, - documentNo: document.documentNo, - status: document.status, - }; } return result; 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 aa80c980a..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'), @@ -373,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', @@ -661,6 +676,18 @@ 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)); @@ -712,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(), ], @@ -804,9 +831,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.reviewDocuments, FREIGHT_PERMS.bookings.finalizeClearance, FREIGHT_PERMS.contracts.view, - FREIGHT_PERMS.contracts.staffAccept, - FREIGHT_PERMS.contracts.requestChanges, - FREIGHT_PERMS.contracts.reject, + ...bothFreightTypes(FREIGHT_PERMS.contracts.staffAccept), + ...bothFreightTypes(FREIGHT_PERMS.contracts.requestChanges), + ...bothFreightTypes(FREIGHT_PERMS.contracts.reject), FREIGHT_PERMS.contracts.approveLineStaff, FREIGHT_PERMS.contracts.generateContract, FREIGHT_PERMS.contracts.signStaff, diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 072c559c6..7bbf2b0cb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -416,9 +416,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { { appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" }, { appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" }, - // Cargo-securing / lashing — flat fee, billed once per booking whose - // cargo type has hasLashing = true. - { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", rateValue: 40, rateUnit: "FLAT" }, + // Cargo-securing / lashing — bulk-only, fires when the cargo type has + // hasLashing. Sold per direction; commodity-wide catch-alls seeded here, + // commodity-specific rates are configured by the rates team. + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "IMPORT", rateValue: 40, rateUnit: "PER_TON" }, + { appliesTo: "OTHER", trigger: "LASHING", rateType: "LASHING", tradeDirection: "EXPORT", rateValue: 40, rateUnit: "PER_TON" }, // ── First/last-mile road haulage (per km) — drives the mile invoices ── { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "FIRST_MILE", rateValue: 20, rateUnit: "PER_KM" }, { appliesTo: "OTHER", trigger: "ALWAYS", rateType: "LAST_MILE", rateValue: 25, rateUnit: "PER_KM" }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 04a26e3fe..bb4d024c8 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -802,8 +802,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> ("accept"); const [previewOpen, setPreviewOpen] = useState(false); @@ -97,7 +110,8 @@ export function ContractActionsToolbar({ ); } - const canAccept = status === "SUBMITTED"; + const canAccept = + status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject); // The document stays editable for the whole approval chain, but only by the // approver whose turn it is. The server resolves that against the caller's // position type; the client cannot derive it. @@ -126,35 +140,41 @@ export function ContractActionsToolbar({ {canAccept && ( <> - - - + {mayAccept && ( + + )} + {mayRequestChanges && ( + + )} + {mayReject && ( + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts index 8c3e6f071..2b84d9483 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-booking-form/total.ts @@ -65,8 +65,12 @@ export function computeGlShipmentTotal( (i) => i.containerSize === line.containerSize && i.unit === "per_container" && - !i.conditionalOn, - ) ?? rateFor((i) => i.containerSize === line.containerSize); + !i.conditionalOn && + !i.isClearance, + ) ?? + rateFor( + (i) => i.containerSize === line.containerSize && !i.isClearance, + ); if (rate) { lines.push({ label: rate.label, @@ -123,7 +127,12 @@ export function computeGlShipmentTotal( } else { const qty = q.bulkQuantity; const rate = - rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; + rateFor( + (i) => + (i.unit === "per_ton" || i.unit === "per_item") && + !i.isClearance && + !i.conditionalOn, + ) ?? items[0]; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -159,6 +168,53 @@ export function computeGlShipmentTotal( } } + // Lashing / cargo securing — bulk-only, applies whenever the contract shows + // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon + // depends on the wagon capacity the train stocks — shown at real pricing. + const lashing = items.find((i) => i.conditionalOn === "has_lashing"); + if (lashing && lashing.unit === "per_ton") { + const tons = q.bulkQuantity; + if (tons > 0) { + lines.push({ + label: lashing.label, + unitPrice: lashing.unitPrice, + unit: lashing.unit, + quantity: tons, + amount: lashing.unitPrice * tons, + }); + } + } + + // Customs clearance service fee — billed on the booking invoice with the + // freight. Container fees estimate per size (per box, or per wagon: two 20ft + // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on + // the wagon capacity the train stocks — shown at real pricing, not estimated. + for (const cl of items.filter((i) => i.isClearance)) { + let qty = 0; + if (q.isContainer) { + const boxes = q.containers + .filter((c) => c.containerSize === cl.containerSize) + .reduce((s, c) => s + Number(c.quantity || 0), 0); + qty = + cl.unit === "per_wagon" + ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) + : boxes; + } else if (cl.unit === "per_ton") { + qty = q.bulkQuantity; + } else if (cl.unit === "flat") { + qty = 1; + } + if (qty > 0) { + lines.push({ + label: cl.label, + unitPrice: cl.unitPrice, + unit: cl.unit, + quantity: qty, + amount: cl.unitPrice * qty, + }); + } + } + const total = lines.reduce((s, l) => s + l.amount, 0); return { currency, lines, total }; } @@ -167,6 +223,7 @@ export function computeGlShipmentTotal( export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { const map: Record = { per_container: "container", + per_wagon: "wagon", per_ton: "ton", per_item: "item", per_km: "km", diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index c7893659b..ff99be149 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -23,6 +23,8 @@ import { import { useState } from "react"; import { useFileViewer } from "@edr/ui-common"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { fetchViewableFile } from "@/services/files.service"; import { api } from "@/services/api"; import type { Company, CompanyChangeRequest } from "@/types/customer"; @@ -128,6 +130,8 @@ function DiffRow({ * (with note) actions, plus a short history of past decisions. */ export function ChangeRequestReview({ company }: { company: Company }) { + const { user } = useAuth(); + const canReview = hasPermission(user, FREIGHT_PERMS.customers.verify); const query = useQuery( api.customers.changeRequests.queryOptions({ input: { id: company.id } }), ); @@ -323,25 +327,30 @@ export function ChangeRequestReview({ company }: { company: Company }) { )} - - - - + {/* Reviewing the diff is `customers:view`; deciding on it is + `customers:verify`. Without it the request stays readable but + un-actionable. */} + {canReview && ( + + + + + )} )} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index 04267cc7b..112026ca6 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -11,6 +11,8 @@ import { } from "@mantine/core"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { api } from "@/services/api"; import type { @@ -286,6 +288,19 @@ export function InvoiceStatusBadge({ * regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so * an already-active profile is still managable. */ +/** + * Which permission each status write needs. Mirrors `STATUS_PERM` in the API's + * `companies.controller.ts` — approving is a different authority from + * suspending, and both go through the same endpoint. Keep the two in step. + */ +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, +}; + export function ProfileApprovalActions({ profileId, status, @@ -295,6 +310,10 @@ export function ProfileApprovalActions({ status: ProfileStatus; locked?: boolean; }) { + const { user } = useAuth(); + /** The API rejects these anyway — hide rather than offer a button that 403s. */ + const canSet = (next: ProfileStatus) => + hasPermission(user, STATUS_PERM[next]); const { mutate, isPending } = useMutation( api.customers.setProfileStatus.mutationOptions(), ); @@ -414,35 +433,41 @@ export function ProfileApprovalActions({ } if (status === "pending") { + if (!canSet("active") && !canSet("rejected")) return null; return ( <> {decisionModal} - - + {canSet("active") && ( + + )} + {canSet("rejected") && ( + + )} ); } if (status === "rejected") { + if (!canSet("active")) return null; return ( - + {canSet("active") && ( + + )} + {canSet("blacklisted") && ( + + )} ); } if (status === "blacklisted") { + if (!canSet("pending")) return null; return ( @@ -140,7 +141,8 @@ const DashboardPage = () => { diff --git a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx index 6fce0ec39..0503baad8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/OrganizationAdminsPage.tsx @@ -1,7 +1,7 @@ -import OrganizationsAdmins from "@/super-admin/components/organizationAdmins/OrganizationAdmins"; +import OrgAdminsPage from "@/super-admin/components/org-admins/OrgAdminsPage"; const OrganizationAdminsPage = () => { - return ; + return ; }; export default OrganizationAdminsPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 79e2f3b6e..35488b94f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -56,6 +56,8 @@ import { humanize, } from "@/components/customers"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { downloadBookingFile, fetchViewableFile, @@ -108,6 +110,7 @@ export default function CustomerDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { view, viewer } = useFileViewer(); + const { user } = useAuth(); const { data: company, isLoading } = useQuery( api.customers.getById.queryOptions({ @@ -180,6 +183,11 @@ export default function CustomerDetailPage() { // API's rule exactly, so no button is offered that the server would reject. const stillOnboarding = company ? isOnboardingDraft(company) : false; const canReview = company ? hasSubmittedOnboarding(company) : true; + // Workflow gate (above) AND authority: asking the customer to correct a + // document is a `customers:verify` action, so a view-only reviewer reads the + // documents but is not offered the request-change control. + const canRequestDocChange = + canReview && hasPermission(user, FREIGHT_PERMS.customers.verify); /** Document the reviewer is asking the customer to correct; null = closed. */ const [changeRequestDoc, setChangeRequestDoc] = @@ -446,7 +454,7 @@ export default function CustomerDetailPage() { > - {canReview && ( + {canRequestDocChange && ( [] = useMemo( diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 0fcb35a27..12f5181d6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -14,8 +14,10 @@ import { Text, Title, Container, + ActionIcon, + Tooltip, } from '@mantine/core'; -import { Plus } from 'lucide-react'; +import { CheckCircle2, Plus, Trash2 } from 'lucide-react'; import Breadcrumbs from '@/components/ui/Breadcrumbs'; import { useToast } from '@/hooks/use-toast'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -26,6 +28,7 @@ interface MaintenanceSchedule { id: string; vehicleId: string; maintenanceType: string; + serviceItem?: string | null; description: string; scheduledDate: string; completedDate?: string; @@ -40,6 +43,7 @@ interface DueBoardRow { vehicleId: string; plateNumber: string; maintenanceType: string; + serviceItem: string | null; description: string; scheduledDate: string; nextDueDate: string | null; @@ -50,8 +54,20 @@ interface DueBoardRow { overdue: boolean; } +interface MaintenanceInterval { + id: string; + vehicleId: string; + maintenanceType: string; + serviceItem: string | null; + intervalKm: number | null; + intervalDays: number | null; + description: string | null; + isActive: boolean; +} + const emptyForm = { maintenanceType: 'PREVENTIVE', + serviceItem: '', description: '', scheduledDate: new Date().toISOString().split('T')[0], estimatedCost: 0, @@ -59,12 +75,24 @@ const emptyForm = { notes: '', }; +const emptyIntervalForm = { + maintenanceType: 'PREVENTIVE', + serviceItem: '', + intervalKm: '' as number | '', + intervalDays: '' as number | '', + description: '', +}; + export function MaintenancePage() { const { toast } = useToast(); const queryClient = useQueryClient(); const [selectedVehicle, setSelectedVehicle] = useState(null); const [openScheduleModal, setOpenScheduleModal] = useState(false); const [formData, setFormData] = useState(emptyForm); + const [intervalForm, setIntervalForm] = useState(emptyIntervalForm); + const [completeTarget, setCompleteTarget] = useState(null); + const [completeOdometer, setCompleteOdometer] = useState(''); + const [completeCost, setCompleteCost] = useState(''); // Maintenance is driven by time AND km, not a picked-then-scheduled action — // this is the fleet-wide board of what's actually due, by date or mileage. @@ -94,7 +122,32 @@ export function MaintenancePage() { enabled: !!selectedVehicle, }); + const { data: intervals } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''), + queryFn: async () => { + if (!selectedVehicle) return []; + const res = await api.get(`/maintenance/intervals/${selectedVehicle}`); + return (res.data || []) as MaintenanceInterval[]; + }, + enabled: !!selectedVehicle, + }); + const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : []; + const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : []; + + const invalidateVehicle = () => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT }); + }; + + const onError = (err: unknown) => { + toast({ + title: 'Error', + description: + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? + 'Failed', + variant: 'destructive', + }); + }; const scheduleMutation = useMutation({ mutationFn: async () => { @@ -102,24 +155,79 @@ export function MaintenancePage() { const res = await api.post('/maintenance/schedules', { vehicleId: selectedVehicle, ...formData, + serviceItem: formData.serviceItem.trim() || undefined, }); return res.data; }, onSuccess: () => { toast({ title: 'Maintenance scheduled' }); - queryClient.invalidateQueries({ - queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), - }); + invalidateVehicle(); setOpenScheduleModal(false); setFormData(emptyForm); }, - onError: (err: any) => { - toast({ - title: 'Error', - description: err?.response?.data?.message ?? 'Failed', - variant: 'destructive', + onError, + }); + + // Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling + // of the next service when a maintenance completes with an odometer reading. + const intervalMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + const res = await api.post('/maintenance/intervals', { + vehicleId: selectedVehicle, + maintenanceType: intervalForm.maintenanceType, + serviceItem: intervalForm.serviceItem.trim() || undefined, + intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm), + intervalDays: + intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays), + description: intervalForm.description.trim() || undefined, }); + return res.data; }, + onSuccess: () => { + toast({ title: 'Interval saved' }); + invalidateVehicle(); + setIntervalForm(emptyIntervalForm); + }, + onError, + }); + + const deactivateIntervalMutation = useMutation({ + mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`), + onSuccess: () => { + toast({ title: 'Interval deactivated' }); + invalidateVehicle(); + }, + onError, + }); + + // Completion with odometer: the reading is what advances KM-based + // scheduling — the API auto-creates the next SCHEDULED item from it. + const completeMutation = useMutation({ + mutationFn: async () => { + if (!completeTarget) return; + const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, { + status: 'COMPLETED', + completedDate: new Date().toISOString(), + odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer), + actualCost: completeCost === '' ? undefined : Number(completeCost), + }); + return res.data; + }, + onSuccess: () => { + toast({ + title: 'Maintenance completed', + description: + completeOdometer === '' + ? 'No odometer recorded — next service was NOT auto-scheduled.' + : 'Next service auto-scheduled from the recorded odometer.', + }); + invalidateVehicle(); + setCompleteTarget(null); + setCompleteOdometer(''); + setCompleteCost(''); + }, + onError, }); const vehicleOptions = @@ -170,6 +278,7 @@ export function MaintenancePage() { Vehicle Type + Service Item Next Due Date Next Due Km Current Km @@ -186,6 +295,7 @@ export function MaintenancePage() { > {row.plateNumber} {row.maintenanceType} + {row.serviceItem ?? '—'} {row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'} @@ -231,50 +341,179 @@ export function MaintenancePage() { ) : ( - - - Upcoming Maintenance - - - {isLoading ? ( - Loading... - ) : upcomingList.length > 0 ? ( - - - - Type - Description - Scheduled - Est. Cost - Status - - - - {upcomingList.map((m) => ( - - {m.maintenanceType} - {m.description} - {new Date(m.scheduledDate).toLocaleDateString()} - - {m.estimatedCost != null - ? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}` - : '—'} - - - {m.status} - + <> + + + Service Intervals — drives auto-scheduling + + e.g. oil change every 10,000 km. On completion with an odometer reading, the + next service is scheduled automatically at reading + interval. + + + + + {intervalList.length > 0 && ( +
+ + + Type + Service Item + Every (km) + Every (days) + Description + + + + + {intervalList.map((i) => ( + + {i.maintenanceType} + {i.serviceItem ?? '—'} + {i.intervalKm ?? '—'} + {i.intervalDays ?? '—'} + {i.description ?? '—'} + + + deactivateIntervalMutation.mutate(i.id)} + > + + + + + + ))} + +
+ )} + + + + + + )} + /> + ( + + + {t("orgAdmins.form.nameAm")} + + + + + + + + )} + /> + + ( + + + {t("orgAdmins.form.username")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.email")} + + + + + + + + )} + /> + ( + + + {t("orgAdmins.form.phoneNumber")} + + + + + + + + )} + /> + + {!isEdit && ( + <> + ( + + + {t("organization.organization")} + + + + + + )} + /> + {(isLoadingUnits || units.length > 0 || !selectedOrgId) && ( + ( + + + {t("orgAdmins.form.unit")} + + + + + + )} + /> + )} +

+ {!isLoadingUnits && selectedOrgId && units.length === 0 + ? t("orgAdmins.add.noUnitsOrgAdmin") + : t("orgAdmins.add.inviteNote")} +

+ + )} + + {apiError && ( +
+ {apiError} +
+ )} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx new file mode 100644 index 000000000..870d75486 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/AssignExistingAdminModal.tsx @@ -0,0 +1,334 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Loader2, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { ScrollArea } from "@/shared/common/ui/scroll-area"; +import { Badge } from "@/shared/common/ui/badge"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useEmployees } from "@/user-management/hooks/useEmployees"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; + +interface AssignExistingAdminModalProps { + isOpen: boolean; + onClose: () => void; + /** org preselected in the panel (the page's current org) */ + organizationId: string; + /** user ids that are already admins of the page's org — shown disabled */ + existingAdminIds: string[]; + /** server-side error from the last assign attempt, shown inline */ + apiError: string | null; + /** unitId set → grant unit-admin of that unit instead of org-admin */ + onAssign: (userId: string, organizationId: string, unitId?: string) => void; + isAssigning: boolean; +} + +/** + * Promote an existing employee to admin. Three panels like the old + * AssignAdminDialog: pick an org (page org preselected), pick a unit (or + * none → org admin), then pick a user — unit selection also filters the + * employee list to that unit. + */ +export default function AssignExistingAdminModal({ + isOpen, + onClose, + organizationId, + existingAdminIds, + apiError, + onAssign, + isAssigning, +}: AssignExistingAdminModalProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [selectedUserId, setSelectedUserId] = useState(""); + const [search, setSearch] = useState(""); + const [orgId, setOrgId] = useState(organizationId); + // "" → org admin (all org users listed); set → unit admin of that unit + const [unitId, setUnitId] = useState(""); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 300 }, + ); + const orgs = organizationsResponse?.items ?? []; + + const { data: unitsResponse, isLoading: isLoadingUnits } = + useUnit().getList(orgId, { take: 300, skip: 0 }, isOpen); + const units = unitsResponse?.data?.items ?? []; + + const { + employeesResponseByOrg, + isLoadingEmployeesByOrg, + isErrorEmployeesByOrg, + refetchEmployeesByOrg, + } = useEmployees({ + organizationId: isOpen ? orgId : undefined, + unitId: unitId || undefined, + params: { take: 3000, skip: 0 }, + }); + + const filteredEmployees = useMemo(() => { + const query = search.trim().toLowerCase(); + const employees = employeesResponseByOrg?.items ?? []; + if (!query) return employees; + return employees.filter((employee: any) => { + const name = localizedName(employee.user?.name).toLowerCase(); + const email = employee.user?.email?.toLowerCase() ?? ""; + return name.includes(query) || email.includes(query); + }); + }, [employeesResponseByOrg, localizedName, search]); + + const selectOrg = (id: string) => { + setOrgId(id); + setUnitId(""); + setSelectedUserId(""); + }; + + const selectUnit = (id: string) => { + setUnitId(id); + setSelectedUserId(""); + }; + + const handleClose = () => { + if (isAssigning) return; + setSelectedUserId(""); + setSearch(""); + setOrgId(organizationId); + setUnitId(""); + onClose(); + }; + + // already-admin info only covers the page's org + const knownAdminIds = orgId === organizationId ? existingAdminIds : []; + + useEffect(() => { + if (isOpen) { + setOrgId(organizationId); + setUnitId(""); + setSelectedUserId(""); + setSearch(""); + } + }, [isOpen, organizationId]); + + return ( + !open && handleClose()}> + + + {t("orgAdmins.assign.title")} + + {t("orgAdmins.assign.description")} + + + +
+ {/* Step 1: organization (page org preselected) */} +
+ + {isLoadingOrgs ? ( +
+ +
+ ) : ( + +
+ {orgs.map((org) => ( + + ))} +
+
+ )} +
+ + {/* Step 2: unit (or none → org admin) */} +
+ + {isLoadingUnits ? ( +
+ +
+ ) : ( + +
+ + {units.map((unit: any) => ( + + ))} +
+
+ )} +
+ + {/* Step 3: user */} +
+ + setSearch(event.target.value)} + placeholder={t("orgAdmins.assign.searchUsers")} + /> + {isLoadingEmployeesByOrg ? ( +
+ +
+ ) : isErrorEmployeesByOrg ? ( +
+ {t("orgAdmins.assign.loadError")} + +
+ ) : ( + +
+ {filteredEmployees.map((employee: any) => { + const userId = employee.user?.id; + if (!userId) return null; + const isAlreadyAdmin = knownAdminIds.includes(userId); + return ( + + ); + })} + {filteredEmployees.length === 0 && ( +

+ {t("orgAdmins.assign.noUsersFound")} +

+ )} +
+
+ )} +
+
+ + {apiError && ( +
+ {apiError} +
+ )} + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx new file mode 100644 index 000000000..97c3bfbc6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsColumnDefn.tsx @@ -0,0 +1,228 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { + ArrowUpDown, + MoreHorizontal, + Pencil, + Send, + Trash2, + UserCheck, + UserX, +} from "lucide-react"; +import { t } from "i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/common/ui/dropdown-menu"; +import { OrgAdminUser } from "@/super-admin/hooks/useOrgAdmins"; + +export const ORG_ADMIN_ROLE_KEY = "organization_admin"; +export const UNIT_ADMIN_ROLE_KEY = "unit_admin"; + +export interface AdminRoleInfo { + isOrgAdmin: boolean; + isUnitAdmin: boolean; + unitId?: string; +} + +/** + * all-admins/:id returns users who are org admins of the org OR unit admins of + * one of its units; userRoles carries every role of the user, so match the org + * explicitly for the org-admin grant. + */ +// ponytail: unit relation isn't loaded, so a unit_admin grant from another org +// can't be told apart — acceptable, the server only returns admins of this org. +export function getAdminRoleInfo( + admin: OrgAdminUser, + selectedOrgId: string, +): AdminRoleInfo { + const roles = admin.userRoles ?? []; + const isOrgAdmin = roles.some( + (r) => + r.role?.key === ORG_ADMIN_ROLE_KEY && + r.organizationId === selectedOrgId, + ); + const unitRole = roles.find( + (r) => r.role?.key === UNIT_ADMIN_ROLE_KEY && r.unitId, + ); + return { + isOrgAdmin, + isUnitAdmin: !!unitRole, + unitId: unitRole?.unitId ?? undefined, + }; +} + +interface ColumnCallbacks { + selectedOrgId: string; + localizedName: (name?: { am?: string; en?: string }) => string; + onEdit: (admin: OrgAdminUser) => void; + onResend: (admin: OrgAdminUser) => void; + onToggleActive: (admin: OrgAdminUser) => void; + onRemove: (admin: OrgAdminUser, roleInfo: AdminRoleInfo) => void; +} + +export function getOrgAdminsColumnDefn({ + selectedOrgId, + localizedName, + onEdit, + onResend, + onToggleActive, + onRemove, +}: ColumnCallbacks): ColumnDef[] { + return [ + { + id: "name", + accessorFn: (row) => localizedName(row.name), + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+

+ {localizedName(row.original.name) || "—"} +

+

+ {row.original.username} +

+
+ ), + }, + { + id: "email", + accessorFn: (row) => row.email ?? "", + header: () => t("orgAdmins.columns.email"), + cell: ({ row }) => ( + {row.original.email || "—"} + ), + }, + { + id: "phoneNumber", + accessorFn: (row) => row.phoneNumber ?? "", + header: () => t("orgAdmins.columns.phone"), + cell: ({ row }) => ( + {row.original.phoneNumber || "—"} + ), + }, + { + id: "role", + header: () => t("orgAdmins.columns.role"), + cell: ({ row }) => { + const info = getAdminRoleInfo(row.original, selectedOrgId); + return ( +
+ {info.isOrgAdmin && ( + + {t("orgAdmins.roleOrgAdmin")} + + )} + {info.isUnitAdmin && ( + + {t("orgAdmins.roleUnitAdmin")} + + )} +
+ ); + }, + }, + { + id: "status", + accessorFn: (row) => + !row.hasSetPassword + ? "invited" + : row.isActive + ? "active" + : "inactive", + header: () => t("orgAdmins.columns.status"), + cell: ({ row }) => { + const admin = row.original; + if (!admin.hasSetPassword) { + return ( + + {t("orgAdmins.statusInvited")} + + ); + } + return admin.isActive ? ( + + {t("orgAdmins.statusActive")} + + ) : ( + + {t("orgAdmins.statusInactive")} + + ); + }, + }, + { + id: "createdAt", + accessorFn: (row) => row.createdAt ?? "", + header: () => t("orgAdmins.columns.addedOn"), + cell: ({ row }) => + row.original.createdAt + ? new Date(row.original.createdAt).toLocaleDateString() + : "—", + }, + { + id: "actions", + header: () => t("orgAdmins.columns.actions"), + enableHiding: false, + cell: ({ row }) => { + const admin = row.original; + const roleInfo = getAdminRoleInfo(admin, selectedOrgId); + return ( + + + + + + onEdit(admin)}> + + {t("orgAdmins.actions.edit")} + + {!admin.hasSetPassword && ( + onResend(admin)}> + + {t("orgAdmins.actions.resend")} + + )} + onToggleActive(admin)}> + {admin.isActive ? ( + <> + + {t("orgAdmins.actions.deactivate")} + + ) : ( + <> + + {t("orgAdmins.actions.activate")} + + )} + + + onRemove(admin, roleInfo)} + > + + {t("orgAdmins.actions.remove")} + + + + ); + }, + }, + ]; +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx new file mode 100644 index 000000000..d5844a603 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgAdminsPage.tsx @@ -0,0 +1,430 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Building2, Loader2, Plus, UserPlus, Users2 } from "lucide-react"; +import { Button } from "@/shared/common/ui/button"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; +import { Badge } from "@/shared/common/ui/badge"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { + OrgAdminUser, + useOrgAdmins, +} from "@/super-admin/hooks/useOrgAdmins"; +import { OrgPicker } from "./OrgPicker"; +import { + AdminRoleInfo, + getOrgAdminsColumnDefn, +} from "./OrgAdminsColumnDefn"; +import AdminFormModal, { AdminFormValues } from "./AdminFormModal"; +import AssignExistingAdminModal from "./AssignExistingAdminModal"; + +interface RemoveTarget { + admin: OrgAdminUser; + roleInfo: AdminRoleInfo; +} + +export default function OrgAdminsPage() { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + + const [selectedOrg, setSelectedOrg] = useState(null); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + // modals & confirms + const [formOpen, setFormOpen] = useState(false); + const [editAdmin, setEditAdmin] = useState(null); + const [assignOpen, setAssignOpen] = useState(false); + const [removeTarget, setRemoveTarget] = useState(null); + const [toggleTarget, setToggleTarget] = useState(null); + + const { + adminsResponse, + isLoading, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + } = useOrgAdmins(selectedOrg?.id, { + take: pageSize, + skip: pageIndex * pageSize, + }); + + useEffect(() => { + setPageIndex(0); + }, [selectedOrg?.id, pageSize]); + + const admins = adminsResponse?.items ?? []; + const adminCount = adminsResponse?.count ?? 0; + const existingAdminIds = useMemo( + () => admins.map((admin) => admin.id), + [admins], + ); + + const handleFormSubmit = (values: AdminFormValues) => { + if (!selectedOrg) return; + const person = { + name: values.name, + username: values.username, + email: values.email, + phoneNumber: values.phoneNumber, + }; + if (editAdmin) { + updateAdminProfile( + { id: editAdmin.id, payload: person }, + { + onSuccess: () => { + setFormOpen(false); + setEditAdmin(null); + }, + }, + ); + } else { + addAdmin( + { + organizationId: values.organizationId, + unitId: values.unitId || undefined, + ...person, + }, + { onSuccess: () => setFormOpen(false) }, + ); + } + }; + + const handleAssign = ( + userId: string, + organizationId: string, + unitId?: string, + ) => { + assignAdmin( + { organizationId, userId, unitId }, + { onSuccess: () => setAssignOpen(false) }, + ); + }; + + const handleRemoveConfirm = () => { + if (!removeTarget || !selectedOrg) return; + const { admin, roleInfo } = removeTarget; + removeAdmin( + // unit-admin-only rows go through the unit endpoint; everything else + // defaults to org removal so a role anomaly never sends unitId: undefined + !roleInfo.isOrgAdmin && roleInfo.unitId + ? { userId: admin.id, unitId: roleInfo.unitId } + : { userId: admin.id, organizationId: selectedOrg.id }, + { onSuccess: () => setRemoveTarget(null) }, + ); + }; + + const handleResend = (admin: OrgAdminUser) => { + if (!admin.email || !admin.phoneNumber) { + toast.error(t("orgAdmins.toasts.missingContact")); + return; + } + const toastId = toast.loading(t("orgAdmins.toasts.resending")); + resendInvite( + { email: admin.email, phoneNumber: admin.phoneNumber }, + { onSettled: () => toast.dismiss(toastId) }, + ); + }; + + const handleToggleConfirm = () => { + if (!toggleTarget) return; + toggleActive( + { id: toggleTarget.id, activate: !toggleTarget.isActive }, + { onSuccess: () => setToggleTarget(null) }, + ); + }; + + const columns = useMemo( + () => + getOrgAdminsColumnDefn({ + selectedOrgId: selectedOrg?.id ?? "", + localizedName: localizedName as (name?: { + am?: string; + en?: string; + }) => string, + onEdit: (admin) => { + setEditAdmin(admin); + setFormOpen(true); + }, + onResend: handleResend, + onToggleActive: setToggleTarget, + onRemove: (admin, roleInfo) => setRemoveTarget({ admin, roleInfo }), + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [selectedOrg?.id], + ); + + return ( +
+ + + + {t("orgAdmins.title")} + +

+ {t("orgAdmins.subtitle")} +

+
+ + {/* Org selector + summary */} +
+ + {selectedOrg && ( +
+ + + {t("orgAdmins.adminsCount", { count: adminCount })} + + {selectedOrg.activeEmployeeCount !== undefined && ( + + {t("orgAdmins.activeEmployees", { + count: selectedOrg.activeEmployeeCount, + })} + + )} + + {selectedOrg.status} + +
+ )} +
+ + {!selectedOrg ? ( +
+ +

+ {t("orgAdmins.selectOrgPrompt")} +

+

+ {t("orgAdmins.selectOrgPromptHint")} +

+
+ ) : ( + <> + {isError && ( +
+ {t("orgAdmins.loadError")} + +
+ )} + {!isLoading && !isError && adminCount === 0 && ( +
+ {t("orgAdmins.noAdminsHint", { + name: localizedName(selectedOrg.name), + })} +
+ )} + setPageIndex(pageIndex + 1)} + prevFunction={() => setPageIndex(Math.max(pageIndex - 1, 0))} + refresh={refetch} + isLoading={isLoading} + extraToolbar={ +
+ + +
+ } + /> + + )} +
+
+ + {/* Add / Edit modal */} + { + setFormOpen(false); + setEditAdmin(null); + clearErrors(); + }} + admin={editAdmin} + organizationId={selectedOrg?.id} + apiError={formError} + onSubmit={handleFormSubmit} + isSubmitting={isAdding || isUpdatingProfile} + /> + + {/* Assign existing employee modal */} + {selectedOrg && ( + { + setAssignOpen(false); + clearErrors(); + }} + organizationId={selectedOrg.id} + existingAdminIds={existingAdminIds} + apiError={assignError} + onAssign={handleAssign} + isAssigning={isAssigning} + /> + )} + + {/* Remove admin confirm */} + { + if (!open) { + setRemoveTarget(null); + clearErrors(); + } + }} + > + + + + {t("orgAdmins.confirmRemove.title")} + + + {t("orgAdmins.confirmRemove.description", { + name: + localizedName(removeTarget?.admin.name) || + removeTarget?.admin.email, + org: localizedName(selectedOrg?.name), + })} + + + {removeError && ( +
+ {removeError} +
+ )} + + + {t("common.cancel")} + + + {isRemoving + ? t("orgAdmins.confirmRemove.removing") + : t("orgAdmins.actions.remove")} + + +
+
+ + {/* Activate / Deactivate confirm */} + { + if (!open) { + setToggleTarget(null); + clearErrors(); + } + }} + > + + + + {toggleTarget?.isActive + ? t("orgAdmins.confirmToggle.deactivateTitle") + : t("orgAdmins.confirmToggle.activateTitle")} + + + {t("orgAdmins.confirmToggle.description", { + name: + localizedName(toggleTarget?.name) || toggleTarget?.email, + })} + + + {toggleError && ( +
+ {toggleError} +
+ )} + + + {t("common.cancel")} + + + {isToggling && } + {toggleTarget?.isActive + ? t("orgAdmins.actions.deactivate") + : t("orgAdmins.actions.activate")} + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx new file mode 100644 index 000000000..cc32d4a5d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/components/org-admins/OrgPicker.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Building2, Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shared/common/ui/button"; +import { Badge } from "@/shared/common/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/common/ui/popover"; +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from "@/shared/common/ui/command"; +import { cn } from "@/super-admin/lib/utils"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { getOrganizationsWithAdminFlag } from "@/shared/services/organizationsService"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { ORG_PICKER_KEY } from "@/super-admin/hooks/useOrgAdmins"; + +interface OrgPickerProps { + value: OrganizationDto | null; + onChange: (org: OrganizationDto) => void; +} + +/** Searchable organization combobox — server-side name search, shows admin counts. */ +export function OrgPicker({ value, onChange }: OrgPickerProps) { + const { t } = useTranslation(); + const localizedName = useLocalizedName(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebouncedValue(search, 300); + + const { + data: orgsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_PICKER_KEY, debouncedSearch], + queryFn: async () => { + const { data } = await getOrganizationsWithAdminFlag({ + take: 50, + skip: 0, + name: debouncedSearch || undefined, + }); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrganizationDto[], + }; + }, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const orgs = orgsResponse?.items ?? []; + + // default to the first org that already has admins (initial load only, + // never while the user is searching) + useEffect(() => { + if (value || debouncedSearch || !orgs.length) return; + const firstWithAdmins = orgs.find((org) => (org.adminsCount ?? 0) > 0); + if (firstWithAdmins) onChange(firstWithAdmins); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [orgsResponse]); + + return ( + + + + + + + + + {isLoading ? ( +
+ +
+ ) : isError ? ( +
+ {t("orgAdmins.pickerError")} + +
+ ) : ( + <> + {t("orgAdmins.noOrgsFound")} + {orgs.map((org) => ( + { + onChange(org); + setOpen(false); + }} + className="flex items-center justify-between gap-2" + > + + + + {localizedName(org.name)} + + + {(org.adminsCount ?? 0) > 0 ? ( + + {t("orgAdmins.adminsCount", { + count: org.adminsCount ?? 0, + })} + + ) : ( + + {t("orgAdmins.noAdmins")} + + )} + + ))} + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx deleted file mode 100644 index 2ac9ef71d..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/AssignOrgAdminDialog.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { FormEvent, useMemo, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { Check, Loader2 } from "lucide-react"; -import { useTranslation } from "react-i18next"; -import { toast } from "sonner"; - -import { Button } from "@/shared/common/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { ScrollArea } from "@/shared/common/ui/scroll-area"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { cn } from "@/super-admin/lib/utils"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { - assignOrgAdminRole, - RemoveOrAssignOrgAdminPayload, -} from "@/super-admin/services/api/userRoleService"; -import { useEmployees } from "@/user-management/hooks/useEmployees"; - -interface AssignOrgAdminDialogProps { - isOpen: boolean; - onClose: () => void; - onSuccess: () => void; -} - -export function AssignOrgAdminDialog({ - isOpen, - onClose, - onSuccess, -}: AssignOrgAdminDialogProps) { - const queryClient = useQueryClient(); - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [selectedOrg, setSelectedOrg] = useState(""); - const [selectedUser, setSelectedUser] = useState(""); - const [search, setSearch] = useState(""); - const [isAssigning, setIsAssigning] = useState(false); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 300 } - ); - - const { - employeesResponseByOrg, - isLoadingEmployeesByOrg: isLoadingEmployees, - } = useEmployees({ - organizationId: selectedOrg || undefined, - params: { take: 3000, skip: 0 }, - }); - - const filteredEmployees = useMemo(() => { - const query = search.trim().toLowerCase(); - const employees = employeesResponseByOrg?.items ?? []; - - if (!query) return employees; - - return employees.filter((employee) => { - const name = localizedName(employee.user.name).toLowerCase(); - const email = employee.user.email?.toLowerCase() ?? ""; - - return name.includes(query) || email.includes(query); - }); - }, [employeesResponseByOrg, localizedName, search]); - - const resetForm = () => { - setSelectedOrg(""); - setSelectedUser(""); - setSearch(""); - }; - - const handleClose = () => { - if (isAssigning) return; - resetForm(); - onClose(); - }; - - const handleOrganizationChange = (organizationId: string) => { - setSelectedOrg(organizationId); - setSelectedUser(""); - setSearch(""); - }; - - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - if (!selectedOrg || !selectedUser) { - toast.error(t("organization.allFieldsRequired", "All fields are required")); - return; - } - - const payload: RemoveOrAssignOrgAdminPayload = { - organizationId: selectedOrg, - userId: selectedUser, - }; - - setIsAssigning(true); - - try { - await assignOrgAdminRole(payload); - await queryClient.invalidateQueries({ - queryKey: ["organizationAdmins"], - }); - resetForm(); - onClose(); - onSuccess(); - } catch (error: any) { - toast.error(t("organization.userAssignFailed"), { - description: error?.response?.data?.message, - }); - } finally { - setIsAssigning(false); - } - }; - - return ( - { - if (!open) handleClose(); - }} - > - - - - {t( - "organization.assignAdminToOrganization", - "Assign admin to organization" - )} - - - {t( - "organization.assignOrgAdminInstructions", - "Select an organization and a user to assign as its administrator." - )} - - - -
-
-
- - {isLoadingOrgs ? ( -
- -
- ) : ( - -
- {organizationsResponse?.items?.map((organization) => ( - - ))} -
-
- )} -
- -
- - {!selectedOrg ? ( -
-

- {t("organization.selectOrganizationFirst")} -

-
- ) : isLoadingEmployees ? ( -
- -
- ) : ( -
- setSearch(event.target.value)} - placeholder={t( - "organization.searchOrganizationUsers", - "Search organization users" - )} - /> - -
- {filteredEmployees.map((employee) => ( - - ))} - {filteredEmployees.length === 0 && ( -

- {t("organization.noUsersFound")} -

- )} -
-
-
- )} -
-
- - - - - -
-
-
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx deleted file mode 100644 index 81fcaff80..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdmins.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { Plus, Loader2, UserPlus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "../../../shared/common/ui/card"; -import { toast } from "sonner"; -import { AdvancedTable } from "../../../shared/common/ui/table/AdvancedTable"; -import { OrganizationAdminsColumnDefn } from "./OrganizationAdminsColumnDefn"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { AssignOrgAdminDialog } from "./AssignOrgAdminDialog"; -import { useTranslation } from "react-i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; - -export default function OrganizationAdmins() { - const [pageIndex, setPageIndex] = useState(0); // starts at 0 - const pageSize = 10; - const { t } = useTranslation(); - const localizedName = useLocalizedName(); - const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { organizationsAdminsResponse, isLoading, isError, refetch } = - useOrganizations("Admin", { - take: pageSize, - skip: pageIndex * pageSize, - orderBy: "createdAt", - order: "createdAt:Desc", - name: searchTerm || undefined, - }); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - - const handleSearchChange = (term: string) => { - setSearchTerm(term); - setPageIndex(0); // Reset to first page when search term changes - }; - - const handleRefresh = () => { - refetch(); - }; - - if (isLoading) { - return ( -
-
- -
- {t("organization.loadingAdmins")} -
-
-
- ); - } - - if (isError) { - return ( -
-
-
- {t("organization.errorLoadingAdmins")} -
- -
-
- ); - } - - return ( - <> -
- - - - {t("organization.organizationAdmins")} - - - - string - )} - data={organizationsAdminsResponse?.items || []} - tableName="Organization Admins" - toolBarPosition="right" - refresh={handleRefresh} - onGlobalFilterChange={handleSearchChange} - disableClientFiltering={true} - extraToolbar={ -
- - - - -
- } - itemCount={organizationsAdminsResponse?.count || 0} - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> -
-
-
- - {/* Assign Admin Dialog */} - setIsAssignDialogOpen(false)} - onSuccess={() => { - refetch(); - toast.success(t("organization.adminAssignedSuccess")); - }} - /> - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx deleted file mode 100644 index 812b0cb29..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsActions.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../../../shared/common/ui/dropdown-menu"; -import { Edit, MoreHorizontal, Trash, UserCheck, UserX } from "lucide-react"; -import { Button } from "../../../shared/common/ui/button"; -import { Link } from "react-router-dom"; -import { OrganizationAdmin } from "@/super-admin/services/api/organizationAdminService"; -import { toast } from "sonner"; - -interface OrganizationAdminsActionsProps { - rowData: OrganizationAdmin; -} -const OrganizationAdminsActions: React.FC = ({ - rowData, -}) => { - const handleDeleteClick = () => { - toast.warning("Delete functionality not implemented yet"); - }; - - const handleStatusChange = (status: string) => { - toast.info(`Admin status change to ${status} not implemented yet`); - }; - - return ( - - - - - - Actions - - - - - Edit - - - {rowData.status !== "active" && ( - handleStatusChange("active")} - className="flex items-center" - > - - Activate - - )} - {rowData.status === "active" && ( - handleStatusChange("inactive")} - className="flex items-center" - > - - Deactivate - - )} - handleDeleteClick()} - className="flex items-center" - > - - Delete - - - - ); -}; - -export default OrganizationAdminsActions; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx deleted file mode 100644 index 4a1e4d33f..000000000 --- a/apps/edr-freight-web/backoffice/src/super-admin/components/organizationAdmins/OrganizationAdminsColumnDefn.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { ColumnDef } from "@tanstack/react-table"; -import { Button } from "../../../shared/common/ui/button"; -import { ArrowUpDown, Eye } from "lucide-react"; -import { OrganizationAdminsDto } from "@/shared/dto/organization/organizationDto"; -import { StatusCell } from "./StatusCell"; -import { t } from "i18next"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { Link } from "react-router-dom"; -import OrganizationAdminsActions from "./OrganizationAdminsActions"; - -export const OrganizationAdminsColumnDefn = ( - localizedName: (name?: { am?: string; en?: string }) => string, -): ColumnDef[] => { - return [ - { - accessorKey: "name", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => ( -
{localizedName(row.original.name)}
- ), - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const id = row.original.id; - return ( - - ); - }, - }, - { - accessorKey: "actions", - header: t("organization.actions") || "Actions", - cell: ({ row }) => ( -
- - - - -
- ), - }, - ]; -}; diff --git a/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts new file mode 100644 index 000000000..57c5cddc2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/super-admin/hooks/useOrgAdmins.ts @@ -0,0 +1,259 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { + assignOrganizationAdmin, + assignUnitAdmin, + fetchAllUnitAdminById, +} from "@/super-admin/services/api/organizationAdminService"; +import { + assignOrgAdminRole, + assignUnitAdminRole, + removeOrgAdminRole, + removeUnitAdminRole, +} from "@/super-admin/services/api/userRoleService"; +import { + activateUser, + deactivateUser, +} from "@/super-admin/services/api/userService"; +import { resendVerificationCode } from "@/shared/services/authService"; +import { + updateProfile, + UpdateProfilePayload, +} from "@/user-management/services/api/employeePositionsService"; + +export interface OrgAdminUserRole { + id: string; + organizationId?: string | null; + unitId?: string | null; + role?: { key?: string } | null; +} + +/** User row returned by GET /organizations/all-admins/:id (userRoles.role relation included). */ +export interface OrgAdminUser { + id: string; + name?: { am?: string; en?: string }; + username?: string; + email?: string; + phoneNumber?: string; + isActive: boolean; + hasSetPassword: boolean; + status?: string; + createdAt?: string; + userRoles?: OrgAdminUserRole[]; +} + +export const ORG_ADMINS_KEY = "orgAdmins"; +export const ORG_PICKER_KEY = "orgAdminsOrgPicker"; + +export interface RemoveAdminInput { + userId: string; + /** set for org-admin removal */ + organizationId?: string; + /** set for unit-admin removal (wins over organizationId) */ + unitId?: string; +} + +export interface AddAdminInput { + organizationId: string; + /** set → create as unit admin of this unit instead of org admin */ + unitId?: string; + name: { am: string; en: string }; + username: string; + email: string; + phoneNumber: string; +} + +export const useOrgAdmins = ( + orgId?: string, + params?: { take?: number; skip?: number }, +) => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError, getErrorMessage } = useErrorHandler(t); + + // dialog mutations surface their API errors inline, not via toast + const [formError, setFormError] = useState(null); + const [assignError, setAssignError] = useState(null); + const [removeError, setRemoveError] = useState(null); + const [toggleError, setToggleError] = useState(null); + + const inlineError = + (set: (message: string | null) => void) => async (err: unknown) => { + console.error(err); + set(await getErrorMessage(err)); + }; + + const clearErrors = () => { + setFormError(null); + setAssignError(null); + setRemoveError(null); + setToggleError(null); + }; + + const { + data: adminsResponse, + isLoading, + isError, + refetch, + } = useQuery({ + queryKey: [ORG_ADMINS_KEY, orgId, params], + queryFn: async () => { + const { data } = await fetchAllUnitAdminById(orgId as string, params); + return { + count: (data?.count ?? 0) as number, + items: (data?.items ?? []) as OrgAdminUser[], + }; + }, + enabled: !!orgId, + staleTime: 5 * 60 * 1000, + retry: false, + }); + + const invalidate = () => { + queryClient.invalidateQueries({ queryKey: [ORG_ADMINS_KEY] }); + // picker + org tables show adminsCount — keep them fresh + queryClient.invalidateQueries({ queryKey: [ORG_PICKER_KEY] }); + queryClient.invalidateQueries({ queryKey: ["organizations"] }); + queryClient.invalidateQueries({ queryKey: ["organizationAdmins"] }); + }; + + const { mutate: addAdmin, isPending: isAdding } = useMutation({ + mutationFn: async ({ + organizationId, + unitId, + ...person + }: AddAdminInput) => { + // both iam invite endpoints create the user (employee + role + + // SET_PASSWORD OTP in one tx); unitId decides the admin scope + const { data } = unitId + ? await assignUnitAdmin({ unitId, ...person }) + : await assignOrganizationAdmin({ organizationId, ...person }); + return data; + }, + onMutate: () => setFormError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.added")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + const { mutate: assignAdmin, isPending: isAssigning } = useMutation({ + mutationFn: async ({ + organizationId, + userId, + unitId, + }: { + organizationId: string; + userId: string; + /** set → grant unit-admin of this unit instead of org-admin */ + unitId?: string; + }) => { + const { data } = unitId + ? await assignUnitAdminRole({ unitId, userId }) + : await assignOrgAdminRole({ organizationId, userId }); + return data; + }, + onMutate: () => setAssignError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.assigned")); + invalidate(); + }, + onError: inlineError(setAssignError), + }); + + const { mutate: removeAdmin, isPending: isRemoving } = useMutation({ + mutationFn: async ({ userId, organizationId, unitId }: RemoveAdminInput) => { + const { data } = unitId + ? await removeUnitAdminRole({ unitId, userId }) + : await removeOrgAdminRole({ + organizationId: organizationId as string, + userId, + }); + return data; + }, + onMutate: () => setRemoveError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.removed")); + invalidate(); + }, + onError: inlineError(setRemoveError), + }); + + const { mutate: resendInvite, isPending: isResending } = useMutation({ + mutationFn: async (payload: { email: string; phoneNumber: string }) => { + const { data } = await resendVerificationCode(payload); + return data; + }, + onSuccess: () => { + toast.success(t("orgAdmins.toasts.resent")); + }, + onError: handleError, + }); + + const { mutate: toggleActive, isPending: isToggling } = useMutation({ + mutationFn: async ({ id, activate }: { id: string; activate: boolean }) => { + const { data } = activate + ? await activateUser(id) + : await deactivateUser(id); + return data; + }, + onMutate: () => setToggleError(null), + onSuccess: (_data, variables) => { + toast.success( + variables.activate + ? t("orgAdmins.toasts.activated") + : t("orgAdmins.toasts.deactivated"), + ); + invalidate(); + }, + onError: inlineError(setToggleError), + }); + + const { mutate: updateAdminProfile, isPending: isUpdatingProfile } = + useMutation({ + mutationFn: async ({ + id, + payload, + }: { + id: string; + payload: UpdateProfilePayload; + }) => { + const { data } = await updateProfile(payload, id); + return data; + }, + onMutate: () => setFormError(null), + onSuccess: () => { + toast.success(t("orgAdmins.toasts.profileUpdated")); + invalidate(); + }, + onError: inlineError(setFormError), + }); + + return { + adminsResponse, + isLoading, + isError, + refetch, + addAdmin, + isAdding, + assignAdmin, + isAssigning, + removeAdmin, + isRemoving, + resendInvite, + isResending, + toggleActive, + isToggling, + updateAdminProfile, + isUpdatingProfile, + formError, + assignError, + removeError, + toggleError, + clearErrors, + }; +}; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index 0711a4cf2..8a6edbdee 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -28,7 +28,6 @@ export const BOOKING_STATUSES = [ "CONTRACT_ACTIVE", "CONTRACT_CLOSED", // Post counter-sign document-clearance gate. - "AWAITING_CLEARANCE_PAYMENT", "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY", diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx deleted file mode 100644 index e7613d2e3..000000000 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/ActionsColumn.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/shared/common/ui/button"; -import { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, -} from "@/shared/common/ui/alert-dialog"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { t } from "i18next"; - -type ActionsColumnProps = { - row: PositionTypeDto; -}; - -const ActionsColumn: React.FC = ({ row }) => { - const navigate = useNavigate(); - const [openDialog, setOpenDialog] = useState(false); - const [deletingId, setDeletingId] = useState(null); - - const { deletePositionType } = usePositionTypes({ id: "" }); - - const handleDeleteClick = (id: string) => { - setDeletingId(id); - setOpenDialog(true); - }; - - const handleDeleteConfirm = async () => { - if (!deletingId) return; - await deletePositionType.mutateAsync(deletingId); - setOpenDialog(false); - setDeletingId(null); - }; - - return ( -
- - - - - - - - - - {t("contentManagement.delMsg")} - - {t("contentManagement.delMsg2")} - - - - { - setOpenDialog(false); - setDeletingId(null); - }} - > - {t("common.Cancel")} - - - {deletePositionType.isPending - ? t("organization.deleting") - : t("organization.delete")} - - - - -
- ); -}; - -export default ActionsColumn; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx index 8c06bf50e..f8fc0cfdd 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/CreatePositionForm.tsx @@ -1,455 +1,529 @@ -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Input } from "@/shared/common/ui/input"; -import { Button } from "@/shared/common/ui/button"; -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from "@/shared/common/ui/form"; -import { toast } from "sonner"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { useNavigate } from "react-router-dom"; -import { t } from "i18next"; -import { useAuth } from "@/shared/context/AuthContext"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { SingleSelect } from "@/shared/common/ui/single-select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; -import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; -import i18n from "@/i18n"; -import { PermissionSearch } from "./PermissionSearch"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; - -const formSchema = z.object({ - nameAm: z.string().min(2), - nameEn: z.string().min(2), - permissions: z.array(z.string()), -}); - -type FormValues = z.infer; - -export interface CreatePositionFormProps { - mode?: "create" | "edit"; - positionTypeId?: string; - initialValues?: { - nameAm: string; - nameEn: string; - unitId: string; - key?: string; - }; - onSuccess?: () => void; - onCancel?: () => void; -} - -export const CreatePositionForm = ({ - mode = "create", - positionTypeId, - initialValues, - onSuccess, - onCancel, -}: CreatePositionFormProps = {}) => { - const navigate = useNavigate(); - const { - createPositionType, - updatePositionType, - positionTypes, - isLoading: isLoadingPositionTypes, - } = usePositionTypes(); - const { user } = useAuth(); - const { getList, getById } = useUnit(); - const localizedName = useLocalizedName(); - const userOrganizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - const [selectedOrganizationId, setSelectedOrganizationId] = useState( - userOrganizationId ?? "", - ); - const [selectedUnitId, setSelectedUnitId] = useState( - initialValues?.unitId ?? "", - ); - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [copyFromPositionId, setCopyFromPositionId] = useState(""); - const [isCopying, setIsCopying] = useState(false); - const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); - const hasLoadedEditData = useRef(false); - const lang = i18n.language; - const { applications, isLoading: isLoadingApplications } = useApplications(); - const queryClient = useQueryClient(); - - const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( - "Org", - { take: 3000 }, - ); - - const { data: unitsResponse, isLoading: isLoadingUnits } = getList( - selectedOrganizationId, - { take: 3000, skip: 0 }, - ); - - const organizationOptions = useMemo( - () => - (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ - value: org.id, - label: localizedName(org.name) || org.id, - })), - [organizationsResponse, localizedName], - ); - - const unitOptions = useMemo( - () => - (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ - value: unit.id, - label: localizedName(unit.name) || unit.id, - })), - [unitsResponse, localizedName], - ); - - const { - data: editUnitResponse, - isSuccess: isUnitSuccess, - isError: isUnitError, - } = getById(initialValues?.unitId ?? ""); - - const { - data: permissionsResponse, - isSuccess: isPermissionsSuccess, - isError: isPermissionsError, - } = useQuery({ - queryKey: ["position-type-permissions", positionTypeId], - queryFn: () => - positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId!, - ), - enabled: mode === "edit" && !!positionTypeId, - }); - // Reset the selected unit when the organization changes so a unit from a - // different org can't be submitted by mistake. - useEffect(() => { - if (mode === "edit") return; - setSelectedUnitId(""); - }, [selectedOrganizationId, mode]); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - nameAm: initialValues?.nameAm ?? "", - nameEn: initialValues?.nameEn ?? "", - permissions: [], - }, - }); - - useEffect(() => { - if (mode !== "edit" || !initialValues || !positionTypeId) return; - if (hasLoadedEditData.current) return; - - const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; - const isPermissionsDone = isPermissionsSuccess || isPermissionsError; - - if (isUnitDone && isPermissionsDone) { - hasLoadedEditData.current = true; - - const unit = editUnitResponse?.data; - if (unit) { - setSelectedOrganizationId(unit.organizationId); - setSelectedUnitId(unit.id); - } else if (initialValues.unitId) { - setSelectedUnitId(initialValues.unitId); - } - - const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; - form.reset({ - nameAm: initialValues.nameAm, - nameEn: initialValues.nameEn, - permissions: ids, - }); - - setIsLoadingEditData(false); - } - }, [ - mode, - initialValues, - positionTypeId, - isUnitSuccess, - isUnitError, - isPermissionsSuccess, - isPermissionsError, - editUnitResponse, - permissionsResponse, - form, - ]); - - const handlePermissionChange = (permissionId: string, checked: boolean) => { - const currentPermissions = form.getValues("permissions"); - if (checked) { - form.setValue("permissions", [...currentPermissions, permissionId]); - } else { - form.setValue( - "permissions", - currentPermissions.filter((id) => id !== permissionId), - ); - } - }; - - const handleCopyFrom = async (positionTypeId: string) => { - setCopyFromPositionId(positionTypeId); - if (!positionTypeId) { - form.setValue("permissions", []); - return; - } - setIsCopying(true); - try { - const response = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionTypeId, - ); - const ids = response.data.items?.map((p) => p.id) ?? []; - form.setValue("permissions", ids); - } catch { - toast.error(t("contentManagement.copyPermissionsFailed")); - } finally { - setIsCopying(false); - } - }; - - const onSubmit = async (values: FormValues) => { - try { - if (!selectedUnitId) { - toast.error(t("organization.selectUnit")); - return; - } - - const payload = { - name: { - am: values.nameAm, - en: values.nameEn, - }, - key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), - unitId: selectedUnitId, - }; - - let targetId = positionTypeId; - - if (mode === "edit" && positionTypeId) { - await updatePositionType.mutateAsync({ - id: positionTypeId, - data: payload, - }); - } else { - const response = await createPositionType.mutateAsync(payload); - targetId = response.data.id; - } - - if (targetId && values.permissions.length > 0) { - await positionTypePermissionService.assignPermissionsToPositionType({ - firstId: targetId, - secondIds: values.permissions, - }); - } - - queryClient.invalidateQueries({ - queryKey: ["position-type"], - }); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ - queryKey: ["position-type-permissions"], - }); - toast.success(t("contentManagement.permissionSuccess")); - - if (onSuccess) { - onSuccess(); - } else { - navigate("/user-management/position-management"); - } - } catch { - toast.error(t("contentManagement.permissionSuccess")); - } - }; - - if (isLoadingEditData) { - return ( -
- {t("common.loading")} -
- ); - } - - return ( -
- - ( - - {t("contentManagement.englishName")} - - - - - - )} - /> - - ( - - {t("contentManagement.amharicName")} - - - - - - )} - /> - - {/* ✅ Organization (searchable, all orgs) */} -
- - -
- - {/* ✅ Unit Selector — searchable, scoped to picked org */} -
- - -
- -
- - -
- -
- - -

- {t("contentManagement.copyPermissionsHint")} -

-
- - ( - - {t("contentManagement.permission")} - - - - )} - /> - -
- - -
- - - ); -}; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@/shared/common/ui/input"; +import { Button } from "@/shared/common/ui/button"; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/shared/common/ui/form"; +import { toast } from "sonner"; +import { + invalidatePositionTypeQueries, + usePositionTypes, +} from "@/user-management/hooks/usePositionTypes"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useAuth } from "@/shared/context/AuthContext"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { SingleSelect } from "@/shared/common/ui/single-select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { useOrganizations } from "@/super-admin/hooks/useOrganizations"; +import { OrganizationDto } from "@/shared/dto/organization/organizationDto"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; +import { PermissionSearch } from "./PermissionSearch"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +export interface CreatePositionFormProps { + mode?: "create" | "edit"; + positionTypeId?: string; + initialValues?: { + nameAm: string; + nameEn: string; + unitId: string; + key?: string; + }; + onSuccess?: () => void; + onCancel?: () => void; +} + +export const CreatePositionForm = ({ + mode = "create", + positionTypeId, + initialValues, + onSuccess, + onCancel, +}: CreatePositionFormProps = {}) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + const { + createPositionType, + updatePositionType, + positionTypes, + isLoading: isLoadingPositionTypes, + isError: isErrorPositionTypes, + } = usePositionTypes(); + const { user } = useAuth(); + const { getList, getById } = useUnit(); + const localizedName = useLocalizedName(); + const userOrganizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + const [copyFromPositionId, setCopyFromPositionId] = useState(""); + const [isCopying, setIsCopying] = useState(false); + const [isLoadingEditData, setIsLoadingEditData] = useState(mode === "edit"); + const hasLoadedEditData = useRef(false); + // Permissions the position type had when the form opened. Needed because the + // API cannot represent "no permissions" (see onSubmit). + const loadedPermissionCount = useRef(0); + const { applications, isLoading: isLoadingApplications } = useApplications(); + const queryClient = useQueryClient(); + + const formSchema = useMemo( + () => + z.object({ + nameEn: z.string().trim().min(2, t("organization.englishNameRequired")), + nameAm: z.string().trim().min(2, t("organization.amharicNameRequired")), + organizationId: z.string().min(1, t("organization.organizationRequired")), + unitId: z.string().min(1, t("contentManagement.unitRequired")), + permissions: z.array(z.string()), + }), + [t], + ); + + type FormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + nameAm: initialValues?.nameAm ?? "", + nameEn: initialValues?.nameEn ?? "", + organizationId: userOrganizationId ?? "", + unitId: initialValues?.unitId ?? "", + permissions: [], + }, + }); + + const selectedOrganizationId = form.watch("organizationId"); + + const { organizationsResponse, isLoading: isLoadingOrgs } = useOrganizations( + "Org", + { take: 3000 }, + ); + + const { data: unitsResponse, isLoading: isLoadingUnits } = getList( + selectedOrganizationId, + { take: 3000, skip: 0 }, + ); + + const organizationOptions = useMemo( + () => + (organizationsResponse?.items ?? []).map((org: OrganizationDto) => ({ + value: org.id, + label: localizedName(org.name) || org.id, + })), + [organizationsResponse, localizedName], + ); + + const unitOptions = useMemo( + () => + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => ({ + value: unit.id, + label: localizedName(unit.name) || unit.id, + })), + [unitsResponse, localizedName], + ); + + const { + data: editUnitResponse, + isSuccess: isUnitSuccess, + isError: isUnitError, + } = getById(initialValues?.unitId ?? ""); + + const { + data: permissionsResponse, + isSuccess: isPermissionsSuccess, + isError: isPermissionsError, + } = useQuery({ + queryKey: ["position-type-permissions", positionTypeId], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId( + positionTypeId!, + ), + enabled: mode === "edit" && !!positionTypeId, + }); + + // A position type belongs to a unit, and a unit to an organization — IAM has + // no organizationId on the type itself and no organization-scoped route, so + // the picked org narrows the list through its units. isSystem types are the + // shared "commons" and stay available to every organization. + const orgUnitIds = useMemo( + () => + new Set( + (unitsResponse?.data?.items ?? []).map((unit: UnitDto) => unit.id), + ), + [unitsResponse], + ); + + const copyFromOptions = useMemo(() => { + if (!selectedOrganizationId) return []; + return positionTypes.filter( + (type: PositionTypeDto) => + type.id !== positionTypeId && + (type.isSystem || (!!type.unitId && orgUnitIds.has(type.unitId))), + ); + }, [positionTypes, orgUnitIds, selectedOrganizationId, positionTypeId]); + + // Reset the selected unit when the organization changes so a unit from a + // different org can't be submitted by mistake. The copy source is cleared + // too — it is scoped to the old organization. + useEffect(() => { + if (mode === "edit") return; + form.setValue("unitId", ""); + setCopyFromPositionId(""); + }, [selectedOrganizationId, mode, form]); + + useEffect(() => { + if (mode !== "edit" || !initialValues || !positionTypeId) return; + if (hasLoadedEditData.current) return; + + const isUnitDone = !initialValues.unitId || isUnitSuccess || isUnitError; + const isPermissionsDone = isPermissionsSuccess || isPermissionsError; + + if (isUnitDone && isPermissionsDone) { + hasLoadedEditData.current = true; + + const unit = editUnitResponse?.data; + const ids = permissionsResponse?.data?.items?.map((p) => p.id) ?? []; + loadedPermissionCount.current = ids.length; + + form.reset({ + nameAm: initialValues.nameAm, + nameEn: initialValues.nameEn, + organizationId: unit?.organizationId ?? userOrganizationId ?? "", + unitId: unit?.id ?? initialValues.unitId ?? "", + permissions: ids, + }); + + setIsLoadingEditData(false); + } + }, [ + mode, + initialValues, + positionTypeId, + isUnitSuccess, + isUnitError, + isPermissionsSuccess, + isPermissionsError, + editUnitResponse, + permissionsResponse, + userOrganizationId, + form, + ]); + + const handlePermissionChange = (permissionId: string, checked: boolean) => { + const currentPermissions = form.getValues("permissions"); + form.setValue( + "permissions", + checked + ? [...currentPermissions, permissionId] + : currentPermissions.filter((id) => id !== permissionId), + ); + }; + + const handleCopyFrom = async (sourcePositionTypeId: string) => { + setCopyFromPositionId(sourcePositionTypeId); + setIsCopying(true); + try { + const response = + await positionTypePermissionService.getPermissionsByPositionTypeId( + sourcePositionTypeId, + ); + const ids = response.data.items?.map((p) => p.id) ?? []; + form.setValue("permissions", ids); + } catch (error) { + handleError(error); + toast.error(t("contentManagement.copyPermissionsFailed")); + } finally { + setIsCopying(false); + } + }; + + const onSubmit = async (values: FormValues) => { + const payload = { + name: { am: values.nameAm, en: values.nameEn }, + key: values.nameEn.toLowerCase().replace(/\s+/g, "-"), + unitId: values.unitId, + }; + + // Save the position type first. If this fails nothing else runs, and the + // mutation's own onError surfaces the reason (403 for built-in types, + // conflict on the globally-unique key, ...). + let targetId = positionTypeId; + try { + if (mode === "edit" && positionTypeId) { + await updatePositionType.mutateAsync({ + id: positionTypeId, + data: payload, + }); + } else { + const response = await createPositionType.mutateAsync(payload); + targetId = response.data.id; + } + } catch { + return; // already reported by the mutation's onError + } + + // assign-seconds-for-first replaces the whole set, but an empty secondIds + // fails server-side — so "unassign everything" is not expressible. Keep the + // save and tell the user their permissions were left alone. + const mustClearAll = + values.permissions.length === 0 && loadedPermissionCount.current > 0; + + if (targetId && values.permissions.length > 0) { + try { + await positionTypePermissionService.assignPermissionsToPositionType({ + firstId: targetId, + secondIds: values.permissions, + }); + } catch (error) { + handleError(error); + invalidatePositionTypeQueries(queryClient); + toast.error(t("contentManagement.permissionsAssignFailed")); + return; + } + } + + invalidatePositionTypeQueries(queryClient); + queryClient.invalidateQueries({ queryKey: ["position-type-permissions"] }); + + if (mustClearAll) { + toast.warning(t("contentManagement.cannotClearAllPermissions")); + } else { + toast.success(t("contentManagement.permissionSuccess")); + } + + if (onSuccess) { + onSuccess(); + } else { + navigate("/user-management/position-management"); + } + }; + + if (isLoadingEditData) { + return ( +
+ {t("common.loading")} +
+ ); + } + + const selectedPermissionCount = form.watch("permissions").length; + // form.formState.isSubmitting stays true for the whole async handler, so it + // also covers the permission-assignment call that follows the save. + const isBusy = form.formState.isSubmitting || isCopying; + + const copyFromPlaceholder = !selectedOrganizationId + ? t("contentManagement.selectOrganizationToCopy") + : isCopying || isLoadingPositionTypes || isLoadingUnits + ? t("common.loading") + : isErrorPositionTypes + ? t("contentManagement.failedToLoadPositionTypes") + : t("contentManagement.selectPositionToCopy"); + + return ( +
+ + ( + + {t("contentManagement.englishName")} + + + + + + )} + /> + + ( + + {t("contentManagement.amharicName")} + + + + + + )} + /> + + {/* Organization (searchable, all orgs) */} + ( + + {t("organization.organization")} + + + + )} + /> + + {/* Unit Selector — searchable, scoped to picked org */} + ( + + {t("organization.selectUnit")} + + + + )} + /> + +
+ + +
+ +
+ + +

+ {t("contentManagement.copyPermissionsHint")} +

+
+ + ( + + + {t("contentManagement.permission")} + {selectedPermissionCount > 0 && ( + + ( + {t("contentManagement.permissionsSelected", { + count: selectedPermissionCount, + })} + ) + + )} + + + + + )} + /> + +
+ + +
+ + + ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx index e69a6eedb..befe2f7d8 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/EditPositionForm.tsx @@ -1,160 +1,193 @@ -import { useEffect, useState } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { Input } from "@/shared/common/ui/input"; -import { Label } from "@/shared/common/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { useNavigate } from "react-router-dom"; - -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { useApplications } from "@/user-management/hooks/useApplications"; -import { PermissionSearch } from "./PermissionSearch"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { t } from "i18next"; - -export const EditPositionForm = ({ id }: { id: string }) => { - const navigate = useNavigate(); - const { user } = useAuth(); - const { getList } = useUnit(); - const localizedName = useLocalizedName(); - - const { positionType, isLoadingSingle } = usePositionTypes({ id }); - - const organizationId = - user?.employee && user.employee.length > 0 - ? user.employee[0].organizationId - : undefined; - - const { applications, isLoading: isLoadingApplications } = useApplications(); - - const { data: unitsResponse } = getList(organizationId || "", { - take: 300, - skip: 0, - }); - - const [selectedApplicationId, setSelectedApplicationId] = - useState(""); - const [assignedPermissions, setAssignedPermissions] = useState< - PermissionDto[] - >([]); - const [isLoadingPermissions, setIsLoadingPermissions] = useState(false); - - useEffect(() => { - const load = async () => { - if (!positionType) return; - setIsLoadingPermissions(true); - try { - const assigned = - await positionTypePermissionService.getPermissionsByPositionTypeId( - positionType.id, - ); - setAssignedPermissions(assigned.data.items ?? []); - } finally { - setIsLoadingPermissions(false); - } - }; - load(); - }, [positionType]); - - if (isLoadingSingle) return

Loading...

; - if (!positionType) return null; - - const unit = unitsResponse?.data?.items?.find( - (u: UnitDto) => u.id === positionType.unitId, - ); - const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; - - return ( -
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - {selectedApplicationId ? ( - perm.id)} - onPermissionChange={() => { - // view-only mode in edit form - }} - applicationId={selectedApplicationId} - disabled - /> - ) : ( -
- {isLoadingPermissions ? ( -
Loading...
- ) : assignedPermissions.length === 0 ? ( -
- {t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
    - {assignedPermissions.map((perm) => ( -
  • - {localizedName(perm.name)} -
  • - ))} -
- )} -
- )} -
- -
- -
-
- ); -}; +import { useState } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { Input } from "@/shared/common/ui/input"; +import { Label } from "@/shared/common/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; + +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { positionTypePermissionService } from "@/user-management/services/api/positionTypePermissionService"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { useApplications } from "@/user-management/hooks/useApplications"; +import { PermissionSearch } from "./PermissionSearch"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { t } from "i18next"; + +export const EditPositionForm = ({ id }: { id: string }) => { + const navigate = useNavigate(); + const { user } = useAuth(); + const { getList } = useUnit(); + const localizedName = useLocalizedName(); + + const { positionType, isLoadingSingle, isErrorSingle } = usePositionTypes({ + id, + }); + + const organizationId = + user?.employee && user.employee.length > 0 + ? user.employee[0].organizationId + : undefined; + + const { applications, isLoading: isLoadingApplications } = useApplications(); + + const { data: unitsResponse } = getList(organizationId || "", { + take: 300, + skip: 0, + }); + + const [selectedApplicationId, setSelectedApplicationId] = + useState(""); + + // Shares the cache key CreatePositionForm writes under, so editing a position + // type's permissions refreshes this view too. + const { + data: assignedResponse, + isLoading: isLoadingPermissions, + isError: isErrorPermissions, + } = useQuery({ + queryKey: ["position-type-permissions", id], + queryFn: () => + positionTypePermissionService.getPermissionsByPositionTypeId(id), + enabled: !!id, + }); + + const assignedPermissions = assignedResponse?.data?.items ?? []; + + if (isLoadingSingle) { + return ( +

+ {t("common.loading")} +

+ ); + } + + if (isErrorSingle || !positionType) { + return ( +
+

+ {t("contentManagement.positionTypeNotFound")} +

+
+ +
+
+ ); + } + + const unit = unitsResponse?.data?.items?.find( + (u: UnitDto) => u.id === positionType.unitId, + ); + const unitName = unit ? unit.name.en || unit.name.am : positionType.unitId; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {selectedApplicationId ? ( + perm.id)} + onPermissionChange={() => { + // view-only mode in edit form + }} + applicationId={selectedApplicationId} + disabled + /> + ) : ( +
+ {isLoadingPermissions ? ( +
+ {t("common.loading")} +
+ ) : isErrorPermissions ? ( +
+ {t("contentManagement.failedToLoadPermissions")} +
+ ) : assignedPermissions.length === 0 ? ( +
+ {t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
    + {assignedPermissions.map((perm) => ( +
  • + {localizedName(perm.name)} +
  • + ))} +
+ )} +
+ )} +
+ +
+ +
+
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx index bfc645fc7..8d853a3b0 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PermissionSearch.tsx @@ -1,152 +1,120 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; -import { Input } from "@/shared/common/ui/input"; -import { Checkbox } from "@/shared/common/ui/checkbox"; -import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; -import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { t } from "i18next"; -import { Search, Loader2 } from "lucide-react"; - -interface PermissionSearchProps { - selectedPermissions: string[]; - onPermissionChange: (permissionId: string, checked: boolean) => void; - applicationId?: string; - disabled?: boolean; -} - -const INITIAL_TAKE = 50; // Initial number of items to fetch - -export const PermissionSearch: React.FC = ({ - selectedPermissions, - onPermissionChange, - applicationId, - disabled = false, -}) => { - const [searchTerm, setSearchTerm] = useState(""); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [take, setTake] = useState(INITIAL_TAKE); // Start with 50 - const hasSetTotalCount = useRef(false); // Track if we've set the total count - - const scrollContainerRef = useRef(null); - - const localizedName = useLocalizedName(); - - /** ------------------ 1. Debounce Search ------------------ */ - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - setTake(INITIAL_TAKE); // Reset to 50 - hasSetTotalCount.current = false; // Reset the flag - }, 300); - - return () => clearTimeout(timer); - }, [searchTerm]); - - /** ------------------ 2. Fetch Permissions ------------------ */ - const { permissions, isPermissionsLoading } = usePermissionManager({ - params: applicationId - ? { - take, - skip: 0, // Always skip 0, we fetch everything at once - search: debouncedSearchTerm || undefined, - applicationId, - } - : undefined, - }); - - /** ------------------ 3. Update take to total count after first fetch ------------------ */ - useEffect(() => { - if ( - permissions?.count && - !hasSetTotalCount.current && - take !== permissions.count - ) { - hasSetTotalCount.current = true; - setTake(permissions.count); // Fetch all items - } - }, [permissions?.count, take]); - - /** ------------------ 4. Client-side Filtering (Optional) ------------------ */ - const filteredPermissions = useMemo(() => { - if (!permissions?.items?.length) return []; - if (!searchTerm.trim()) return permissions.items; - - return permissions.items.filter((perm: PermissionDto) => { - const name = localizedName(perm.name).toLowerCase(); - const key = perm.key.toLowerCase(); - const search = searchTerm.toLowerCase(); - return name.includes(search) || key.includes(search); - }); - }, [permissions?.items, searchTerm, localizedName]); - - /** ------------------ Render ------------------ */ - return ( -
- {/* Search Input */} -
- - setSearchTerm(e.target.value)} - className="pl-10" - /> -
- - {/* Permission List Container */} - {!applicationId ? ( -
- {t("contentManagement.selectApplicationToLoadPermissions") || - "Select an application to load permissions."} -
- ) : isPermissionsLoading ? ( -
- -
- ) : ( -
- {filteredPermissions.length === 0 ? ( -
- {searchTerm - ? t("contentManagement.noPermissionsFound") - : t("contentManagement.noPermissionsAvailable")} -
- ) : ( -
- {filteredPermissions.map((perm: PermissionDto) => ( -
- { - if (!disabled) onPermissionChange(perm.id, !!checked); - }} - /> - -
- ))} -
- )} -
- )} - - {/* Footer Info */} - {filteredPermissions.length > 0 && ( -
- {t("contentManagement.showingPermissions", { - count: filteredPermissions.length, - total: permissions?.count || 0, - })} -
- )} -
- ); -}; +import React, { useState, useEffect } from "react"; +import { Input } from "@/shared/common/ui/input"; +import { Checkbox } from "@/shared/common/ui/checkbox"; +import { usePermissionManager } from "@/user-management/hooks/usePermissionManager"; +import { PermissionDto } from "@/user-management/dto/permissions/permissonDto"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { t } from "i18next"; +import { Search, Loader2 } from "lucide-react"; + +interface PermissionSearchProps { + selectedPermissions: string[]; + onPermissionChange: (permissionId: string, checked: boolean) => void; + applicationId?: string; + disabled?: boolean; +} + +// One request per application. This used to fetch 50, read `count` off the +// response and immediately refetch with take = count — two round trips on every +// mount for the same list. +const TAKE = 1000; + +export const PermissionSearch: React.FC = ({ + selectedPermissions, + onPermissionChange, + applicationId, + disabled = false, +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + + const localizedName = useLocalizedName(); + + /** ------------------ 1. Debounce Search ------------------ */ + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchTerm(searchTerm), 300); + return () => clearTimeout(timer); + }, [searchTerm]); + + /** ------------------ 2. Fetch Permissions ------------------ */ + // The API does the filtering. Filtering the result again on the *undebounced* + // term used to blank the list for 300ms on every keystroke. + const { permissions, isPermissionsLoading } = usePermissionManager({ + params: applicationId + ? { + take: TAKE, + skip: 0, + search: debouncedSearchTerm || undefined, + applicationId, + } + : undefined, + }); + + const items = permissions?.items ?? []; + + /** ------------------ Render ------------------ */ + return ( +
+ {/* Search Input */} +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {/* Permission List Container */} + {!applicationId ? ( +
+ {t("contentManagement.selectApplicationToLoadPermissions")} +
+ ) : isPermissionsLoading ? ( +
+ +
+ ) : ( +
+ {items.length === 0 ? ( +
+ {debouncedSearchTerm + ? t("contentManagement.noPermissionsFound") + : t("contentManagement.noPermissionsAvailable")} +
+ ) : ( +
+ {items.map((perm: PermissionDto) => ( +
+ { + if (!disabled) onPermissionChange(perm.id, !!checked); + }} + /> + +
+ ))} +
+ )} +
+ )} + + {/* Footer Info */} + {items.length > 0 && ( +
+ {t("contentManagement.showingPermissions", { + count: items.length, + total: permissions?.count || 0, + })} +
+ )} +
+ ); +}; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx index d15d82799..142b97f83 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionLists.tsx @@ -1,276 +1,253 @@ -import { useEffect, useState, useMemo } from "react"; -import { Button } from "@/shared/common/ui/button"; -import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; - -import { Link } from "react-router-dom"; -import { Plus } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/shared/common/ui/card"; -import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; -import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { t } from "i18next"; -import { useUnit } from "@/user-management/hooks/useUnit"; -import { useAuth } from "@/shared/context/AuthContext"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/common/ui/select"; -import { UnitDto } from "@/user-management/dto/unit/unitDto"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; - -export default function PositionManagement() { - const [pageIndex, setPageIndex] = useState(0); - const pageSize = 10; - const [isExporting, setIsExporting] = useState(false); - const [searchTerm, setSearchTerm] = useState(""); - const { createConfiguration } = usePositionTypeConfiguration(); - - const { user } = useAuth(); - - const { getAccessibleList } = useUnit(); - - const organizationId = user?.employee?.[0]?.organizationId; - - const { data: unitsResponse } = getAccessibleList(organizationId ?? "", { - take: 300, - skip: 0, - }); - - // Add state for selected unitId - // Default: if super_admin => "All", otherwise wait for units - const [selectedUnitId, setSelectedUnitId] = useState("All"); - - useEffect(() => { - // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" - if (!selectedUnitId) { - if (unitsResponse?.data?.items?.length) { - setSelectedUnitId(unitsResponse.data.items[0].id); - } else { - setSelectedUnitId("All"); - } - } - }, [unitsResponse, selectedUnitId]); - - // Reset to first page whenever the search term or unit changes so users - // land on the first page of matches instead of an empty later page. - useEffect(() => { - setPageIndex(0); - }, [searchTerm, selectedUnitId]); - - const handlePageChange = (newPage: number) => { - setPageIndex(newPage); - }; - const { - positionTypeResponse, - isLoading, - positionTypeByUnitId, - refetch, - refetchPosition, - } = usePositionTypes({ - params: { - take: 1000, - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: selectedUnitId === "All" ? undefined : selectedUnitId, - }); - - // Fetch position types without unitId for migration options - const { - positionTypeResponse: globalPositionTypes, - refetch: refetchGlobalPositionTypes, - } = usePositionTypes({ - params: { - take: 1000, // Get all global position types - skip: 0, - orderBy: "updatedAt:DESC", - }, - unitId: undefined, // Explicitly fetch position types without unitId - }); - - // Create a combined refetch function for the onDelete callback - const handlePositionTypeDeleted = async () => { - await Promise.all([ - selectedUnitId === "All" ? refetch() : refetchPosition(), - refetchGlobalPositionTypes(), - ]); - }; - const handleToggle = async ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => { - if (!selectedUnitId || selectedUnitId === "All") return; - - await createConfiguration({ - positionTypeId, - timeframe: "yearly", - organizationId: organizationId!, - canReceiveRecord: field === "canReceiveRecord" ? checked : false, - canAssignRecord: field === "canAssignRecord" ? checked : false, - canCreateBankRecord: field === "canCreateBankRecord" ? checked : false, - }); - - await handlePositionTypeDeleted(); - }; - - // Create columns with positionTypeResponse - const columns = useMemo( - () => - createPositionTypeColumns( - selectedUnitId === "All" ? positionTypeResponse : positionTypeByUnitId, - globalPositionTypes, - handlePositionTypeDeleted, - handlePositionTypeDeleted, - handleToggle, // ← pass toggle handler - selectedUnitId === "All", // ← isGlobal: hide toggle when "All" - ), - [ - selectedUnitId, - positionTypeResponse, - positionTypeByUnitId, - globalPositionTypes, - ], - ); - const allItems = useMemo( - () => - (selectedUnitId === "All" - ? positionTypeResponse?.items - : positionTypeByUnitId?.items) || [], - [selectedUnitId, positionTypeResponse?.items, positionTypeByUnitId?.items], - ); - - const filteredItems = useMemo(() => { - const trimmed = searchTerm.trim().toLowerCase(); - if (!trimmed) return allItems; - return allItems.filter((item: any) => { - const en = (item?.name?.en || "").toLowerCase(); - const am = (item?.name?.am || "").toLowerCase(); - const key = (item?.key || "").toLowerCase(); - return ( - en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) - ); - }); - }, [allItems, searchTerm]); - - const paginatedItems = useMemo(() => { - const start = pageIndex * pageSize; - return filteredItems.slice(start, start + pageSize); - }, [filteredItems, pageIndex, pageSize]); - - if (isLoading) { - return
{t("contentManagement.addUser")}
; - } - - const exportTypes = () => { - setIsExporting(true); - positionTypeService - .getAll({ - take: 3000, - }) - .then((allPositionKeys) => { - // Get the position type keys - const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); - - if (positionTypeKeys && positionTypeKeys.length > 0) { - // Convert the array of keys into a string, with each key on a new line - const fileContent = positionTypeKeys.join("\n"); - - // Create a Blob from the string content - const blob = new Blob([fileContent], { type: "text/plain" }); - - // Create a link element to trigger the download - const link = document.createElement("a"); - - // Create an object URL for the Blob - link.href = URL.createObjectURL(blob); - - // Set the download attribute with a file name - link.download = "position_keys.txt"; - - // Programmatically trigger a click on the link to start the download - link.click(); - - // Clean up by revoking the object URL - URL.revokeObjectURL(link.href); - } else { - console.error("No position type keys found."); - } - setIsExporting(false); - }); - }; - - return ( -
- - - - {t("contentManagement.permissionType")} - - - - {unitsResponse?.data?.items?.length > 0 && ( -
- - -
- )} - - - - - } - pageIndex={pageIndex} - onPageChange={handlePageChange} - nextFunction={() => handlePageChange(pageIndex + 1)} - prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} - /> - -
-
- ); -} +import { useEffect, useState, useMemo, useCallback } from "react"; +import { Button } from "@/shared/common/ui/button"; +import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable"; + +import { Link } from "react-router-dom"; +import { Plus } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { createPositionTypeColumns } from "./PositionTypeColumnDefn"; +import { positionTypeService } from "@/user-management/services/api/positionTypesService"; +import { t } from "i18next"; +import { toast } from "sonner"; +import { useUnit } from "@/user-management/hooks/useUnit"; +import { useAuth } from "@/shared/context/AuthContext"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/common/ui/select"; +import { UnitDto } from "@/user-management/dto/unit/unitDto"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +export default function PositionManagement() { + const [pageIndex, setPageIndex] = useState(0); + const pageSize = 10; + const [isExporting, setIsExporting] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + + const { user } = useAuth(); + + const { getAccessibleList } = useUnit(); + + const organizationId = user?.employee?.[0]?.organizationId; + + const { data: unitsResponse, isError: isUnitsError } = getAccessibleList( + organizationId ?? "", + { + take: 300, + skip: 0, + }, + ); + + // Add state for selected unitId + // Default: if super_admin => "All", otherwise wait for units + const [selectedUnitId, setSelectedUnitId] = useState("All"); + + useEffect(() => { + // If there’s no selectedUnitId yet, default to first unit (if any), otherwise keep "All" + if (!selectedUnitId) { + if (unitsResponse?.data?.items?.length) { + setSelectedUnitId(unitsResponse.data.items[0].id); + } else { + setSelectedUnitId("All"); + } + } + }, [unitsResponse, selectedUnitId]); + + // Reset to first page whenever the search term or unit changes so users + // land on the first page of matches instead of an empty later page. + useEffect(() => { + setPageIndex(0); + }, [searchTerm, selectedUnitId]); + + const handlePageChange = (newPage: number) => { + setPageIndex(newPage); + }; + + const showingAllUnits = selectedUnitId === "All"; + + const { + positionTypeResponse, + isLoading, + isError, + positionTypeByUnitId, + isLoadingPosition, + isErrorPosition, + refetch, + refetchPosition, + } = usePositionTypes({ + params: { + take: 1000, + skip: 0, + orderBy: "updatedAt:DESC", + }, + unitId: showingAllUnits ? undefined : selectedUnitId, + }); + + // Refresh whichever list is on screen. `positionTypeResponse` is the + // unscoped fetch, so it doubles as the migration-target source — no second + // usePositionTypes() call needed (its cache key ignores unitId, so a second + // call returned the very same query). + const handlePositionTypeChanged = useCallback(async () => { + await (showingAllUnits ? refetch() : refetchPosition()); + }, [showingAllUnits, refetch, refetchPosition]); + + const columns = useMemo( + () => + createPositionTypeColumns( + positionTypeResponse, + handlePositionTypeChanged, + handlePositionTypeChanged, + ), + [positionTypeResponse, handlePositionTypeChanged], + ); + + const allItems = useMemo( + () => + (showingAllUnits + ? positionTypeResponse?.items + : positionTypeByUnitId?.items) || [], + [showingAllUnits, positionTypeResponse?.items, positionTypeByUnitId?.items], + ); + + const filteredItems = useMemo(() => { + const trimmed = searchTerm.trim().toLowerCase(); + if (!trimmed) return allItems; + return allItems.filter((item: PositionTypeDto) => { + const en = (item?.name?.en || "").toLowerCase(); + const am = (item?.name?.am || "").toLowerCase(); + const key = (item?.key || "").toLowerCase(); + return ( + en.includes(trimmed) || am.includes(trimmed) || key.includes(trimmed) + ); + }); + }, [allItems, searchTerm]); + + const paginatedItems = useMemo(() => { + const start = pageIndex * pageSize; + return filteredItems.slice(start, start + pageSize); + }, [filteredItems, pageIndex, pageSize]); + + // Track whichever query is actually feeding the table — picking a unit used + // to leave the previous unit's rows on screen with no loading state. + const isLoadingList = showingAllUnits ? isLoading : isLoadingPosition; + const isErrorList = showingAllUnits ? isError : isErrorPosition; + + const exportTypes = () => { + setIsExporting(true); + positionTypeService + .getAll({ take: 3000 }) + .then((allPositionKeys) => { + const positionTypeKeys = allPositionKeys.data?.items?.map((p) => p.key); + + if (!positionTypeKeys?.length) { + toast.error(t("contentManagement.exportFailed")); + return; + } + + // One key per line, downloaded as a plain text file. + const blob = new Blob([positionTypeKeys.join("\n")], { + type: "text/plain", + }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "position_keys.txt"; + link.click(); + URL.revokeObjectURL(link.href); + }) + .catch(() => { + toast.error(t("contentManagement.exportFailed")); + }) + .finally(() => { + setIsExporting(false); + }); + }; + + return ( +
+ + + + {t("contentManagement.permissionType")} + + + + {!!unitsResponse?.data?.items?.length && ( +
+ + +
+ )} + {isUnitsError && ( +

+ {t("organization.errorLoadingUnits")} +

+ )} + + {isLoadingList ? ( +
+ {t("common.loading")} +
+ ) : isErrorList ? ( +
+ {t("contentManagement.failedToLoadPositionTypes")} +
+ ) : ( + + + + } + pageIndex={pageIndex} + onPageChange={handlePageChange} + nextFunction={() => handlePageChange(pageIndex + 1)} + prevFunction={() => handlePageChange(Math.max(pageIndex - 1, 0))} + /> + )} +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx index 32613a0ea..1a2b829d6 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeActions.tsx @@ -1,393 +1,251 @@ -import { useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; - -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuItem, -} from "@/shared/common/ui/dropdown-menu"; - -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/common/ui/alert-dialog"; - -import { Button } from "@/shared/common/ui/button"; -import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; -import { t } from "i18next"; -import PositionTypeMigrationModal from "./PostionTypeMigration"; -import { CreatePositionForm } from "./CreatePositionForm"; -import { toast } from "sonner"; -import { useLocalizedName } from "@/shared/common/localizedName"; -import { positionTypeService } from "@/user-management/services/api/positionTypesService"; -import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; -import { usePositionTypeConfiguration } from "@/user-management/hooks/usePostionType"; -import { Switch } from "@/shared/common/ui/switch"; -import { PositionTypeConfigurationDto } from "@/user-management/services/api/positionTypeConfigurationService"; -import { useQueryClient } from "@tanstack/react-query"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/shared/common/ui/dialog"; - -interface PositionTypeResponse { - items: PositionTypeDto[]; - count: number; -} - -type ActionsCellProps = { - row: PositionTypeDto | PositionTypeConfigurationDto; - globalPositionTypes?: PositionTypeResponse; - onDelete?: () => void | Promise; - onEdit?: () => void | Promise; - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise; - isGlobal?: boolean; // true when viewing "All" units — hide toggle -}; - -const PositionTypeActionsCell: React.FC = ({ - row, - globalPositionTypes, - onDelete, - onEdit, - onToggle, - isGlobal = false, -}) => { - const navigate = useNavigate(); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [showMigrateDialog, setShowMigrateDialog] = useState(false); - const [showEditDialog, setShowEditDialog] = useState(false); - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const localizedName = useLocalizedName(); - const { handleError } = useErrorHandler(t); - const queryClient = useQueryClient(); - // Use row.id as the positionTypeId for the configuration lookup - - const { - configurations, - isLoadingConfigurations, - updateConfiguration, - isUpdatingConfiguration, - } = usePositionTypeConfiguration( - row?.id ?? null, // 👈 pass row.id as unitId - ); - - const configItem = configurations[0]; - const isCanReceiveRecord = configItem?.canReceiveRecord ?? false; - const isCanAssignRecord = configItem?.canAssignRecord ?? false; - const isCanCreateBankRecord = configItem?.canCreateBankRecord ?? false; - - const invalidateConfig = () => { - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfigurations", row.id], - }); - queryClient.invalidateQueries({ - queryKey: ["positionTypeConfiguration", row.id], - }); - }; - - // Create position type options from globalPositionTypes - only those WITHOUT unitId - const positionTypeOptions = - globalPositionTypes?.items - .filter((item) => !item.unitId) - .map((item) => ({ - label: localizedName(item.name), - value: item.id, - })) || []; - - // Only show migrate/delete actions if current row has a unitId - const canBeModified = !!row.unitId; - - const handleView = () => { - navigate(`/user-management/position-management/edit/${row.id}`); - }; - - const handleMigrate = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowMigrateDialog(true); - }; - - const handleEdit = (e: Event) => { - e.preventDefault(); - setDropdownOpen(false); - setShowEditDialog(true); - }; - - const handleDelete = async () => { - try { - setIsDeleting(true); - await positionTypeService.delete(row.id); - toast.success(t("common.DeletedSuccessfully")); - setShowDeleteDialog(false); - if (onDelete) { - await onDelete(); - } - } catch (error) { - handleError(error); - toast.error(t("common.FailedToDelete")); - } finally { - setIsDeleting(false); - } - }; - - const handleToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canReceiveRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canReceiveRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleAssignToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canAssignRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canAssignRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - - const handleCreateBankRecordToggleChange = async (checked: boolean) => { - if (isGlobal) return; - try { - if (configItem?.id) { - await updateConfiguration({ - id: configItem.id, - payload: { - organizationId: configItem.organizationId, - positionTypeId: configItem.positionTypeId, - timeframe: configItem.timeframe, - canCreateBankRecord: checked, - }, - }); - } else { - await onToggle?.(row.id, checked, "canCreateBankRecord"); - } - toast.success(t("incomingRecord.UpdatedSuccessfully")); - invalidateConfig(); - } catch (error) { - handleError(error); - toast.error(t("incomingRecord.FailedToUpdate")); - } - }; - const rowName = "name" in row ? row.name : { am: "", en: "" }; - const isPositionType = "name" in row && "key" in row; - - return ( - <> - - - - - - { - const target = e.target as HTMLElement; - if (!target.closest('[role="dialog"]')) { - setDropdownOpen(false); - } - }}> - Actions - - {canBeModified && ( - - - {t("common.Migrate")} - - )} - - {canBeModified && isPositionType && ( - - - {t("common.Edit")} - - )} - - - - {t("common.View")} - - - {canBeModified && ( - { - setDropdownOpen(false); - setShowDeleteDialog(true); - }} - className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> - - {t("common.Delete")} - - )} - - {/* Toggle moved here from ToggleCell */} - {!isGlobal && ( -
-
- - {t("contentManagement.CanReceiveRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanAssignRecord")} - - -
-
- )} - {!isGlobal && ( -
-
- - {t("contentManagement.CanCreateBankRecord")} - - -
-
- )} -
-
- - {showMigrateDialog && ( - { - setShowMigrateDialog(false); - }} - toId={row.id} - toName={localizedName(rowName)} - positionTypeOptions={positionTypeOptions} - /> - )} - - - - - {t("common.Edit")} - -
- {isPositionType && showEditDialog && ( - { - setShowEditDialog(false); - if (onEdit) { - await onEdit(); - } - }} - onCancel={() => setShowEditDialog(false)} - /> - )} -
-
-
- - - - - {t("common.ConfirmDelete")} - - {t("common.DeleteConfirmationMessage", { - defaultValue: `Are you sure you want to delete "${localizedName(rowName)}"? This action cannot be undone.`, - })} - - - - {t("common.Cancel")} - - {isDeleting ? t("common.Deleting") : t("common.Delete")} - - - - - - ); -}; - -export default PositionTypeActionsCell; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { PositionTypeDto } from "@/user-management/dto/positions/positionType"; + +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuItem, +} from "@/shared/common/ui/dropdown-menu"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/common/ui/alert-dialog"; + +import { Button } from "@/shared/common/ui/button"; +import { MoreVertical, Edit, Eye, Trash2, Pencil } from "lucide-react"; +import { t } from "i18next"; +import PositionTypeMigrationModal from "./PostionTypeMigration"; +import { CreatePositionForm } from "./CreatePositionForm"; +import { useLocalizedName } from "@/shared/common/localizedName"; +import { usePositionTypes } from "@/user-management/hooks/usePositionTypes"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/common/ui/dialog"; + +interface PositionTypeResponse { + items: PositionTypeDto[]; + count: number; +} + +type ActionsCellProps = { + row: PositionTypeDto; + globalPositionTypes?: PositionTypeResponse; + onDelete?: () => void | Promise; + onEdit?: () => void | Promise; +}; + +/* + * TODO(record-toggles): this menu used to carry CanReceiveRecord / + * CanAssignRecord / CanCreateBankRecord switches. They never worked. IAM's + * PositionTypeConfiguration entity only has { id, organizationId, + * positionTypeId, timeframe } — verified against every local build (0.7.4 + * through 0.7.12) and the live swagger. canAssignRecord and + * canCreateBankRecord do not exist anywhere in the IAM package, and the global + * ValidationPipe runs with forbidNonWhitelisted, so every write 400'd. The + * reads were broken too: the list route filters on organizationId (the repo is + * built as TExtraCrudRepository(repo, "organizationId")) while the UI passed a + * positionTypeId, so it always came back empty. + * + * The flag that does exist is PositionConfiguration.canReceiveRecord, keyed by + * positionId — a per-position setting served by /api/position-configurations, + * not a per-position-type one. Restoring this needs either that endpoint and a + * position-level UI, or new columns on PositionTypeConfiguration in IAM. + */ +const PositionTypeActionsCell: React.FC = ({ + row, + globalPositionTypes, + onDelete, + onEdit, +}) => { + const navigate = useNavigate(); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [showMigrateDialog, setShowMigrateDialog] = useState(false); + const [showEditDialog, setShowEditDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const localizedName = useLocalizedName(); + const { deletePositionType } = usePositionTypes(); + + // Create position type options from globalPositionTypes - only those WITHOUT unitId + const positionTypeOptions = + globalPositionTypes?.items + .filter((item) => !item.unitId) + .map((item) => ({ + label: localizedName(item.name), + value: item.id, + })) || []; + + // Only show migrate/delete actions if current row has a unitId + const canBeModified = !!row.unitId; + + const handleView = () => { + navigate(`/user-management/position-management/edit/${row.id}`); + }; + + const handleMigrate = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowMigrateDialog(true); + }; + + const handleEdit = (e: Event) => { + e.preventDefault(); + setDropdownOpen(false); + setShowEditDialog(true); + }; + + // Goes through the mutation rather than the service directly, so the cache is + // invalidated and IAM's 403 for built-in types reaches the user. + const handleDelete = async () => { + try { + await deletePositionType.mutateAsync(row.id); + setShowDeleteDialog(false); + await onDelete?.(); + } catch { + // reported by the mutation's onError + } + }; + + return ( + <> + + + + + + { + const target = e.target as HTMLElement; + if (!target.closest('[role="dialog"]')) { + setDropdownOpen(false); + } + }}> + {t("userRecord.Actions")} + + {canBeModified && ( + + + {t("common.Migrate")} + + )} + + {canBeModified && ( + + + {t("common.Edit")} + + )} + + + + {t("common.View")} + + + {canBeModified && ( + { + setDropdownOpen(false); + setShowDeleteDialog(true); + }} + className="cursor-pointer hover:!text-red-500 !bg-transparent !transition-colors duration-200"> + + {t("common.Delete")} + + )} + + + + {showMigrateDialog && ( + { + setShowMigrateDialog(false); + }} + toId={row.id} + toName={localizedName(row.name)} + positionTypeOptions={positionTypeOptions} + /> + )} + + + + + {t("common.Edit")} + +
+ {showEditDialog && ( + { + setShowEditDialog(false); + if (onEdit) { + await onEdit(); + } + }} + onCancel={() => setShowEditDialog(false)} + /> + )} +
+
+
+ + + + + {t("common.ConfirmDelete")} + + {t("common.DeleteConfirmationMessage", { + name: localizedName(row.name), + })} + + + + + {t("common.Cancel")} + + + {deletePositionType.isPending + ? t("common.Deleting") + : t("common.Delete")} + + + + + + ); +}; + +export default PositionTypeActionsCell; diff --git a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx index d5fcb509e..bcd73ae3f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/components/position-management/PositionTypeColumnDefn.tsx @@ -16,16 +16,9 @@ const NameCell = ({ name }: { name: PositionTypeDto["name"] }) => { }; export const createPositionTypeColumns = ( - _positionTypeResponse?: PositionTypeResponse, globalPositionTypes?: PositionTypeResponse, onDelete?: () => void | Promise, onEdit?: () => void | Promise, - onToggle?: ( - positionTypeId: string, - checked: boolean, - field: "canReceiveRecord" | "canAssignRecord" | "canCreateBankRecord", - ) => void | Promise, - isGlobal?: boolean, ): ColumnDef[] => [ { accessorKey: "name", @@ -64,8 +57,6 @@ export const createPositionTypeColumns = ( globalPositionTypes={globalPositionTypes} onDelete={onDelete} onEdit={onEdit} - onToggle={onToggle} - isGlobal={isGlobal} /> ), }, diff --git a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts index 3f76be9f7..c2e79844f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/dto/positions/positionType.ts @@ -5,10 +5,14 @@ export interface PositionTypeDto { en: string; }; key: string; - unitId: string; - canReceiveRecord: boolean; - canCreateBankRecord?: boolean; - canAssignRecord: boolean; + /** + * Null for the built-in ("common") types, which `isSystem` marks and which + * every unit can use. IAM has no organizationId on a position type — the + * owning organization is only reachable via unit -> organizationId. + */ + unitId: string | null; + /** Built-in type. IAM rejects update/delete on these with a 403. */ + isSystem?: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts index a62ef26d6..8f102532d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/hooks/usePositionTypes.ts @@ -1,4 +1,9 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + QueryClient, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { CreatePositionTypePayload, PositionRequest, @@ -23,10 +28,22 @@ interface positionParams { interface UsePositionTypeManagerProps { id?: string; unitId?: string; - organizationId?: string; params?: positionParams; // 👈 we expected query params to be passed like this } +/** + * Every cache key this hook writes under. React Query matches key prefixes + * element by element, so `["position-type"]` does NOT reach + * `["position-types-common", ...]` — each root has to be listed. Anything that + * mutates a position type should call this rather than hand-picking keys, or + * the department pickers (which read the "-common" queries) go stale. + */ +export const invalidatePositionTypeQueries = (queryClient: QueryClient) => { + ["position-types", "position-type", "position-types-common"].forEach( + (root) => queryClient.invalidateQueries({ queryKey: [root] }), + ); +}; + export const usePositionTypes = ({ id, params = { @@ -35,11 +52,11 @@ export const usePositionTypes = ({ orderBy: "createdAt:Desc", }, unitId, - organizationId, }: UsePositionTypeManagerProps = {}) => { const queryClient = useQueryClient(); const { t } = useTranslation(); const { handleError } = useErrorHandler(t); + const invalidateAll = () => invalidatePositionTypeQueries(queryClient); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["position-types", params], queryFn: () => positionTypeService.getAll(params).then((res) => res.data), @@ -70,38 +87,6 @@ export const usePositionTypes = ({ enabled: !!unitId, }); - // Position types by organization ID - const { - data: positionTypeByOrgId, - isLoading: isLoadingOrgPosition, - isError: isErrorOrgPosition, - refetch: refetchOrgPosition, - } = useQuery({ - queryKey: ["position-type-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - - // Common types with organization ID (includes both org-specific and common types) - const { - data: commonPositionTypesByOrgId, - isLoading: isLoadingCommonOrgTypes, - isError: isErrorCommonOrgTypes, - refetch: refetchCommonOrgTypes, - } = useQuery({ - queryKey: ["position-types-common-org", organizationId, params], - queryFn: async () => { - if (!organizationId) return undefined; - const res = await positionTypeService.getCommonTypesByOrganizationId(organizationId, params); - return res.data as PositionTypesListResponse | undefined; - }, - enabled: !!organizationId, - }); - // Common types with unit ID (includes both unit-specific and common types) const { data: commonPositionTypes, @@ -123,15 +108,16 @@ export const usePositionTypes = ({ mutationFn: (payload: CreatePositionTypePayload) => positionTypeService.create(payload), onSuccess: () => { - toast.success("Position type created"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeCreated")); + invalidateAll(); }, onError: (error) => { handleError(error); }, }); - // Update + // Update. IAM answers 403 `position_type_not_allowed_to_update` for built-in + // (isSystem) types, so the error has to reach the user. const updatePositionType = useMutation({ mutationFn: ({ id, @@ -141,11 +127,12 @@ export const usePositionTypes = ({ data: UpdatePositionTypePayload; }) => positionTypeService.update(id, data), onSuccess: () => { - toast.success("Position type updated"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeUpdated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update positon from to @@ -153,30 +140,32 @@ export const usePositionTypes = ({ mutationFn: ({ toId, fromId }: { toId: string; fromId: string }) => positionTypeService.updateFromto(toId, fromId), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-to"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); //update all postions const migratePositionsByPositions = useMutation({ mutationFn: ({ id, data }: { id: string; data: PositionRequest }) => positionTypeService.updateByPostion(id, data), onSuccess: () => { - toast.success("Position type migration updated"); - queryClient.invalidateQueries({ queryKey: ["position-types-migration"] }); - queryClient.invalidateQueries({ queryKey: ["position-type", id] }); + toast.success(t("contentManagement.positionTypeMigrated")); + invalidateAll(); + }, + onError: (error) => { + handleError(error); }, - onError: () => {}, }); - // Delete + // Delete. Also 403s for built-in types. const deletePositionType = useMutation({ mutationFn: (id: string) => positionTypeService.delete(id), onSuccess: () => { - toast.success("Position type deleted"); - queryClient.invalidateQueries({ queryKey: ["position-types"] }); + toast.success(t("contentManagement.positionTypeDeleted")); + invalidateAll(); }, onError: (error) => { handleError(error); @@ -205,16 +194,6 @@ export const usePositionTypes = ({ refetchPosition, isErrorPosition, isLoadingPosition, - // organization-based position types - positionTypeByOrgId, - refetchOrgPosition, - isErrorOrgPosition, - isLoadingOrgPosition, - // common types with organization ID - commonPositionTypesByOrgId, - refetchCommonOrgTypes, - isErrorCommonOrgTypes, - isLoadingCommonOrgTypes, // common types with unit ID commonPositionTypes: commonPositionTypes?.items ?? [], isLoadingCommonTypes, diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts index 9e7e4c1d4..84782b38f 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionService.ts @@ -21,7 +21,7 @@ export interface PositionPayload { organizationId: string; parentPositionId?: string; projectId?: string; - positionTypeId: string; + positionTypeId?: string; } export interface PositionQueryParams { orderBy?: string; diff --git a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts index b3ac43ecd..450009fa7 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts +++ b/apps/edr-freight-web/backoffice/src/user-management/services/api/positionTypesService.ts @@ -40,38 +40,25 @@ export const positionTypeService = { getById: (id: string): Promise> => axiosInstance.get(`/position-types/${id}`, { headers: withHeaders() }), + // Types owned by one unit. IAM has no organization-scoped route — position + // types carry a unitId only, so scoping to an org means filtering by that + // org's units client-side. getByUnitId: ( - id: string, + unitId: string, params?: Params, ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list/${id}`, { - headers: withHeaders(), - params, - }), - - getCommonTypesByOrganizationId: ( - id: string, - params?: Params, - ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list/${unitId}`, { headers: withHeaders(), params, }), + // WHERE isSystem = true OR unitId = :unitId — "commons" means the built-in + // types, not the ones with a null unitId. getCommonTypesById: ( - id: string, + unitId: string, params: Params, ): Promise> => - axiosInstance.get(`/position-types/list-with-commons/${id}`, { + axiosInstance.get(`/position-types/list-with-commons/${unitId}`, { headers: withHeaders(), params, }), diff --git a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx index d16745327..872a82c9d 100644 --- a/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx +++ b/apps/edr-freight-web/backoffice/src/user-management/userManagement/forms/AddDepartmentForm.tsx @@ -48,8 +48,6 @@ export function AddDepartmentForm({ if (!nameAm.trim()) newErrors.nameAm = t("organization.amharicNameRequired"); if (!key.trim()) newErrors.key = t("contentManagement.keyRequired"); - if (!positionTypeId) - newErrors.positionTypeId = t("contentManagement.selectPosType"); setErrors(newErrors); return Object.keys(newErrors).length === 0; @@ -71,7 +69,7 @@ export function AddDepartmentForm({ key: key.trim().toLowerCase().replace(/\s+/g, "-"), unitId, organizationId, - positionTypeId, + ...(positionTypeId ? { positionTypeId } : {}), }; createPosition({ @@ -90,12 +88,11 @@ export function AddDepartmentForm({ return (
- + - {errors.positionTypeId && ( -

{errors.positionTypeId}

- )}
diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 019f83143..86c37585c 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -14,7 +14,6 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { PayNowButton } from "@/pages/bookings/payments/PayNowButton"; import { api } from "@/services/api"; import { ContractClearanceAction } from "./ContractClearanceAction"; @@ -70,17 +69,6 @@ export function ContractCustomerAction({ ); } - if (action.type === "pay-clearance") { - return ( - - ); - } - if (action.type === "initiate") { return ( - invoicesService.listForSource(payItemSource, payItem!.targetId), + invoicesService.listForSource("booking", payItem!.targetId), enabled: payItem !== null, }); const payableInvoiceId = payItemInvoices.find((inv) => @@ -188,7 +183,6 @@ export function ActionNeededSection({ navigate(`/contracts/${item.targetId}`); break; case "pay": - case "clearance-fee": setPayItem(item); break; case "sign": @@ -284,9 +278,7 @@ export function ActionNeededSection({ > {item.kind === "pay" ? "Pay now" - : item.kind === "clearance-fee" - ? "Pay clearance fee" - : item.kind === "duty" + : item.kind === "duty" ? "Pay duty & upload slip" : item.kind === "sign" ? "Sign" diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts index b67b11315..7be5a0aed 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/constants.ts @@ -165,19 +165,6 @@ export const STATUS_CONFIG: Record = { badgeDot: "edr-green.5", action: { label: "View", kind: "outline" }, }, - AWAITING_CLEARANCE_PAYMENT: { - stage: 3, - icon: Wallet, - iconColor: "edr-amber-text", - tile: "edr-amber-soft", - hint: "Clearance service fee due · pay to unlock document upload", - step: "edr-accent", - badgeLabel: "Clearance fee due", - badgeBg: "edr-amber-soft", - badgeText: "edr-amber-text", - badgeDot: "edr-accent", - action: { label: "Pay clearance fee", kind: "amber", icon: ArrowRight }, - }, AWAITING_DOCUMENTS: { stage: 3, icon: FileUp, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index ce218f84b..ce4a2dbea 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -1,4 +1,4 @@ -import { Group, Paper, Tabs, Text } from "@mantine/core"; +import { Group, Tabs } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; import { CreditCard, FileText, LayoutGrid } from "lucide-react"; import { useState } from "react"; @@ -12,7 +12,6 @@ import { isPayable } from "@/pages/billing/invoice-ui"; import type { Freight } from "@edr/types"; import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton"; -import { PayClearanceFeeButton } from "../payments/PayClearanceFeeButton"; import { ActivityCard } from "./components/ActivityCard"; import { ClearanceCard } from "./components/ClearanceCard"; import { DocumentsTab } from "./components/DocumentsTab"; @@ -143,8 +142,6 @@ export function ReadonlyBookingView({ const isCustoms = Boolean(booking.customsClearingEnabled); const canSelfRebook = !isCustoms; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; - // Prepaid clearance service fee gate — document upload stays locked until paid. - const isAwaitingClearanceFee = status === "AWAITING_CLEARANCE_PAYMENT"; const isClearance = [ "AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", @@ -243,28 +240,6 @@ export function ReadonlyBookingView({
- {isAwaitingClearanceFee && ( - - -
- - Customs clearance service fee due - - - Pay the clearance service fee to unlock the clearance - document upload. Global Logistics starts working on your - shipment once the fee is settled. - -
- -
-
- )} - {isClearance && } 0) { const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder); const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id; - const feeActive = status === "AWAITING_CLEARANCE_PAYMENT"; const delivered = ["COMPLETED", "DELIVERED"].includes(status); const steps: JourneyStep[] = [ { key: "booked", label: "Booking initiated", state: "done" }, - { - key: "fee", - label: "Clearance fee paid", - state: feeActive ? "active" : "done", - owner: "CUST", - }, ...sorted.map((m) => ({ key: m.id, label: m.milestoneLabel, @@ -71,7 +64,7 @@ export function buildJourneySteps( ? "done" : m.status === "SKIPPED" ? "skipped" - : !feeActive && m.id === firstPendingId + : m.id === firstPendingId ? "active" : "idle", })), @@ -79,7 +72,7 @@ export function buildJourneySteps( ]; // Every known milestone is done but the booking hasn't closed yet — the // delivery step is what's in progress. - if (!feeActive && !firstPendingId && !delivered) { + if (!firstPendingId && !delivered) { steps[steps.length - 1].state = "active"; } return steps; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index f6df38e9b..ec9b8c509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -3,7 +3,6 @@ import { useDisclosure } from "@mantine/hooks"; import { AlertCircle, ArrowRight, - CreditCard, PackagePlus, PencilLine, Upload, @@ -12,7 +11,6 @@ import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { ResubmitBookingModal } from "@/pages/bookings/resubmit/ResubmitBookingModal"; import { BookingActionModal } from "./BookingActionModal"; @@ -25,7 +23,6 @@ const ICON_BY_KIND: Record< BookingActionKind, typeof Upload > = { - PAY_CLEARANCE: CreditCard, UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, @@ -59,19 +56,6 @@ export function BookingActionButton({ if (!isChangesRequested && !action) return null; - // The prepaid clearance service fee has its own payment flow (method modal + - // provider redirect) — delegate to the self-contained pay button. - if (action?.kind === "PAY_CLEARANCE") { - return ( - - ); - } - const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; // BOOK navigates to the booking form (cargo + day + window check) — the diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 2bdd976a9..1ebb047a6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -7,7 +7,6 @@ import type { Freight } from "@edr/types"; * to operation. */ export type BookingActionKind = - | "PAY_CLEARANCE" // AWAITING_CLEARANCE_PAYMENT — pay the prepaid clearance service fee | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed @@ -24,11 +23,6 @@ export interface BookingNextAction { } const ACTION_BY_STATUS: Record = { - AWAITING_CLEARANCE_PAYMENT: { - kind: "PAY_CLEARANCE", - label: "Pay clearance fee", - title: "Pay the clearance service fee", - }, AWAITING_DOCUMENTS: { kind: "UPLOAD_DOCUMENTS", label: "Upload documents", diff --git a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx deleted file mode 100644 index 54a7e7d9d..000000000 --- a/apps/edr-freight-web/portal/src/pages/bookings/payments/PayClearanceFeeButton.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Button, type ButtonProps } from "@mantine/core"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { CreditCard } from "lucide-react"; -import { useState } from "react"; - -import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper"; -import { isPayable } from "@/pages/billing/invoice-ui"; -import { api } from "@/services/api"; -import { invoicesService } from "@/services/invoices.service"; -import { - paymentsService, - type PaymentMethod, -} from "@/services/payments.service"; -import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal"; - -/** - * Payment flow for the prepaid customs clearance service fee. The fee is its - * own `clearance`-source invoice — sourceId is the contract id (ONE_TIME, - * contract status AWAITING_CLEARANCE_PAYMENT) or the booking id (GENERAL - * shipment request, booking status AWAITING_CLEARANCE_PAYMENT). Paying it - * unlocks the clearance document upload; same modal + provider redirect as - * booking payment. - */ -export function useClearanceFeePayment(sourceId: string) { - const [modalOpen, setModalOpen] = useState(false); - - const { data: invoices = [] } = useQuery({ - queryKey: ["clearance-invoices", sourceId], - queryFn: () => invoicesService.listForSource("clearance", sourceId), - enabled: Boolean(sourceId), - }); - const payableInvoice = invoices.find((inv) => isPayable(inv.status)) ?? null; - - const mutation = useMutation({ - mutationFn: (method: PaymentMethod) => { - if (!payableInvoice) { - throw new Error( - "No payable clearance-fee invoice found yet. Please refresh or contact support.", - ); - } - return api.invoices.pay.call({ - id: payableInvoice.id, - payload: { method, platform: "web" }, - }); - }, - onSuccess: (data, method) => { - const redirectUrl = - data?.clientAction?.type === "REDIRECT" && data.clientAction.url - ? data.clientAction.url - : paymentsService.checkoutUrlForInvoice({ - invoiceId: payableInvoice!.id, - method, - }); - window.location.href = redirectUrl; - }, - }); - - const close = () => { - if (!mutation.isPending) { - setModalOpen(false); - mutation.reset(); - } - }; - - return { - invoice: payableInvoice, - modalOpen, - open: () => setModalOpen(true), - close, - processing: mutation.isPending, - error: mutation.isError - ? mutation.error instanceof Error - ? mutation.error.message - : "Could not start payment. Please try again." - : null, - confirm: (method: PaymentMethod) => mutation.mutate(method), - }; -} - -interface PayClearanceFeeButtonProps { - /** Contract id (ONE_TIME) or booking id (GENERAL shipment) the fee bills. */ - sourceId: string; - /** Fallback currency while the invoice is loading. */ - currency?: string; - label?: string; - size?: ButtonProps["size"]; - fullWidth?: boolean; -} - -/** Self-contained "Pay clearance fee" action — modal in place, no navigation. */ -export function PayClearanceFeeButton({ - sourceId, - currency, - label = "Pay clearance fee", - size = "xs", - fullWidth, -}: PayClearanceFeeButtonProps) { - const pay = useClearanceFeePayment(sourceId); - - return ( - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index f6c3098a0..fe9209c1d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -72,7 +72,6 @@ import { ContractClearancePanel } from "./ContractClearancePanel"; import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner"; import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel"; import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction"; -import { PayClearanceFeeButton } from "@/pages/bookings/payments/PayClearanceFeeButton"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { getContractBookingAction } from "./contract-booking-action"; import { closedWindowMessage, hasOpenWindow } from "./booking-window"; @@ -438,9 +437,6 @@ export default function ContractDetailPage() { // clearance is finalized. const canUploadClearance = CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized; - // Prepaid clearance service fee gate (Path B) — the document step stays - // locked until the fee invoice settles. - const awaitingClearanceFee = contract.status === "AWAITING_CLEARANCE_PAYMENT"; return ( @@ -574,13 +570,6 @@ export default function ContractDetailPage() { Global Logistics is creating your booking )} - {awaitingClearanceFee && ( - - )} {canUploadClearance && ( + + + + {/* Step 0 — Setup: operation, contract, service, currency, miles. */} {step === 0 && ( @@ -964,8 +993,8 @@ export default function NewContractPage({ )} {item.isClearance && ( - Paid in advance, before clearance — not part of your - shipment booking invoice + Customs service fee — billed on your shipment booking + invoice together with the freight )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index ee67237f5..7028943df 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -125,10 +125,6 @@ export const CONTRACT_STATUS_CONFIG: Record< FULLY_EXECUTED: { label: "Fully Executed", ...TONE.success }, CONTRACT_ACTIVE: { label: "Active", ...TONE.success }, // ── Path B pre-booking clearance (contract-level) ── - AWAITING_CLEARANCE_PAYMENT: { - label: "Clearance Fee Due", - ...TONE.warning, - }, AWAITING_CLEARANCE_DOCUMENTS: { label: "Upload Clearance Docs", ...TONE.warning, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/unit-rates.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/unit-rates.ts index 439f4eeff..f37c90a62 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/unit-rates.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/unit-rates.ts @@ -4,6 +4,7 @@ import type { Freight } from "@edr/types"; export function formatRateUnit(unit: Freight.ContractRateUnit | string): string { const map: Record = { per_container: "container", + per_wagon: "wagon", per_ton: "ton", per_item: "item", per_km: "km", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts index d0ed44629..b4055bb1c 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/total.ts @@ -48,8 +48,12 @@ export function computeShipmentTotal( (i) => i.containerSize === line.containerSize && i.unit === "per_container" && - !i.conditionalOn, - ) ?? rateFor((i) => i.containerSize === line.containerSize); + !i.conditionalOn && + !i.isClearance, + ) ?? + rateFor( + (i) => i.containerSize === line.containerSize && !i.isClearance, + ); if (rate) { lines.push({ label: rate.label, @@ -106,7 +110,12 @@ export function computeShipmentTotal( } else { const qty = Number(values.cargoWeightTons || values.itemCount || 0); const rate = - rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0]; + rateFor( + (i) => + (i.unit === "per_ton" || i.unit === "per_item") && + !i.isClearance && + !i.conditionalOn, + ) ?? items[0]; if (rate && qty > 0) { lines.push({ label: rate.label, @@ -144,6 +153,53 @@ export function computeShipmentTotal( } } + // Lashing / cargo securing — bulk-only, applies whenever the contract shows + // it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon + // depends on the wagon capacity the train stocks — shown at real pricing. + const lashing = items.find((i) => i.conditionalOn === "has_lashing"); + if (lashing && lashing.unit === "per_ton") { + const tons = Number(values.cargoWeightTons || 0); + if (tons > 0) { + lines.push({ + label: lashing.label, + unitPrice: lashing.unitPrice, + unit: lashing.unit, + quantity: tons, + amount: lashing.unitPrice * tons, + }); + } + } + + // Customs clearance service fee — billed on the booking invoice with the + // freight. Container fees estimate per size (per box, or per wagon: two 20ft + // share one); bulk per-ton scales by tonnage. Bulk per-wagon fees depend on + // the wagon capacity the train stocks — shown at real pricing, not estimated. + for (const cl of items.filter((i) => i.isClearance)) { + let qty = 0; + if (isContainer) { + const boxes = (values.containers ?? []) + .filter((c) => c.containerSize === cl.containerSize) + .reduce((s, c) => s + Number(c.quantity || 0), 0); + qty = + cl.unit === "per_wagon" + ? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5)) + : boxes; + } else if (cl.unit === "per_ton") { + qty = Number(values.cargoWeightTons || 0); + } else if (cl.unit === "flat") { + qty = 1; + } + if (qty > 0) { + lines.push({ + label: cl.label, + unitPrice: cl.unitPrice, + unit: cl.unit, + quantity: qty, + amount: cl.unitPrice * qty, + }); + } + } + const total = lines.reduce((s, l) => s + l.amount, 0); return { currency, lines, total }; } diff --git a/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql new file mode 100644 index 000000000..59ae79426 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260723000001_add_route_stop_travel_minutes/migration.sql @@ -0,0 +1,9 @@ +-- Adds RouteStop.travelMinutesToStop: admin-configured travel time (minutes) from the +-- previous stop, used to compute each stop's estimated arrival time (replacing/augmenting +-- distance-proportional interpolation). Nullable — falls back to distance interpolation +-- when unset. +-- Uses IF NOT EXISTS following the pattern established in +-- 20260719000002_repair_route_checkin_minutes, after this same table had two migrations +-- checked in as empty "applied directly" placeholders that never reached the deployed DB. + +ALTER TABLE passenger."RouteStop" ADD COLUMN IF NOT EXISTS "travelMinutesToStop" INTEGER; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index db46df025..de086e465 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1078,6 +1078,7 @@ model RouteStop { sequence Int distanceKm Float? checkinMinutesBefore Int? + travelMinutesToStop Int? plannedArrivalTime DateTime? plannedDepartureTime DateTime? createdAt DateTime @default(now()) diff --git a/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts new file mode 100644 index 000000000..5a0fb50b1 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/checkin-cutoff.utils.ts @@ -0,0 +1,45 @@ +/** + * Resolves the booking/check-in cutoff for one boarding stop: stop-level + * `RouteStop.checkinMinutesBefore` override wins, else the route-level default + * (`Route.checkinMinutesBefore`), else a bare 30-minute fallback for routes/stops with + * neither configured. The basis is the stop's own estimated ARRIVAL time (the train reaching + * that stop), not its departure or the schedule's overall origin departure — a downstream + * stop's cutoff must be independent of how long ago the train left its origin. The first stop + * of a route has no arrival (nothing to arrive at), so it falls back to its own departure. + * + * Single source of truth for this computation — SeatsService.holdSeats and + * SearchService.buildScheduleResult already applied it (search results only ever showed a + * segment as bookable if this same cutoff hadn't passed); GuestBookingService.createGuestBooking + * used to independently hardcode a flat, non-configurable 30 minutes off the schedule's origin + * departure, which could reject a booking the search/hold steps had just accepted under the + * route's actual configured cutoff. + */ +export interface CheckinCutoff { + /** The stop's own estimated arrival time (or departure, for the first stop / missing data). */ + segmentTime: Date; + /** Minutes before segmentTime that booking/holding closes. */ + checkinMinutes: number; + /** The moment booking/holding closes for this stop. */ + cutoffAt: Date; +} + +export function resolveCheckinCutoff( + schedule: { + departureAt: Date; + route?: { + checkinMinutesBefore?: number | null; + stops?: Array<{ stationId: string; checkinMinutesBefore: number | null }>; + } | null; + }, + stopTime: { plannedArrivalAt?: Date | null; plannedDepartureAt?: Date | null } | null | undefined, + stationId: string | null | undefined, +): CheckinCutoff { + const segmentTime = stopTime?.plannedArrivalAt ?? stopTime?.plannedDepartureAt ?? schedule.departureAt; + const routeStop = stationId ? schedule.route?.stops?.find((s) => s.stationId === stationId) : undefined; + const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; + return { + segmentTime, + checkinMinutes, + cutoffAt: new Date(segmentTime.getTime() - checkinMinutes * 60_000), + }; +} diff --git a/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts new file mode 100644 index 000000000..2fcc6581b --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/segment-resolver.utils.ts @@ -0,0 +1,37 @@ +/** + * Resolves a booking's actual boarding/alighting station AND time for one leg from + * originStationId/destinationStationId (set when the booking covers only part of a + * longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D), via + * the schedule's stopTimes — falling back to the schedule's own full-route + * station/time when there's no segment override (older records, or a booking that + * covers the whole run). + * + * Single source of truth for this resolution — station-only lookups used to be + * duplicated ad hoc across bookings/tickets/notifications while the departureAt/ + * arrivalAt kept being read straight off the schedule (the train's full-route span), + * which showed the wrong boarding/alighting time for any stop-based booking. + */ +export interface ResolvedSegment { + origin: any; + destination: any; + departureAt: any; + arrivalAt: any; +} + +export function resolveBookingSegment( + schedule: any, + originStationId: string | null | undefined, + destinationStationId: string | null | undefined, +): ResolvedSegment { + const stopTimes: any[] = schedule?.stopTimes ?? []; + const findStop = (stationId: string | null | undefined) => + stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; + const originStop = findStop(originStationId); + const destStop = findStop(destinationStationId); + return { + origin: originStop?.station ?? schedule?.originStation ?? null, + destination: destStop?.station ?? schedule?.destinationStation ?? null, + departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, + arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, + }; +} diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index b75f1eeb4..98a54528b 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -14,6 +14,7 @@ import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; @@ -140,7 +141,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -148,31 +149,34 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ - id: booking.id, - bookingRef: booking.bookingRef, - status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), - currency: booking.displayCurrency, - displayCurrency: booking.displayCurrency, - displayTotalMinor: booking.displayTotalMinor, - adultCount: booking.adultCount, - childCount: booking.childCount, - bookingType: booking.bookingType, - returnLegStatus: (booking as any).returnLegStatus ?? null, - createdAt: booking.createdAt, - schedule: { - train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, - }, - paymentIntent: booking.paymentIntent, - seatCount: booking.seats.length, - })), + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + id: booking.id, + bookingRef: booking.bookingRef, + status: booking.status, + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, (booking as any).priceTier?.priceMinor, booking.adultCount, booking.childCount), + currency: booking.displayCurrency, + displayCurrency: booking.displayCurrency, + displayTotalMinor: booking.displayTotalMinor, + adultCount: booking.adultCount, + childCount: booking.childCount, + bookingType: booking.bookingType, + returnLegStatus: (booking as any).returnLegStatus ?? null, + createdAt: booking.createdAt, + schedule: { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + }, + paymentIntent: booking.paymentIntent, + seatCount: booking.seats.length, + }; + }), meta: { page, pageSize, @@ -270,7 +274,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: { select: { method: true, status: true, amountMinor: true, currency: true } }, seats: { select: { id: true } }, priceTier: { select: { priceMinor: true } }, @@ -294,7 +298,9 @@ export class BookingsService { this.prisma.packageBooking.count({ where: pkgWhere }), ]); - const mappedBookings = items.map(booking => ({ + const mappedBookings = items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -309,14 +315,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, payment: booking.paymentIntent ?? undefined, seatCount: booking.seats.length, - })); + }; + }); const mappedPkg = pkgItems.map((b: any) => ({ id: b.id, @@ -397,7 +404,7 @@ export class BookingsService { take: pageSize, orderBy: { createdAt: 'desc' }, include: { - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -405,9 +412,11 @@ export class BookingsService { }), this.prisma.booking.count({ where }), ]); - + return { - items: items.map(booking => ({ + items: items.map(booking => { + const segment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, @@ -422,14 +431,15 @@ export class BookingsService { createdAt: booking.createdAt, schedule: { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - arrivalAt: booking.schedule.arrivalAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, }, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, - })), + }; + }), meta: { page, pageSize, @@ -532,7 +542,7 @@ export class BookingsService { orderBy: { createdAt: 'desc' }, include: { passenger: { select: { id: true, iamUserId: true } }, - schedule: { include: { originStation: true, destinationStation: true, train: true } }, + schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, paymentIntent: true, seats: { include: { seat: true } }, priceTier: { select: { priceMinor: true } }, @@ -554,9 +564,10 @@ export class BookingsService { const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined; const passengerDetails = booking.seats.map((s: any) => ({ name: s.passengerName, category: s.passengerCategory })); const uniquePassengers = Array.from(new Map(passengerDetails.map((p: any) => [p.name, p])).values()); + const segment = booking.schedule ? resolveBookingSegment(booking.schedule, booking.originStationId, booking.destinationStationId) : null; return { id: booking.id, bookingRef: booking.bookingRef, status: booking.status, - totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), + totalMinor: booking.displayTotalMinor ?? resolvePackageRoundTripTotal(booking, booking.priceTier?.priceMinor, booking.adultCount, booking.childCount), currency: booking.displayCurrency, displayCurrency: booking.displayCurrency, displayTotalMinor: booking.displayTotalMinor, contactEmail: booking.contactEmail, contactPhone: booking.contactPhone, @@ -567,11 +578,11 @@ export class BookingsService { passenger: iam ? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number } : null, passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))], passengers: uniquePassengers, - schedule: booking.schedule ? { + schedule: segment ? { train: booking.schedule.train, - originStation: booking.schedule.originStation, - destinationStation: booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, } : null, paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, @@ -715,16 +726,15 @@ export class BookingsService { verifaydaVerified: s.verifaydaVerified, seat: s.seat ? { seatNumber: s.seat.seatNumber, coach: { number: s.seat.coach?.number ?? null } } : null, })), - schedule: { - train: booking.schedule.train, - originStation: (booking as any).originStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).originStationId)?.station ?? booking.schedule.originStation) - : booking.schedule.originStation, - destinationStation: (booking as any).destinationStationId - ? ((booking.schedule as any).stopTimes?.find((s: any) => s.stationId === (booking as any).destinationStationId)?.station ?? booking.schedule.destinationStation) - : booking.schedule.destinationStation, - departureAt: booking.schedule.departureAt, - }, + schedule: (() => { + const segment = resolveBookingSegment(booking.schedule, (booking as any).originStationId, (booking as any).destinationStationId); + return { + train: booking.schedule.train, + originStation: segment.origin, + destinationStation: segment.destination, + departureAt: segment.departureAt, + }; + })(), paymentIntent: booking.paymentIntent, seatCount: booking.seats.length, }; @@ -1850,35 +1860,6 @@ export class BookingsService { ); } - // Resolves the passenger's actual boarding/alighting stations AND times for one leg - // from originStationId/destinationStationId (set when the booking covers only part of - // a longer multi-stop schedule, e.g. train runs A→D but the passenger booked B→D) via - // the schedule's stopTimes, falling back to the schedule's own full-route endpoints/ - // times when there's no segment override (older records, or a booking that covers the - // whole run). Station resolution mirrors notifications.service.ts's - // resolveSegmentStations (already applied to SMS/email); the departureAt/arrivalAt - // resolution mirrors search.service.ts's leg construction (originStop.plannedDepartureAt - // / destStop.plannedArrivalAt) — this brings the booking API (voucher, detail page, - // confirmation) to the same behavior search results already have, instead of always - // showing the train's full-route span. - private resolveSegmentStations( - schedule: any, - originStationId: string | null | undefined, - destinationStationId: string | null | undefined, - ): { origin: any; destination: any; departureAt: any; arrivalAt: any } { - const stopTimes: any[] = schedule?.stopTimes ?? []; - const findStop = (stationId: string | null | undefined) => - stationId && stopTimes.length > 0 ? stopTimes.find((st: any) => st.stationId === stationId) : undefined; - const originStop = findStop(originStationId); - const destStop = findStop(destinationStationId); - return { - origin: originStop?.station ?? schedule?.originStation ?? null, - destination: destStop?.station ?? schedule?.destinationStation ?? null, - departureAt: originStop?.plannedDepartureAt ?? schedule?.departureAt ?? null, - arrivalAt: destStop?.plannedArrivalAt ?? schedule?.arrivalAt ?? null, - }; - } - async getByRef(bookingRefOrId: string) { const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bookingRefOrId); const booking = await this.prisma.booking.findUnique({ @@ -1987,13 +1968,13 @@ export class BookingsService { if (refreshed) Object.assign(booking, refreshed); } - const outboundSegment = this.resolveSegmentStations( + const outboundSegment = resolveBookingSegment( (booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId, ); const returnSegment = (booking as any).returnSchedule - ? this.resolveSegmentStations( + ? resolveBookingSegment( (booking as any).returnSchedule, (booking as any).returnOriginStationId, (booking as any).returnDestinationStationId, diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index d40824c36..3e2a225e6 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -8,9 +8,22 @@ import { FareEngineService } from '../fare-engine/fare-engine.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { CreateGuestBookingDto, SavedPassengerProfileDto } from './guest-booking.dto'; import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client'; +import { resolveCheckinCutoff } from '../../common/utils/checkin-cutoff.utils'; -/** Booking cutoff: reject new bookings within this many ms of departure. */ -const BOOKING_CUTOFF_MS = 30 * 60 * 1000; +/** + * Throws if the given boarding stop's own configurable check-in cutoff (route/stop + * checkinMinutesBefore, same mechanism the seat hold and search results already enforce) has + * passed. Must be checked against the actual boarding stop, not the schedule's origin — a + * downstream stop's cutoff is independent of how long ago the train left its origin. + */ +function assertWithinCheckinCutoff(schedule: any, stopTime: any, stationId: string | null | undefined): void { + const { cutoffAt, checkinMinutes } = resolveCheckinCutoff(schedule, stopTime, stationId); + if (Date.now() >= cutoffAt.getTime()) { + throw new BadRequestException( + `Bookings are not accepted within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`, + ); + } +} function generateRef(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; @@ -107,20 +120,22 @@ export class GuestBookingService { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + route: { include: { stops: true } }, }, }); if (!schedule) throw new NotFoundException('Schedule not found'); - if (Date.now() >= schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const originStop = schedule.stopTimes.find(s => s.stationId === dto.originStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.originStationId, sequence: 0, station: schedule.originStation } : undefined); const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId) ?? (schedule.stopTimes.length === 0 ? { stationId: schedule.destinationStationId, sequence: 1, station: schedule.destinationStation } : undefined); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); + // Cut off relative to the passenger's actual boarding stop, using the same + // configurable per-stop/route checkinMinutesBefore that already gated the seat hold + // and the search result — not a separate, hardcoded 30 minutes off the train's origin. + assertWithinCheckinCutoff(schedule, originStop, dto.originStationId); + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; @@ -391,7 +406,7 @@ export class GuestBookingService { const [outboundSchedule, returnSchedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, @@ -401,10 +416,6 @@ export class GuestBookingService { if (!outboundSchedule) throw new NotFoundException('Outbound schedule not found'); if (!returnSchedule) throw new NotFoundException('Return schedule not found'); - if (Date.now() >= outboundSchedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const synth = (sched: any, stationId: string, seq: number) => { const station = sched.originStationId === stationId ? sched.originStation : sched.destinationStation; return { stationId, sequence: seq, station }; @@ -418,6 +429,10 @@ export class GuestBookingService { if (!outboundOriginStop || !outboundDestStop) throw new NotFoundException('Outbound origin or destination not found on schedule'); if (!returnOriginStop || !returnDestStop) throw new NotFoundException('Return origin or destination not found on schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(outboundSchedule, outboundOriginStop, dto.originStationId); + const outboundSegmentRoute = `${outboundOriginStop.station.code}-${outboundDestStop.station.code}`; const outboundFullRoute = `${outboundSchedule.originStation.code}-${outboundSchedule.destinationStation.code}`; const returnSegmentRoute = `${returnOriginStop.station.code}-${returnDestStop.station.code}`; @@ -690,7 +705,7 @@ export class GuestBookingService { const [leg1Schedule, leg2Schedule] = await Promise.all([ this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } }, }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, @@ -700,10 +715,6 @@ export class GuestBookingService { if (!leg1Schedule) throw new NotFoundException('Leg-1 schedule not found'); if (!leg2Schedule) throw new NotFoundException('Leg-2 schedule not found'); - if (Date.now() >= leg1Schedule.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const leg1OriginStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.originStationId); const leg1DestStop = leg1Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); const leg2OriginStop = leg2Schedule.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -711,6 +722,10 @@ export class GuestBookingService { if (!leg1OriginStop || !leg1DestStop) throw new NotFoundException('Leg-1 origin or transit station not found on schedule'); if (!leg2OriginStop || !leg2DestStop) throw new NotFoundException('Transit or leg-2 destination not found on leg-2 schedule'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(leg1Schedule, leg1OriginStop, dto.originStationId); + // Process passengers (verify identity once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; @@ -894,7 +909,7 @@ export class GuestBookingService { if (!retL2Hold || retL2Hold.expiresAt < now) throw new BadRequestException('Return leg-2 hold expired'); const [obL1Sched, obL2Sched, retL1Sched, retL2Sched] = await Promise.all([ - this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), + this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, route: { include: { stops: true } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.leg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), this.prisma.trainSchedule.findUnique({ where: { id: dto.returnLeg2ScheduleId }, include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }), @@ -904,10 +919,6 @@ export class GuestBookingService { if (!retL1Sched) throw new NotFoundException('Return leg-1 schedule not found'); if (!retL2Sched) throw new NotFoundException('Return leg-2 schedule not found'); - if (Date.now() >= obL1Sched.departureAt.getTime() - BOOKING_CUTOFF_MS) { - throw new BadRequestException('Bookings are not accepted within 30 minutes of departure'); - } - const obL1Origin = obL1Sched.stopTimes.find(s => s.stationId === dto.originStationId); const obL1Dest = obL1Sched.stopTimes.find(s => s.stationId === dto.transitStationId); const obL2Origin = obL2Sched.stopTimes.find(s => s.stationId === dto.transitStationId); @@ -921,6 +932,10 @@ export class GuestBookingService { if (!retL1Origin || !retL1Dest) throw new NotFoundException('Return leg-1: origin or transit stop not found'); if (!retL2Origin || !retL2Dest) throw new NotFoundException('Return leg-2: transit or destination stop not found'); + // Cut off relative to the passenger's actual boarding stop, using the same configurable + // per-stop/route checkinMinutesBefore that already gated the seat hold and search result. + assertWithinCheckinCutoff(obL1Sched, obL1Origin, dto.originStationId); + // Process passengers (verify once) const passengersData: any[] = []; let adultCount = 0, childCount = 0; diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index a1678d131..12f549e9f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -7,6 +7,7 @@ import { PushAdapter, NotificationChannel } from './notification.adapters'; import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -353,27 +354,6 @@ export class NotificationsService { } } - /** - * Resolves the user's actual boarding/alighting stations from the booking's originStationId / - * destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when - * the booking has no segment override (e.g. older records or packages). - */ - private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } { - const s = booking?.schedule ?? {}; - const stopTimes: any[] = s.stopTimes ?? []; - const findStation = (stationId: string | null | undefined, fallback: any) => { - if (stationId && stopTimes.length > 0) { - const stop = stopTimes.find((st: any) => st.stationId === stationId); - if (stop?.station) return stop.station; - } - return fallback ?? null; - }; - return { - originStation: findStation(booking?.originStationId, s.originStation), - destinationStation: findStation(booking?.destinationStationId, s.destinationStation), - }; - } - /** * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get @@ -400,17 +380,17 @@ export class NotificationsService { // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. const passengerName = seats[0]?.passengerName ?? 'Passenger'; const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); return { passengerName, bookingRef: ref, - origin: originSt?.name ?? '', - destination: destSt?.name ?? '', + origin: segment.origin?.name ?? '', + destination: segment.destination?.name ?? '', trainSeatLines, - travelDate: fmtDate(s.departureAt), - departureTime: fmtTime(s.departureAt), - arrivalTime: fmtTime(s.arrivalAt), + travelDate: fmtDate(segment.departureAt), + departureTime: fmtTime(segment.departureAt), + arrivalTime: fmtTime(segment.arrivalAt), payLink, }; } @@ -509,12 +489,12 @@ export class NotificationsService { private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string { const s = booking.schedule ?? {}; - const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD'; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const dep = segment.departureAt ? new Date(segment.departureAt).toLocaleString('en-GB') : 'TBD'; const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', '); - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); return [ `Booking ${booking.bookingRef} confirmed.`, - `${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`, + `${segment.origin?.name ?? ''} -> ${segment.destination?.name ?? ''}`, `Train: ${s.train?.name ?? s.train?.number ?? ''}`, `Departs: ${dep}`, passengers ? `Passengers: ${passengers}` : '', @@ -527,7 +507,9 @@ export class NotificationsService { const s = booking.schedule ?? {}; const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const originSt = segment.origin; + const destSt = segment.destination; const seatRows = (booking.seats ?? []) .map((bs: any) => { const coach = bs.seat?.coach?.number ?? '-'; @@ -568,11 +550,11 @@ export class NotificationsService { Departs - ${fmt(s.departureAt)} + ${fmt(segment.departureAt)} Arrives - ${fmt(s.arrivalAt)} + ${fmt(segment.arrivalAt)} @@ -636,12 +618,12 @@ export class NotificationsService { const fmt = (d: any) => d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD'; const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : ''; - const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking); - const origin = originSt?.name ?? ''; - const dest = destSt?.name ?? ''; + const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); + const origin = segment.origin?.name ?? ''; + const dest = segment.destination?.name ?? ''; const train = s.train?.name ?? s.train?.number ?? ''; - const dep = fmt(s.departureAt); - const arr = fmt(s.arrivalAt); + const dep = fmt(segment.departureAt); + const arr = fmt(segment.arrivalAt); const seats: { name: string; coach: string; seat: string; cls: string }[] = (booking.seats ?? []).map((bs: any) => ({ name: bs.passengerName ?? '', diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 7889c67a1..1aef00185 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -599,32 +599,31 @@ export class ReportsService { sortBy?: string; search?: string; }) { - // Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies). - // Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate). + // Load all exchange rates once — we need conversions in both directions. const rateRows = await this.prisma.currencyExchangeRate.findMany({ - where: { toCurrency: 'ETB' as any }, orderBy: { effectiveDate: 'desc' }, }); - const rateToEtb = new Map(); + // Most-recent rate for each fromCurrency→toCurrency pair + const rateMap = new Map(); for (const r of rateRows) { - if (!rateToEtb.has(r.fromCurrency)) { - rateToEtb.set(r.fromCurrency, Number(r.rate)); - } + const key = `${r.fromCurrency}→${r.toCurrency}`; + if (!rateMap.has(key)) rateMap.set(key, Number(r.rate)); } - // Convert any minor amount to its ETB equivalent using stored exchange rates. - // b.totalMinor is the booking's canonical ETB amount (always stored in ETB), - // so callers should pass that directly rather than converting displayTotalMinor. - const toEtbMinor = (minor: number, currency: string): number => { - if (currency === 'ETB') return minor; - const rate = rateToEtb.get(currency); - // If no rate is on file fall back to the raw value (avoids silently hiding - // cross-currency bookings, at the cost of an approximate comparison). - return rate ? Math.round(minor * rate) : minor; + // Convert minor amount from one currency to another. + const convertMinor = (minor: number, from: string, to: string): number => { + if (from === to) return minor; + const direct = rateMap.get(`${from}→${to}`); + if (direct) return Math.round(minor * direct); + // Try via ETB as pivot + const toEtb = rateMap.get(`${from}→ETB`); + const fromEtb = rateMap.get(`ETB→${to}`); + if (toEtb && fromEtb) return Math.round(minor * toEtb * fromEtb); + return minor; // fallback: no rate on file }; if (params.search?.trim()) { - return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor); + return this.getDiscrepancyForRef(params.search.trim(), convertMinor); } const dateFilter: Record = {}; @@ -679,20 +678,18 @@ export class ReportsService { .map(b => { const pi = b.paymentIntent!; - // Display amounts shown to the passenger (may be in DJF). + // Display amounts shown to the passenger (may be in DJF/USD). const actualMinor = b.displayTotalMinor ?? b.totalMinor; const actualCurrency = (b.displayCurrency as string | null) ?? b.currency; const paidMinor = pi.amountMinor; const paidCurrency = pi.currency; - // b.totalMinor is always in ETB minor. pi.amountMinor is the charge MAJOR amount - // (the gateway receives major units — displayMinorToChargeMajor divides by 100 before - // sending). Multiply by 100 to convert back to minor before the ETB comparison. - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + // Balance in the booking's display currency: + // convert paid (major units from gateway) to display currency minor, then subtract. + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); @@ -731,7 +728,7 @@ export class ReportsService { private async getDiscrepancyForRef( search: string, - toEtbMinor: (minor: number, currency: string) => number, + convertMinor: (minor: number, from: string, to: string) => number, ) { let bookingId: string | null = null; const byPnr = await this.prisma.booking.findUnique({ @@ -797,10 +794,9 @@ export class ReportsService { const paidMinor = pi?.amountMinor ?? 0; const paidCurrency = pi?.currency ?? b.currency; - const owedEtb = b.totalMinor; - const paidEtb = toEtbMinor(paidMinor * 100, paidCurrency); - const balanceMinor = owedEtb - paidEtb; - const balanceCurrency = 'ETB'; + const paidInDisplayMinor = convertMinor(paidMinor * 100, paidCurrency, actualCurrency); + const balanceMinor = actualMinor - paidInDisplayMinor; + const balanceCurrency = actualCurrency; const firstSeat = b.seats[0]; const passengers = this.buildSeatPassengers(b.seats, actualCurrency); diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index d468bba7c..5e3237e93 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -42,7 +42,7 @@ Route stops carry distanceKm for fare-by-distance calculations.`, @Patch(':id') @PassengerStaff([PASSENGER_PERMS.routes.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth') - @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveUntil)' }) + @ApiOperation({ summary: 'Update route metadata (name, description, active flag, effectiveFrom, effectiveUntil)' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Route updated' }) @ApiResponse({ status: 404, description: 'Route not found' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts index f2db36e7c..1036138c9 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.dto.ts @@ -5,8 +5,9 @@ import { Type } from 'class-transformer'; export class RouteStopInputDto { @ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string; @ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 120.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Ignored for sequence 1 (origin, no predecessor). Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class CreateRouteDto { @@ -14,8 +15,9 @@ export class CreateRouteDto { @ApiProperty({ example: 'Addis Ababa – Djibouti' }) @IsString() name: string; @ApiPropertyOptional({ example: 'Main corridor via Dire Dawa' }) @IsOptional() @IsString() description?: string; @ApiProperty({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsDateString() effectiveFrom: string; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: true, description: 'Whether the route is active (defaults to true)' }) @IsOptional() @IsBoolean() active?: boolean; + @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route (defaults to 30 if omitted)' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiProperty({ type: [RouteStopInputDto], description: 'Ordered stops for this route. Sequence 1 = origin, last sequence = destination.', @@ -35,15 +37,17 @@ export class CreateRouteDto { export class AddRouteStopDto { @ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string; @ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number; - @ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number; + @ApiPropertyOptional({ example: 75.5, description: 'Cumulative distance in km from the route origin (not from the previous stop)' }) @IsOptional() @IsNumber() distanceKm?: number; @ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; + @ApiPropertyOptional({ example: 40, description: 'Travel time in minutes from the previous stop, used to estimate this stop\'s arrival time. Falls back to distance-proportional interpolation if omitted.' }) @IsOptional() @IsInt() @Min(1) travelMinutesToStop?: number; } export class UpdateRouteDto { @ApiPropertyOptional({ example: 'Addis Ababa – Djibouti Express' }) @IsOptional() @IsString() name?: string; @ApiPropertyOptional() @IsOptional() @IsString() description?: string; @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean; - @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string; + @ApiPropertyOptional({ example: '2026-01-01T00:00:00Z', description: 'Date from which this route is effective' }) @IsOptional() @IsDateString() effectiveFrom?: string; + @ApiPropertyOptional({ example: '2027-12-31T23:59:59Z', description: 'Send null to clear (open-ended route)' }) @IsOptional() @IsDateString() effectiveUntil?: string | null; @ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number; @ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[]; } diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts index 58e647180..1a78e906e 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../../common/prisma.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception'; import { AuditService } from '../../common/audit.service'; +import { parseEthiopianTime } from '../../common/utils/timezone.utils'; @Injectable() export class RoutesService { @@ -10,6 +11,33 @@ export class RoutesService { // ── Route CRUD ───────────────────────────────────────────────────────────── + /** + * distanceKm is CUMULATIVE distance from the route origin, not distance from the previous + * stop (that's what travelMinutesToStop is for) — fare pricing computes a segment's distance + * as destStop.distanceKm - originStop.distanceKm, so a route with equal or decreasing values + * across stops silently produces zero/negative segment distances, which the fare engine + * rejects (caught and swallowed by search into a bare "N/A" instead of a visible error). Catch + * the mistake here instead, with a message that names the exact stops involved. + */ + private validateStopDistances(stops: { sequence: number; stationId: string; distanceKm?: number | null }[]): void { + const sorted = [...stops].sort((a, b) => a.sequence - b.sequence); + let prevDistance = sorted[0]?.distanceKm ?? 0; + for (let i = 1; i < sorted.length; i++) { + const stop = sorted[i]; + if (stop.distanceKm == null) { + throw new BadRequestException( + `Stop ${stop.sequence} is missing distanceKm (cumulative distance in km from the route origin). This is required for fare pricing.`, + ); + } + if (stop.distanceKm <= prevDistance) { + throw new BadRequestException( + `Stop ${stop.sequence}'s distanceKm (${stop.distanceKm}) must be greater than stop ${sorted[i - 1].sequence}'s distanceKm (${prevDistance}) — distanceKm is cumulative distance from the route origin, not distance from the previous stop. Equal or decreasing values make fare pricing between these stops fail silently.`, + ); + } + prevDistance = stop.distanceKm; + } + } + async createRoute(dto: CreateRouteDto) { const existing = await this.prisma.route.findUnique({ where: { code: dto.code } }); if (existing) throw new ConflictException(`Route code "${dto.code}" already exists`); @@ -19,6 +47,8 @@ export class RoutesService { const seqs = dto.stops.map(s => s.sequence); if (new Set(seqs).size !== seqs.length) throw new ConflictException('Duplicate sequence numbers in stop list'); + this.validateStopDistances(dto.stops); + const stationIds = [...new Set(dto.stops.map(s => s.stationId))]; const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } }); if (stations.length !== stationIds.length) throw new BadRequestException('One or more station IDs not found'); @@ -29,14 +59,16 @@ export class RoutesService { name: dto.name, description: dto.description, active: dto.active ?? true, - effectiveFrom: new Date(dto.effectiveFrom), - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null, + ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), + effectiveFrom: parseEthiopianTime(dto.effectiveFrom), + effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null, stops: { create: dto.stops.map(s => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }, }, @@ -86,13 +118,20 @@ export class RoutesService { const route = await this.prisma.route.findUnique({ where: { id } }); if (!route) throw new NotFoundException('Route not found'); + if (dto.stops && dto.stops.length >= 2) this.validateStopDistances(dto.stops); + await this.prisma.route.update({ where: { id }, data: { name: dto.name, description: dto.description, active: dto.active, - effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined, + ...(dto.effectiveFrom ? { effectiveFrom: parseEthiopianTime(dto.effectiveFrom) } : {}), + // effectiveUntil is nullable (open-ended route) — distinguish "field not sent" (leave + // untouched) from "explicitly cleared" (null → set to null), not just truthy/falsy. + ...(dto.effectiveUntil !== undefined + ? { effectiveUntil: dto.effectiveUntil ? parseEthiopianTime(dto.effectiveUntil) : null } + : {}), ...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}), }, }); @@ -106,6 +145,7 @@ export class RoutesService { sequence: s.sequence, distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null, checkinMinutesBefore: s.checkinMinutesBefore ?? null, + travelMinutesToStop: s.travelMinutesToStop ?? null, })), }); } @@ -218,6 +258,9 @@ export class RoutesService { }); if (existing) throw new ConflictException(`Sequence ${dto.sequence} already exists on this route`); + const otherStops = await this.prisma.routeStop.findMany({ where: { routeId } }); + this.validateStopDistances([...otherStops, { sequence: dto.sequence, stationId: dto.stationId, distanceKm: dto.distanceKm }]); + return this.prisma.routeStop.create({ data: { routeId, @@ -225,6 +268,7 @@ export class RoutesService { sequence: dto.sequence, distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null, checkinMinutesBefore: dto.checkinMinutesBefore ?? null, + travelMinutesToStop: dto.travelMinutesToStop ?? null, }, }); } diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts index 675168cf9..49dec1d96 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.dto.ts @@ -52,6 +52,10 @@ export class CreateScheduleDto { }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => PlannedStopTimeDto) plannedTimes?: PlannedStopTimeDto[]; + + @ApiPropertyOptional({ type: [String], description: 'Coach UUIDs to assign, in consist order. Overrides the route coach template if provided. A schedule must end up with at least one coach.' }) + @IsOptional() @IsArray() @IsString({ each: true }) + coachIds?: string[]; } export class UpdateScheduleDto { diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index 4c2870a8a..be1c936b4 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { RoutesService } from './routes.service'; import { FareEngineService } from '../fare-engine/fare-engine.service'; @@ -9,6 +9,8 @@ import { AuditService } from '../../common/audit.service'; @Injectable() export class SchedulesService { + private readonly logger = new Logger(SchedulesService.name); + constructor( private prisma: PrismaService, private routesService: RoutesService, @@ -16,6 +18,44 @@ export class SchedulesService { private auditService: AuditService, ) { } + /** + * Computes each stop's planned arrival/departure time by walking the route in sequence + * order and accumulating `RouteStop.travelMinutesToStop` (minutes of travel from the + * previous stop). Falls back to distance-proportional interpolation over `distanceKm` for + * any stop missing `travelMinutesToStop`. The last stop is always locked to the confirmed + * overall `arr` regardless of the accumulated cursor, so schedule.arrivalAt stays + * authoritative even if per-stop estimates drift. + */ + private computePlannedTimes( + route: { id: string; stops: { sequence: number; distanceKm: number | null; travelMinutesToStop: number | null }[] }, + dep: Date, + arr: Date, + ) { + const totalDuration = arr.getTime() - dep.getTime(); + const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; + + let cursor = dep; + return route.stops.map((stop, index) => { + if (index === 0) { + cursor = dep; + } else if (index === route.stops.length - 1) { + cursor = arr; + } else if (stop.travelMinutesToStop != null) { + cursor = new Date(cursor.getTime() + stop.travelMinutesToStop * 60_000); + } else { + const stopDistance = stop.distanceKm || 0; + const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); + cursor = new Date(dep.getTime() + totalDuration * progress); + this.logger.warn(`Route ${route.id} stop seq ${stop.sequence} missing travelMinutesToStop; falling back to distance interpolation`); + } + return { + sequence: stop.sequence, + plannedArrivalAt: index === 0 ? undefined : cursor.toISOString(), + plannedDepartureAt: index === route.stops.length - 1 ? undefined : cursor.toISOString(), + }; + }); + } + async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) { const startDate = parseEthiopianTime(dto.startDateTime); const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000); @@ -43,20 +83,14 @@ export class SchedulesService { departureAt: departureAt.toISOString(), arrivalAt: arrivalAt.toISOString(), plannedTimes: dto.plannedTimes || [], + coachIds: dto.coachIds, }; + // createSchedule applies coachIds if given, else auto-applies the route coach template, + // and rejects the day outright (caught below) if it would end up with zero coaches. const schedule = await this.createSchedule(createDto); scheduleIds.push(schedule.id); - // createSchedule already auto-applies the route coach template; - // only override if explicit coachIds are provided - if (dto.coachIds && dto.coachIds.length > 0) { - await this.assignCoaches( - schedule.id, - dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), - ); - } - scheduleCount++; } catch (error) { errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`); @@ -135,26 +169,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence)); @@ -183,15 +198,34 @@ export class SchedulesService { const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap); - // Auto-apply route coach template if one is defined - const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ - where: { routeId: dto.routeId }, - orderBy: { positionNumber: 'asc' }, - }); - if (coachTemplates.length > 0) { + // Explicit coachIds (from the schedule form's Coaches step) override the route's coach + // template; otherwise auto-apply the template if one is defined. + if (dto.coachIds && dto.coachIds.length > 0) { await this.assignCoaches( schedule.id, - coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + dto.coachIds.map((coachId, idx) => ({ coachId, positionNumber: idx + 1 })), + ); + } else { + const coachTemplates = await this.prisma.routeCoachTemplate.findMany({ + where: { routeId: dto.routeId }, + orderBy: { positionNumber: 'asc' }, + }); + if (coachTemplates.length > 0) { + await this.assignCoaches( + schedule.id, + coachTemplates.map(t => ({ coachId: t.coachId, positionNumber: t.positionNumber })), + ); + } + } + + // A schedule with zero coaches has zero seats and is silently invisible to search (and + // unbookable) with no indication why — block creation instead of leaving a dead schedule. + const assignedCoachCount = await this.prisma.coachAssignment.count({ where: { scheduleId: schedule.id } }); + if (assignedCoachCount === 0) { + await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: schedule.id } }); + await this.prisma.trainSchedule.delete({ where: { id: schedule.id } }); + throw new BadRequestException( + 'A schedule must have at least one coach assigned to be bookable. Add coaches in the Coaches step, or set a Route Coach Template on this route so new schedules auto-assign coaches.', ); } @@ -306,26 +340,7 @@ export class SchedulesService { let plannedTimes = dto.plannedTimes; if (!plannedTimes || plannedTimes.length === 0) { - const totalDuration = arr.getTime() - dep.getTime(); - const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0; - - plannedTimes = route.stops.map((stop, index) => { - let stopTime: Date; - if (index === 0) { - stopTime = dep; - } else if (index === route.stops.length - 1) { - stopTime = arr; - } else { - const stopDistance = stop.distanceKm || 0; - const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1); - stopTime = new Date(dep.getTime() + totalDuration * progress); - } - return { - sequence: stop.sequence, - plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(), - plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(), - }; - }); + plannedTimes = this.computePlannedTimes(route, dep, arr); } const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); @@ -655,11 +670,14 @@ export class SchedulesService { if (!schedule) throw new NotFoundException('Schedule not found'); const updateData: any = {}; + let dep: Date | undefined; + let arr: Date | undefined; if (dto.departureAt || dto.arrivalAt) { - const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); - const arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); + dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt); + arr = dto.arrivalAt ? parseEthiopianTime(dto.arrivalAt) : new Date(schedule.arrivalAt); if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time'); + if (dep.getTime() < Date.now()) throw new BadRequestException('departureAt must be in the future'); updateData.departureAt = dep; updateData.arrivalAt = arr; updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000); @@ -672,6 +690,22 @@ export class SchedulesService { await this.prisma.trainSchedule.update({ where: { id }, data: updateData }); } + // departureAt/arrivalAt changed — the per-stop TripStopTime rows were computed against the + // OLD times and are now stale (same interpolation createSchedule/updateSchedule use). Left + // unfixed, check-in cutoff enforcement and search silently keep using outdated per-stop + // arrival/departure estimates for every intermediate stop. + if (dep && arr && schedule.routeId) { + const route = await this.prisma.route.findUnique({ + where: { id: schedule.routeId }, + include: { stops: { orderBy: { sequence: 'asc' } } }, + }); + if (route && route.stops.length >= 2) { + const plannedTimes = this.computePlannedTimes(route, dep, arr); + const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t])); + await this.routesService.applyRouteToSchedule(schedule.routeId, id, plannedTimesMap); + } + } + if (dto.coaches !== undefined) { if (dto.coaches.length > 0) { await this.assignCoaches(id, dto.coaches); diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 6ed657975..0c905acdd 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -10,7 +10,8 @@ import { CurrencyService } from "../currency/currency.service"; import { FareEngineService } from "../fare-engine/fare-engine.service"; import { SegmentsService } from "../segments/segments.service"; import { resolveCurrencyFromNationality } from "../fare-engine/fare-engine.dto"; -import { Currency } from "@prisma/client"; +import { resolveCheckinCutoff } from "../../common/utils/checkin-cutoff.utils"; +import { Currency, Prisma } from "@prisma/client"; const POINTS_TO_MINOR = 10; @@ -220,12 +221,18 @@ export class SearchService { const totalPassengers = adultCount + (childCount ?? 0); const NEEDED = 3; - const baseWhere = { - status: "SCHEDULED", + // Include BOARDING alongside SCHEDULED: BOARDING is just an operational display status the + // schedule-level cron sets on a fixed 30-min-before-departure timer (see tasks.service.ts) — + // it does NOT mean booking is closed. The actual booking cutoff is per-stop and configurable + // (RouteStop/Route.checkinMinutesBefore), enforced below by buildScheduleResult's own live + // check against each stop's estimated arrival/departure. Excluding BOARDING here would + // silently impose a hidden, non-configurable 30-minute cutoff on top of that. + const baseWhere: Prisma.TrainScheduleWhereInput = { + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, - } as const; + }; // Fetch candidates before and after in parallel; take more than needed to // account for routes that don't serve the destination or have no availability. @@ -300,23 +307,21 @@ export class SearchService { const nextDay = new Date( `${String(y)}-${String(m).padStart(2, "0")}-${String(d + 1).padStart(2, "0")}T00:00:00+03:00`, ); - const now = new Date(); const totalPassengers = adultCount + (childCount ?? 0); - // Use now as the lower bound for today so we don't fetch schedules that have - // already fully departed. The per-segment cutoff check in buildScheduleResult - // handles the exact check using each stop's own plannedDepartureAt. - const isToday = - now.getFullYear() === y && - now.getMonth() === m - 1 && - now.getDate() === d; - const earliest = isToday ? now : date; - + // Match on the schedule's own departure DATE only — do NOT use `now` as a lower bound here. + // A schedule whose origin has already departed (EN_ROUTE) can still have a later stop (e.g. + // Lebu, Adama) whose own cutoff hasn't passed; using the overall departureAt as a floor would + // wrongly exclude the whole schedule for those still-bookable downstream segments. The + // per-segment cutoff check in buildScheduleResult is the sole authority for whether THIS + // specific origin stop is still bookable, using each stop's own estimated arrival/departure. const schedules = await this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // EN_ROUTE/BOARDING included alongside SCHEDULED — these are operational display + // statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere). + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, - departureAt: { gte: earliest, lt: nextDay }, + departureAt: { gte: date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, coachAssignments: { some: {} }, }, @@ -368,7 +373,8 @@ export class SearchService { const [leg1Schedules, allCandidates] = await Promise.all([ this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + // BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere. + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: dayEnd }, stopTimes: { some: { stationId: originStationId } }, @@ -378,7 +384,7 @@ export class SearchService { }), this.prisma.trainSchedule.findMany({ where: { - status: "SCHEDULED", + status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] }, isPackageOnly: false, departureAt: { gte: dayStart, lt: leg2WindowEnd }, coachAssignments: { some: {} }, @@ -547,24 +553,13 @@ export class SearchService { if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; - // Segment-level cutoff: use the origin stop's planned departure, not the + // Segment-level cutoff: use the origin stop's own estimated arrival time, not the // schedule's overall departureAt (which is station A's time). This lets - // B→D remain bookable even after A→D closes. - // Cutoff resolution: stop-level override → route default → 30 min fallback. - const now = new Date(); - const segmentDepartureAt = - originStop.plannedDepartureAt ?? schedule.departureAt; - const routeStop = schedule.route?.stops?.find( - (s) => s.stationId === originStationId, - ); - const checkinMinutes = - routeStop?.checkinMinutesBefore ?? - schedule.route?.checkinMinutesBefore ?? - 30; - if ( - segmentDepartureAt.getTime() - now.getTime() <= - checkinMinutes * 60 * 1000 - ) + // B→D remain bookable even after A→D closes. Stop-level checkinMinutesBefore override → + // route default → 30 min fallback — same resolution GuestBookingService applies at + // booking-creation time, so a segment shown as bookable here stays bookable through + // checkout instead of being rejected against a different, hardcoded cutoff. + if (Date.now() >= resolveCheckinCutoff(schedule, originStop, originStationId).cutoffAt.getTime()) return null; // Collect all valid seat IDs upfront for a single batch availability check @@ -676,8 +671,11 @@ export class SearchService { nationality, availabilityByClass, ); - const legDepartureAt = schedule.departureAt; - const legArrivalAt = schedule.arrivalAt; + // Use the selected stop's own planned time, not the schedule's full-route span — + // for stop-based (mid-route) boarding/alighting these differ from the train's + // overall origin departure / final destination arrival. + const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; + const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; const displayCurrency = faresByClass[0]?.displayCurrency ?? diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index bd73c10f5..bd0a3cadf 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -281,7 +281,7 @@ export class SeatsService { }), this.prisma.tripStopTime.findFirst({ where: { scheduleId: dto.scheduleId, stationId: dto.originStationId }, - select: { plannedDepartureAt: true }, + select: { plannedArrivalAt: true, plannedDepartureAt: true }, }), this.prisma.routeStop.findFirst({ where: { @@ -295,7 +295,9 @@ export class SeatsService { // Stop-level override wins; falls back to route-level; then to 30 min. const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30; - const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt; + // Arrival basis: the origin stop's own estimated arrival, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its departure. + const segmentDepartureAt = originStopTime?.plannedArrivalAt ?? originStopTime?.plannedDepartureAt ?? schedule.departureAt; const msUntilDeparture = segmentDepartureAt.getTime() - Date.now(); if (msUntilDeparture <= checkinMinutes * 60 * 1000) { throw new BadRequestException( diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index 4767c7eba..99d6b4d4e 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -81,17 +81,23 @@ export class TasksService { byRoute.get(stop.routeId)!.push(stop.stationId); } + // Arrival basis: each stop's own estimated arrival time, not its departure. The first + // stop of a route has no arrival (nothing to arrive at), so it falls back to its + // departure — expressed below as COALESCE(plannedArrivalAt, plannedDepartureAt). let reopenedCount = 0; let checkinClosedCount = 0; for (const [mins, byRoute] of byMins) { const cutoffAt = new Date(now.getTime() + mins * 60 * 1000); for (const [routeId, stationIds] of byRoute) { // Revert first: if the cutoff was reduced, stops that were prematurely closed - // should reopen (departure is still beyond the new cutoff window). + // should reopen (arrival is still beyond the new cutoff window). const reverted = await this.prisma.tripStopTime.updateMany({ where: { status: 'CHECKIN_CLOSED', - plannedDepartureAt: { gt: cutoffAt }, + OR: [ + { plannedArrivalAt: { gt: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { gt: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -103,7 +109,10 @@ export class TasksService { const closed = await this.prisma.tripStopTime.updateMany({ where: { status: 'OPEN', - plannedDepartureAt: { lte: cutoffAt }, + OR: [ + { plannedArrivalAt: { lte: cutoffAt } }, + { AND: [{ plannedArrivalAt: null }, { plannedDepartureAt: { lte: cutoffAt } }] }, + ], stationId: { in: stationIds }, schedule: { routeId }, }, @@ -169,8 +178,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, }, @@ -179,12 +188,17 @@ export class TasksService { for (const booking of bookings) { try { const createdAt = booking.createdAt as Date; - // Use the booking's origin-segment departure and the route's own check-in window. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window (falling back to the route + // default), same resolution as holdSeats/search. const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30; + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? 30; if (dep <= now) continue; // segment has already departed; cancel job handles clean-up const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime(); @@ -230,13 +244,28 @@ export class TasksService { // ── Cancel bookings whose payment deadline has passed ───────────────────── private async cancelExpiredPendingBookings(now: Date) { - const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - const departureCutoff = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000); + const twoHoursAgo = new Date(now.getTime() - MAX_PAYMENT_HOURS * 60 * 60 * 1000); - // payment_deadline = MIN(createdAt + 2h, departureAt - 30min) + // The departure pre-filter below is a query-scoping optimization only — the real + // deadline check happens per-row further down. It must be widened to the largest + // configured checkinMinutes across all routes/stops, or a booking on a route with a + // cutoff bigger than the CUTOFF_MINUTES default would never even be fetched here, + // silently never getting auto-cancelled. + const [maxRouteCutoff, maxStopCutoff] = await Promise.all([ + this.prisma.route.aggregate({ _max: { checkinMinutesBefore: true } }), + this.prisma.routeStop.aggregate({ _max: { checkinMinutesBefore: true } }), + ]); + const effectiveMaxCutoffMinutes = Math.max( + CUTOFF_MINUTES, + maxRouteCutoff._max.checkinMinutesBefore ?? 0, + maxStopCutoff._max.checkinMinutesBefore ?? 0, + ); + const departureCutoff = new Date(now.getTime() + effectiveMaxCutoffMinutes * 60 * 1000); + + // payment_deadline = MIN(createdAt + 2h, segment_arrival - checkinMinutes) // Deadline is reached when either branch of the MIN is in the past: - // (a) createdAt ≤ now - 2h → 2-hour max window elapsed - // (b) departureAt ≤ now + 30min → departure within 30 min + // (a) createdAt ≤ now - 2h → 2-hour max window elapsed + // (b) departureAt ≤ now + effectiveMaxCutoff → within the widest possible cutoff window const expiredBookings = await this.prisma.booking.findMany({ where: { status: 'PENDING_PAYMENT', @@ -250,8 +279,8 @@ export class TasksService { include: { originStation: { select: { name: true } }, destinationStation: { select: { name: true } }, - stopTimes: { select: { stationId: true, plannedDepartureAt: true } }, - route: { select: { checkinMinutesBefore: true } }, + stopTimes: { select: { stationId: true, plannedArrivalAt: true, plannedDepartureAt: true } }, + route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } }, }, }, paymentIntent: { select: { method: true } }, @@ -264,14 +293,19 @@ export class TasksService { for (const booking of expiredBookings) { try { // Re-verify exact deadline to avoid racing with a concurrent payment confirmation. - // Use the booking's origin-segment departure for the deadline so that a B→C booking - // on an A→B→C→D schedule gets the correct payment window anchored to B, not A. + // Use the booking's origin-segment estimated arrival (falling back to its departure + // for the first stop) and that stop's own check-in window, so a B→C booking on an + // A→B→C→D schedule gets the correct payment window anchored to B, not A. const createdAt = booking.createdAt as Date; const originStop = (booking.schedule as any).stopTimes?.find( (s: any) => s.stationId === (booking as any).originStationId, ); - const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; - const paymentDeadline = computePaymentDeadline(createdAt, dep); + const dep = (originStop?.plannedArrivalAt ?? originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date; + const originRouteStop = (booking.schedule as any).route?.stops?.find( + (s: any) => s.stationId === (booking as any).originStationId, + ); + const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? (booking.schedule as any).route?.checkinMinutesBefore ?? CUTOFF_MINUTES; + const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes); if (now < paymentDeadline) continue; // 1a. Release held seats (Journey rows are the occupancy source of truth once paid) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 4ecaeb267..869a8578a 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -14,10 +14,11 @@ export class TicketsController { @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate tickets for all confirmed bookings that are missing them', - description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed counts.', + description: 'Finds every CONFIRMED booking with no ticket rows and attempts to generate tickets for each. Returns a summary of processed/generated/failed/remaining counts. Call repeatedly until remaining=0.', }) - generateMissing() { - return this.service.generateMissing(); + @ApiQuery({ name: 'limit', required: false, description: 'Max bookings to process per call (default 10)' }) + generateMissing(@Query('limit') limit?: string) { + return this.service.generateMissing(limit ? parseInt(limit, 10) : 10); } @Post('smart-assign/:bookingId') diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index e6f3b0781..6cd94ec6c 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service'; import { AuditService } from '../../common/audit.service'; +import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; import * as QRCode from 'qrcode'; interface OfflineValidation { @@ -129,6 +130,7 @@ export class TicketsService { : { fullName: 'Guest', email: guestEmail, phone: guestPhone }; + const segment = resolveBookingSegment(t.booking?.schedule, t.booking?.originStationId, t.booking?.destinationStationId); return { id: t.id, ticketNumber: t.barcodePayload, @@ -152,20 +154,14 @@ export class TicketsService { contactPhone: t.booking?.contactPhone, returnSchedule: t.booking?.returnSchedule ?? null, seats: t.booking?.seats ?? [], - originStation: (() => { - const id = t.booking?.originStationId; - if (!id) return t.booking?.schedule?.originStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.originStation ?? null; - })(), - destinationStation: (() => { - const id = t.booking?.destinationStationId; - if (!id) return t.booking?.schedule?.destinationStation ?? null; - const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id); - return stop?.station ?? t.booking?.schedule?.destinationStation ?? null; - })(), + originStation: segment.origin, + destinationStation: segment.destination, }, - schedule: t.booking?.schedule, + schedule: t.booking?.schedule ? { + ...t.booking.schedule, + departureAt: segment.departureAt, + arrivalAt: segment.arrivalAt, + } : null, seat: t.seat ? { id: t.seat.id, seatNumber: t.seat.seatNumber, @@ -643,11 +639,14 @@ export class TicketsService { throw new NotFoundException('No ticket found for this booking'); } - // Check if ticket date matches today + // Check if ticket date matches today. Boarding window is relative to the + // passenger's actual boarding stop, not the train's origin — for a mid-route + // boarding these differ. const today = new Date(); - - if ((booking as any).schedule?.departureAt) { - const departureTime = new Date((booking as any).schedule.departureAt); + const boardingSegment = resolveBookingSegment((booking as any).schedule, (booking as any).originStationId, (booking as any).destinationStationId); + + if (boardingSegment.departureAt) { + const departureTime = new Date(boardingSegment.departureAt); const boardingWindowHours = await this.systemConfig.getNumber(CONFIG_KEYS.BOARDING_WINDOW_HOURS_BEFORE_DEPARTURE); const boardingOpenTime = new Date(departureTime.getTime() - boardingWindowHours * 60 * 60 * 1000); @@ -673,18 +672,6 @@ export class TicketsService { // Send notifications after successful boarding await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND'); - // Resolve user-selected segment rather than the full schedule route - const _schedStops = (booking as any).schedule?.stopTimes ?? []; - const _resolveStation = (id: string | null | undefined, fallback: any) => { - if (id) { - const found = _schedStops.find((st: any) => st.stationId === id)?.station; - if (found) return found; - } - return fallback; - }; - const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation); - const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation); - return { success: true, message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`, @@ -693,11 +680,11 @@ export class TicketsService { ticketNumber: ticket.barcodePayload, bookingRef: booking.bookingRef, passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A', - route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`, + route: `${boardingSegment.origin?.name || 'N/A'} → ${boardingSegment.destination?.name || 'N/A'}`, seat: seatNumber, coach: coachNumber, trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A', - departureTime: (booking as any).schedule?.departureAt, + departureTime: boardingSegment.departureAt, boardedAt: result.validatedAt, leg: result.leg || 'OUTBOUND', bookingType: booking.bookingType, @@ -905,15 +892,21 @@ export class TicketsService { }; } - async generateMissing(): Promise<{ processed: number; generated: number; failed: number; details: any[] }> { - const confirmedWithNoTickets = await this.prisma.booking.findMany({ - where: { - status: 'CONFIRMED', - tickets: { none: {} }, - paymentIntent: { status: 'SUCCEEDED' }, - }, - select: { id: true, bookingRef: true }, - }); + async generateMissing(limit = 10): Promise<{ processed: number; generated: number; failed: number; remaining: number; details: any[] }> { + const missingWhere = { + status: 'CONFIRMED' as const, + tickets: { none: {} }, + paymentIntent: { status: 'SUCCEEDED' as const }, + }; + + const [confirmedWithNoTickets, totalRemaining] = await Promise.all([ + this.prisma.booking.findMany({ + where: missingWhere, + select: { id: true, bookingRef: true }, + take: limit, + }), + this.prisma.booking.count({ where: missingWhere }), + ]); const details: any[] = []; let generated = 0; @@ -930,7 +923,13 @@ export class TicketsService { } } - return { processed: confirmedWithNoTickets.length, generated, failed, details }; + return { + processed: confirmedWithNoTickets.length, + generated, + failed, + remaining: Math.max(0, totalRemaining - confirmedWithNoTickets.length), + details, + }; } async delete(id: string) { diff --git a/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts new file mode 100644 index 000000000..93c0386b6 --- /dev/null +++ b/apps/edr-passenger-api/test/checkin-cutoff.e2e-spec.ts @@ -0,0 +1,204 @@ +/** + * Per-station check-in cutoff — proves booking closure is now based on each stop's own + * ESTIMATED ARRIVAL time (computed from RouteStop.travelMinutesToStop), not the schedule's + * overall departure. The regression this guards: before this change, all stops effectively + * shared one cutoff basis, so a later station could be wrongly blocked (or an earlier one + * wrongly left open) together with the rest of the route. + * + * Uses the slim harness (SchedulesService, real Nest DI) for schedule creation — this exercises + * the actual cumulative travel-time interpolation in SchedulesService.createSchedule. SeatsService + * and TasksService are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule + * → RabbitMQ, which the slim harness deliberately avoids — see test/setup/slim-app.ts), so they're + * instantiated directly with a real Prisma + stubbed collaborators, mirroring the Tier-2 pattern in + * money-integrity.e2e-spec.ts. + */ +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { TasksService } from "../src/modules/tasks/tasks.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, DISTANCE, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Creates a fresh Train + TrainSchedule on the seed-core route via the real interpolation logic. */ +async function createTestSchedule( + harness: ServiceHarness, + schedules: SchedulesService, + opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }, +) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + + // createSchedule now rejects a schedule with zero coaches (see schedules.service.ts's + // "must have at least one coach assigned" guard) — the coach has to exist and be passed + // via coachIds BEFORE creation, not attached afterward. + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + + const schedule = await schedules.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + + return { schedule, seats }; +} + +describe("Check-in cutoff — arrival-time basis, per-station independence", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let tasksService: TasksService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const systemConfig = new SystemConfigService(harness.prisma as any); + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + tasksService = new TasksService(harness.prisma as any, asyncStub(), asyncStub()); + }); + + afterAll(async () => { + await harness?.close(); + }); + + it("a later station remains independently bookable after an earlier station's cutoff has passed", async () => { + await resetAndSeedCore(harness.prisma); + + // dep only 5 min out (createSchedule requires a future departureAt). Route-level default + // checkinMinutesBefore is 30 (schema default, unset here), so A's cutoff (dep - 30min) is + // already ~25 min in the past by the time this runs — but B, with a 60-min travel time from + // A, has an arrival far enough out (dep + 60min) that its own cutoff (arrival - 30min) is + // still ~35 min in the future. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-A-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + passengers: [{ passengerId: "11111111-1111-4111-8111-111111111111", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within/i); + + const held = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "22222222-2222-4222-8222-222222222222", seatId: seats[1].id }], + } as any); + expect(held).toBeTruthy(); + }); + + it("a stop-level checkinMinutesBefore override wins over the route-level default", async () => { + // Override B with a LARGE cutoff (90 min) — under the route default (30 min) this exact + // schedule's B segment would still be OPEN (see previous test), so a rejection here proves + // the stop-level override, not the default, is what's actually being applied. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule, seats } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-B-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await expect( + seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "33333333-3333-4333-8333-333333333333", seatId: seats[0].id }], + } as any), + ).rejects.toThrow(/cannot be held within 90 minute/i); + }); + + it("syncScheduleStatuses closes only the specific stops past their own arrival-based cutoff", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, + data: { travelMinutesToStop: 60 }, + }); + await harness.prisma.routeStop.update({ + where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, + data: { travelMinutesToStop: 40 }, + }); + + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-C-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + await tasksService.syncScheduleStatuses(); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const byStation = Object.fromEntries(stopTimes.map((s) => [s.stationId, s.status])); + expect(byStation[IDS.stationA]).toBe("CHECKIN_CLOSED"); + expect(byStation[IDS.stationB]).toBe("OPEN"); + expect(byStation[IDS.stationC]).toBe("OPEN"); + }); + + it("a stop missing travelMinutesToStop falls back to distance interpolation without failing schedule creation", async () => { + await resetAndSeedCore(harness.prisma); // no travelMinutesToStop set on any stop + + const dep = new Date(Date.now() + 60 * 60_000); + const arr = new Date(dep.getTime() + 240 * 60_000); // 4h, matches seed-ui's convention + const { schedule } = await createTestSchedule(harness, schedulesService, { + trainNumber: `CUTOFF-D-${Date.now()}`, + departureAt: dep, + arrivalAt: arr, + }); + + const stopTimes = await harness.prisma.tripStopTime.findMany({ + where: { scheduleId: schedule.id }, + orderBy: { sequence: "asc" }, + }); + const totalDuration = arr.getTime() - dep.getTime(); + const bProgress = DISTANCE.B / DISTANCE.C; + const expectedBArrival = new Date(dep.getTime() + totalDuration * bProgress); + + const bStop = stopTimes.find((s) => s.stationId === IDS.stationB)!; + expect(bStop.plannedArrivalAt?.getTime()).toBe(expectedBArrival.getTime()); + }); +}); diff --git a/apps/edr-passenger-api/test/fixtures/seed-core.ts b/apps/edr-passenger-api/test/fixtures/seed-core.ts index 098489dab..115df217f 100644 --- a/apps/edr-passenger-api/test/fixtures/seed-core.ts +++ b/apps/edr-passenger-api/test/fixtures/seed-core.ts @@ -23,6 +23,17 @@ export const IDS = { /** Route stop distances (km from origin). A=0, B=100, C=250 → A→B is 100km, A→C is 250km. */ export const DISTANCE = { A: 0, B: 100, C: 250 } as const; +/** + * Optional per-stop check-in-cutoff/travel-time overrides, keyed by station label (A/B/C). + * Lets a spec seed a distinct `checkinMinutesBefore` override and/or `travelMinutesToStop` + * per stop without changing the zero-arg call sites the other specs rely on. + */ +export interface RouteStopOverrides { + A?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + B?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; + C?: { checkinMinutesBefore?: number; travelMinutesToStop?: number }; +} + /** * FX rate chosen so the seat-class distance formula (which multiplies an ETB/km rate by the * USD→ETB rate — see fare-engine.service.ts:157) yields whole ETB-minor amounts. 100 makes the @@ -45,7 +56,7 @@ export async function truncateAllPassenger(prisma: PrismaClient): Promise } /** Insert the deterministic core graph. Call after truncateAllPassenger. */ -export async function seedCore(prisma: PrismaClient): Promise { +export async function seedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { const past = new Date("2020-01-01T00:00:00.000Z"); await prisma.coachType.create({ @@ -103,9 +114,9 @@ export async function seedCore(prisma: PrismaClient): Promise { active: true, stops: { create: [ - { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A }, - { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B }, - { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C }, + { stationId: IDS.stationA, sequence: 1, distanceKm: DISTANCE.A, ...stopOverrides.A }, + { stationId: IDS.stationB, sequence: 2, distanceKm: DISTANCE.B, ...stopOverrides.B }, + { stationId: IDS.stationC, sequence: 3, distanceKm: DISTANCE.C, ...stopOverrides.C }, ], }, }, @@ -122,7 +133,7 @@ export async function seedCore(prisma: PrismaClient): Promise { } /** Convenience: reset + seed in one call. */ -export async function resetAndSeedCore(prisma: PrismaClient): Promise { +export async function resetAndSeedCore(prisma: PrismaClient, stopOverrides: RouteStopOverrides = {}): Promise { await truncateAllPassenger(prisma); - await seedCore(prisma); + await seedCore(prisma, stopOverrides); } diff --git a/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts new file mode 100644 index 000000000..dfcc298fd --- /dev/null +++ b/apps/edr-passenger-api/test/stop-based-booking-segment.e2e-spec.ts @@ -0,0 +1,366 @@ +/** + * Stop-based (mid-route) booking — segment correctness suite. + * + * Regression coverage for three bugs reported against live stop-based bookings: + * + * 1. Search results (SearchService.buildScheduleResult) showed the train's overall + * departure/arrival instead of the selected origin/destination stop's own time — e.g. + * searching B→C on a A→B→C schedule showed A's departure time, not B's. Rooted in + * resolving the boarding/alighting STATION correctly for a mid-route segment while still + * reading TIME off the schedule's full-route span. Fixed via resolveBookingSegment() (also + * used by BookingsService, TicketsService, NotificationsService) — see + * src/common/utils/segment-resolver.utils.ts. + * 2. GuestBookingService's 30-minute booking cutoff was computed off the train's origin + * departure regardless of where the passenger actually boards, so a schedule whose origin + * had already departed >30min ago wrongly blocked booking a downstream segment that + * hadn't closed yet. + * 3. Even after (2), GuestBookingService still enforced a hardcoded, non-configurable 30 + * minutes — ignoring RouteStop/Route.checkinMinutesBefore, the SAME configurable cutoff + * that SeatsService.holdSeats and the search step already enforce. A passenger who passed + * the earlier steps under a shorter (or longer) CONFIGURED cutoff could still be wrongly + * rejected — or wrongly allowed — at /booking/review with "not accepted within 30 minutes + * of departure". Fixed by having GuestBookingService use the same resolveCheckinCutoff() + * utility as SeatsService.holdSeats and SearchService — see + * src/common/utils/checkin-cutoff.utils.ts. + * + * Uses the slim harness (real Nest DI) for SchedulesService — this exercises the actual + * cumulative travel-time interpolation in SchedulesService.createSchedule, same as + * checkin-cutoff.e2e-spec.ts. SeatsService/SearchService/BookingsService/GuestBookingService + * are NOT in the slim harness's DOMAIN_MODULES (they pull in NotificationsModule → RabbitMQ), + * so they're instantiated directly with a real Prisma + stubbed collaborators, mirroring the + * Tier-2 pattern in money-integrity.e2e-spec.ts. + */ +import { IdDocumentType } from "@prisma/client"; +import { SchedulesService } from "../src/modules/schedules/schedules.service"; +import { SeatsService } from "../src/modules/seats/seats.service"; +import { SegmentsService } from "../src/modules/segments/segments.service"; +import { SearchService } from "../src/modules/search/search.service"; +import { CurrencyService } from "../src/modules/currency/currency.service"; +import { FareEngineService } from "../src/modules/fare-engine/fare-engine.service"; +import { SystemConfigService } from "../src/modules/system-config/system-config.service"; +import { BookingsService } from "../src/modules/bookings/bookings.service"; +import { GuestBookingService } from "../src/modules/bookings/guest-booking.service"; +import { createServiceHarness, ServiceHarness } from "./setup/slim-app"; +import { IDS, resetAndSeedCore } from "./fixtures/seed-core"; + +/** A Proxy whose every property is an async no-op — satisfies unused collaborator method calls. */ +function asyncStub(): any { + return new Proxy({}, { get: () => async () => undefined }); +} + +/** Formats a Date as a YYYY-MM-DD string in the process's local timezone (EAT on this host — + * matches search.service.ts's "+03:00" date-matching window). */ +function localDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +describe("Stop-based booking — segment time & cutoff correctness", () => { + let harness: ServiceHarness; + let schedulesService: SchedulesService; + let seatsService: SeatsService; + let searchService: SearchService; + let bookingsService: BookingsService; + let guestBookingService: GuestBookingService; + + beforeAll(async () => { + harness = await createServiceHarness(); + schedulesService = await harness.moduleRef.resolve(SchedulesService); + const currencyService = harness.moduleRef.get(CurrencyService); + const fareEngine = harness.moduleRef.get(FareEngineService); + const segmentsService = new SegmentsService(harness.prisma as any); + const systemConfig = new SystemConfigService(harness.prisma as any); + + searchService = new SearchService(harness.prisma as any, currencyService, fareEngine, segmentsService); + // holdSeats() itself never touches segmentsService (only getSeatMap/availability-map + // callers do), so stubbing it here is safe — mirrors checkin-cutoff.e2e-spec.ts. + seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub()); + bookingsService = new BookingsService( + harness.prisma as any, + asyncStub(), // dataSource + seatsService, + { emit: () => true } as any, // eventEmitter + asyncStub(), // verifaydaService + currencyService, + fareEngine, + asyncStub(), // auditService + ); + guestBookingService = new GuestBookingService( + harness.prisma as any, + seatsService, + asyncStub(), // verifaydaService — never reached: test passengers use PASSPORT, not NATIONAL_ID + currencyService, + asyncStub(), // passengerAuthService — never reached: no createAccount in these DTOs + fareEngine, + { emit: () => true } as any, // eventEmitter + ); + }); + + afterAll(async () => { + await harness?.close(); + }); + + /** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation + * (createSchedule now rejects a schedule with zero coaches). */ + async function createTestSchedule(opts: { trainNumber: string; departureAt: Date; arrivalAt: Date }) { + const train = await harness.prisma.train.create({ + data: { number: opts.trainNumber, name: `Test ${opts.trainNumber}` }, + }); + const coach = await harness.prisma.coach.create({ + data: { coachTypeId: IDS.coachType, number: `${opts.trainNumber}-C1`, capacity: 4, sequence: 1, status: "ACTIVE" }, + }); + const seats = await Promise.all( + ["1A", "1B", "1C", "1D"].map((seatNumber, i) => + harness.prisma.seat.create({ + data: { coachId: coach.id, seatNumber, row: 1, col: seatNumber.slice(-1), isWindow: i === 0, isAisle: i === 1 }, + }), + ), + ); + const schedule = await schedulesService.createSchedule({ + trainId: train.id, + routeId: IDS.route, + departureAt: opts.departureAt.toISOString(), + arrivalAt: opts.arrivalAt.toISOString(), + coachIds: [coach.id], + } as any); + return { schedule, seats }; + } + + function foreignPassenger(seatId: string) { + return { + seatId, + passengerName: "Test Passenger", + dateOfBirth: "1990-01-01", + idDocumentType: IdDocumentType.PASSPORT, + passportNumber: "X123456", + passportCountry: "Djibouti", + nationality: "Djiboutian", + }; + } + + describe("search results (SearchService.searchTrips)", () => { + it("shows the boarding stop's own departure time, not the schedule's full-route (station A) departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); // A's departure, 3h out + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DEP-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + // Would equal A's departure (`dep`) under the old (buggy) schedule.departureAt fallback. + expect(new Date(found.departureAt).getTime()).not.toBe(dep.getTime()); + }); + + it("shows the alighting stop's own arrival time, not the schedule's full-route (station C) arrival", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // C's arrival + const { schedule } = await createTestSchedule({ trainNumber: `SEG-ARR-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const result: any = await searchService.searchTrips({ + originStationId: IDS.stationA, + destinationStationId: IDS.stationB, + date: localDateStr(dep), + adultCount: 1, + } as any); + + const found = result.outbound.find((o: any) => o.scheduleId === schedule.id); + expect(found).toBeTruthy(); + + const expectedBArrival = new Date(dep.getTime() + 60 * 60_000); + expect(new Date(found.arrivalAt).getTime()).toBe(expectedBArrival.getTime()); + // Would equal C's arrival (`arr`) under the old (buggy) schedule.arrivalAt fallback. + expect(new Date(found.arrivalAt).getTime()).not.toBe(arr.getTime()); + }); + }); + + describe("guest booking cutoff (GuestBookingService.createGuestBooking)", () => { + it("does NOT block booking a downstream segment whose own boarding stop is still far out, even though the schedule's origin already departed", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 150 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // A departs in 5min (already inside a naive 30-min-before-departure cutoff), but B — the + // passenger's actual boarding stop — is A+150min out (~2.5h), comfortably clear. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 190 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-OK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "66666666-6666-4666-8666-666666666666", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + expect(booking.originStationId).toBe(IDS.stationB); + expect(booking.destinationStationId).toBe(IDS.stationC); + }); + + it("still blocks booking when the passenger's own boarding stop is itself within 30 minutes of its departure", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+10min (~15min from now) — inside the 30-min cutoff. Hold is created + // directly (bypassing SeatsService.holdSeats' own, separately-tested arrival-based + // cutoff — see checkin-cutoff.e2e-spec.ts) to isolate GuestBookingService's own check. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 50 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-BLOCK-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "77777777-7777-4777-8777-777777777777", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 30 minutes/i); + }); + + it("honors a stop-level checkinMinutesBefore override SHORTER than 30 minutes — booking succeeds inside the old hardcoded window", async () => { + // Regression for the reported bug: /booking/review still rejected a booking with + // "not accepted within 30 minutes of departure" even after the passenger passed the + // earlier steps under a shorter CONFIGURED cutoff — because createGuestBooking used to + // enforce its own separate, hardcoded 30 minutes regardless of RouteStop/Route + // .checkinMinutesBefore. B's own configured cutoff here is 10 minutes. + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 10 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 20 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+20min (~25min from now) — inside the OLD hardcoded 30-min cutoff, + // but outside B's own configured 10-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 60 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await seatsService.holdSeats({ + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + passengers: [{ passengerId: "88888888-8888-4888-8888-888888888888", seatId: seats[0].id }], + } as any); + + const booking: any = await guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: (hold as any).holdId, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any); + + expect(booking.bookingRef).toBeTruthy(); + }); + + it("honors a stop-level checkinMinutesBefore override LONGER than 30 minutes — still blocks past the old hardcoded window", async () => { + await resetAndSeedCore(harness.prisma, { B: { checkinMinutesBefore: 90 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 40 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + // B departs at dep+40min (~45min from now) — outside the OLD hardcoded 30-min cutoff + // (would have wrongly been allowed), but inside B's own configured 90-min cutoff. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 80 * 60_000); + const { schedule, seats } = await createTestSchedule({ trainNumber: `CUTOFF-CFG2-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const hold = await harness.prisma.seatHold.create({ + data: { + scheduleId: schedule.id, + seatIds: [seats[0].id], + passengerId: "99999999-9999-4999-8999-999999999999", + expiresAt: new Date(Date.now() + 10 * 60_000), + }, + }); + + await expect( + guestBookingService.createGuestBooking({ + scheduleId: schedule.id, + holdId: hold.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + seatClassId: IDS.seatClassLocal, + passengers: [foreignPassenger(seats[0].id)], + } as any), + ).rejects.toThrow(/not accepted within 90 minutes/i); + }); + }); + + describe("booking detail & list segment resolution (BookingsService)", () => { + it("getByRef and findByPassengerId show the boarding stop's own time and station, not the schedule's full-route span", async () => { + await resetAndSeedCore(harness.prisma); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 2 } }, data: { travelMinutesToStop: 60 } }); + await harness.prisma.routeStop.update({ where: { routeId_sequence: { routeId: IDS.route, sequence: 3 } }, data: { travelMinutesToStop: 40 } }); + + const dep = new Date(Date.now() + 3 * 60 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); + const { schedule } = await createTestSchedule({ trainNumber: `SEG-DETAIL-${Date.now()}`, departureAt: dep, arrivalAt: arr }); + + const passenger = await harness.prisma.passenger.create({ data: {} }); + const booking = await harness.prisma.booking.create({ + data: { + bookingRef: `SEGDET${Date.now()}`, + passengerId: passenger.id, + scheduleId: schedule.id, + originStationId: IDS.stationB, + destinationStationId: IDS.stationC, + status: "CONFIRMED", + totalMinor: 10000, + displayCurrency: "ETB", + }, + }); + + const expectedBDeparture = new Date(dep.getTime() + 60 * 60_000); + + const detail: any = await bookingsService.getByRef(booking.bookingRef); + expect(new Date(detail.schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(detail.schedule.origin.id).toBe(IDS.stationB); + expect(detail.schedule.destination.id).toBe(IDS.stationC); + + const list: any = await bookingsService.findByPassengerId(passenger.id); + expect(list.items).toHaveLength(1); + expect(new Date(list.items[0].schedule.departureAt).getTime()).toBe(expectedBDeparture.getTime()); + expect(list.items[0].schedule.originStation.id).toBe(IDS.stationB); + }); + }); +}); diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index feca7e3b5..251d034b1 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit, Trash2, X, Search, Train, Save, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; interface RouteStop { stationId: string; @@ -17,6 +18,7 @@ interface RouteStop { distanceKm?: number; distanceFromOrigin?: number; checkinMinutesBefore?: number; + travelMinutesToStop?: number; } type Tab = 'routes' | 'coaches'; @@ -175,8 +177,18 @@ export default function RoutesPage() { const [destinationDistance, setDestinationDistance] = useState(undefined); const [originCheckinMinutes, setOriginCheckinMinutes] = useState(undefined); const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState(undefined); + const [destinationTravelMinutes, setDestinationTravelMinutes] = useState(undefined); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null }); const [search, setSearch] = useState(''); + const [error, setError] = useState(null); + const errorBannerRef = useRef(null); + + // The form scrolls internally (long stop lists push the error banner above the fold), so a + // submit failure can land silently off-screen with no visible indication anything went wrong. + // Scroll the banner into view whenever a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); const queryClient = useQueryClient(); const { data: routes, isLoading: routesLoading } = useQuery({ @@ -198,6 +210,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to create route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -207,6 +224,11 @@ export default function RoutesPage() { queryClient.invalidateQueries({ queryKey: ['routes'] }); setShowModal(false); setEditingRoute(null); + setError(null); + }, + onError: (e: any) => { + const msg = e?.response?.data?.message || e?.message || 'Failed to update route'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -244,6 +266,8 @@ export default function RoutesPage() { const sortedMiddleStops = stops; // distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm) + // travelMinutesToStop = minutes of travel from the PREVIOUS stop, used to estimate this + // stop's arrival time. The origin (sequence 1) has no predecessor, so it gets none. const stopsArray = [ { stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined }, ...sortedMiddleStops.map((stop, idx) => ({ @@ -251,12 +275,14 @@ export default function RoutesPage() { sequence: idx + 2, distanceKm: stop.distanceFromOrigin || 0, checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined, + travelMinutesToStop: stop.travelMinutesToStop ?? undefined, })), { stationId: destinationStationId, sequence: sortedMiddleStops.length + 2, distanceKm: destinationDistance || 0, checkinMinutesBefore: destinationCheckinMinutes ?? undefined, + travelMinutesToStop: destinationTravelMinutes ?? undefined, }, ]; @@ -266,8 +292,10 @@ export default function RoutesPage() { name: formData.get('name') as string, description: formData.get('description') as string || undefined, active: !editingRoute ? (formData.get('active') !== 'false') : undefined, - effectiveFrom: formData.get('effectiveFrom') as string, - effectiveUntil: formData.get('effectiveUntil') as string || undefined, + effectiveFrom: eatLocalToISO(formData.get('effectiveFrom') as string), + // null (not undefined) so clearing the field on an edit explicitly clears effectiveUntil + // server-side, instead of being silently dropped as "no change". + effectiveUntil: formData.get('effectiveUntil') ? eatLocalToISO(formData.get('effectiveUntil') as string) : null, checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined, stops: stopsArray, }; @@ -280,7 +308,12 @@ export default function RoutesPage() { }; const addStop = () => { - setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: 0 }]); + // distanceKm must be cumulative and strictly increasing (enforced server-side) — defaulting + // every new stop to 0 made each one collide with the previous, so simply clicking "Add + // Intermediate Stop" a few times and saving without hand-editing every distance always failed + // validation. Default each new stop's distance a step past whatever precedes it instead. + const lastDistance = stops.length > 0 ? (stops[stops.length - 1].distanceFromOrigin ?? 0) : 0; + setStops([...stops, { stationId: '', sequence: stops.length + 1, distanceFromOrigin: lastDistance + 10 }]); }; const removeStop = (index: number) => { @@ -390,14 +423,17 @@ export default function RoutesPage() { setDestinationStationId(destStop.stationId); setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined); setDestinationDistance(destStop.distanceKm || 0); + setDestinationTravelMinutes(destStop.travelMinutesToStop ?? undefined); setStops(routeStops.slice(1, -1).map((s: any) => ({ stationId: s.stationId, sequence: s.sequence, distanceKm: s.distanceKm, distanceFromOrigin: s.distanceKm || 0, checkinMinutesBefore: s.checkinMinutesBefore ?? undefined, + travelMinutesToStop: s.travelMinutesToStop ?? undefined, }))); } + setError(null); setShowModal(true); } finally { setEditLoading(false); @@ -436,7 +472,9 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); + setError(null); setShowModal(true); }} > @@ -515,13 +553,20 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} title={`${editingRoute ? 'Edit' : 'Add'} Route`} size="lg" > + {error && ( +
+ {error} +
+ )} {editingRoute && (

⚠ Warning

@@ -647,7 +692,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveFrom" className="input" - defaultValue={editingRoute?.effectiveFrom ? new Date(editingRoute.effectiveFrom).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16)} + defaultValue={editingRoute?.effectiveFrom ? isoToEATLocal(editingRoute.effectiveFrom) : isoToEATLocal(new Date())} required />
@@ -657,7 +702,7 @@ export default function RoutesPage() { type="datetime-local" name="effectiveUntil" className="input" - defaultValue={editingRoute?.effectiveUntil ? new Date(editingRoute.effectiveUntil).toISOString().slice(0, 16) : ''} + defaultValue={editingRoute?.effectiveUntil ? isoToEATLocal(editingRoute.effectiveUntil) : ''} />
@@ -665,7 +710,7 @@ export default function RoutesPage() {
- Drag to rearrange · Cutoff min overrides route check-in window per stop (leave blank to inherit) + Drag to rearrange · Travel min estimates arrival from the previous stop (falls back to distance if blank) · Cutoff min overrides route check-in window per stop (leave blank to inherit)
@@ -741,6 +786,17 @@ export default function RoutesPage() { required />
+
+ updateStop(index, 'travelMinutesToStop', e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> +
)}
+
+ {destinationStationId && ( + setDestinationTravelMinutes(e.target.value ? parseInt(e.target.value) : undefined)} + min={1} + title="Travel time in minutes from the previous stop, used to estimate this stop's arrival time" + /> + )} +
@@ -833,8 +902,10 @@ export default function RoutesPage() { setDestinationStationId(''); setDestinationCheckinMinutes(undefined); setDestinationDistance(undefined); + setDestinationTravelMinutes(undefined); setStops([]); setSearch(''); + setError(null); }} > Cancel diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 0f37825fb..dcaf6261c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; import DataTable from '@/components/ui/DataTable'; @@ -12,6 +12,7 @@ import { routeCoachTemplatesApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; +import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; interface Schedule { @@ -60,8 +61,15 @@ export default function SchedulesPage() { { isOpen: false, item: null } ); const [error, setError] = useState(null); + const errorBannerRef = useRef(null); const queryClient = useQueryClient(); + // These modals can scroll internally — a submit failure can land silently off-screen with no + // visible indication anything went wrong. Scroll the banner into view when a new error appears. + useEffect(() => { + if (error) errorBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, [error]); + const [bulkForm, setBulkForm] = useState({ trainId: '', routeId: '', @@ -167,7 +175,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + const msg = err.response?.data?.message || 'Failed to generate schedules'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -181,7 +190,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + const msg = err.response?.data?.message || 'Failed to create schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -195,7 +205,8 @@ export default function SchedulesPage() { setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + const msg = err.response?.data?.message || 'Failed to update schedule'; + setError(Array.isArray(msg) ? msg.join(' ') : msg); }, }); @@ -229,20 +240,6 @@ export default function SchedulesPage() { }, }); - /** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return an ISO string. */ - const eatLocalToISO = (local: string): string => { - if (!local) return ''; - return new Date(local + ':00+03:00').toISOString(); - }; - - /** Convert a UTC ISO string to a datetime-local value in EAT (UTC+3). */ - const isoToEATLocal = (iso: string): string => { - if (!iso) return ''; - const utcMs = new Date(iso).getTime(); - const eatMs = utcMs + 3 * 60 * 60 * 1000; - return new Date(eatMs).toISOString().slice(0, 16); - }; - const handleBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -685,7 +682,7 @@ export default function SchedulesPage() { size="xl" > - {error &&
{error}
} + {error &&
{error}
}
@@ -803,7 +800,7 @@ export default function SchedulesPage() { > {error && ( -
+
{error}
)} @@ -1022,7 +1019,7 @@ export default function SchedulesPage() { {editingSchedule && ( {error && ( -
+
{error}
)} diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index bd12def01..8731a1eb4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -111,7 +111,22 @@ export default function TicketsPage() { }); const generateMissingMutation = useMutation({ - mutationFn: () => ticketsApi.generateMissing(), + mutationFn: async () => { + let totalGenerated = 0; + let totalFailed = 0; + let totalProcessed = 0; + let remaining = 1; + + while (remaining > 0) { + const result: any = await ticketsApi.generateMissing(10); + totalGenerated += result.generated ?? 0; + totalFailed += result.failed ?? 0; + totalProcessed += result.processed ?? 0; + remaining = result.remaining ?? 0; + } + + return { generated: totalGenerated, processed: totalProcessed, failed: totalFailed }; + }, onSuccess: (result: any) => { queryClient.invalidateQueries({ queryKey: ['tickets'] }); queryClient.invalidateQueries({ queryKey: ['bookings-missing-tickets'] }); diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 0d35ed2d3..24d5f1b7f 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -224,7 +224,7 @@ export const ticketsApi = { return Array.isArray(response) ? { items: response } : response; }, getById: (id: string) => apiClient.get(`/tickets/${id}`), - generateMissing: () => apiClient.post('/tickets/generate-missing', {}), + generateMissing: (limit = 10) => apiClient.post(`/tickets/generate-missing?limit=${limit}`, {}), validate: (ticketId: string, data: any) => apiClient.post(`/tickets/${ticketId}/validate`, data), scanAndBoard: (qrCodeOrRef: string, data: any) => apiClient.post(`/tickets/scan-board/${encodeURIComponent(qrCodeOrRef)}`, data), regenerate: (ticketId: string) => apiClient.post(`/tickets/${ticketId}/regenerate`), diff --git a/apps/edr-passenger-web/backoffice/src/lib/timezone.ts b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts new file mode 100644 index 000000000..5c6097feb --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/lib/timezone.ts @@ -0,0 +1,21 @@ +/** + * The backend always interprets bare datetime strings (no timezone suffix) as East African + * Time (EAT, UTC+3) and stores everything as UTC — see parseEthiopianTime() in + * apps/edr-passenger-api/src/common/utils/timezone.utils.ts. These mirror that on the frontend + * so `` fields round-trip correctly regardless of the browser's + * own local timezone. + */ + +/** Parse a datetime-local string ("YYYY-MM-DDTHH:mm") as EAT (UTC+3) and return a UTC ISO string. */ +export function eatLocalToISO(local: string): string { + if (!local) return ''; + return new Date(local + ':00+03:00').toISOString(); +} + +/** Convert a UTC ISO string/Date to a datetime-local value ("YYYY-MM-DDTHH:mm") in EAT (UTC+3). */ +export function isoToEATLocal(iso: string | Date): string { + if (!iso) return ''; + const utcMs = new Date(iso).getTime(); + const eatMs = utcMs + 3 * 60 * 60 * 1000; + return new Date(eatMs).toISOString().slice(0, 16); +} diff --git a/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts new file mode 100644 index 000000000..b123f24bc --- /dev/null +++ b/e2e-ui/specs/backoffice/checkin-cutoff.spec.ts @@ -0,0 +1,100 @@ +import { test, expect } from "@playwright/test"; +import { API_URL, STATIONS, staffToken } from "../../fixtures/data"; +import { UI_IDS } from "../../../apps/edr-passenger-api/test/fixtures/seed-ui"; + +/** + * Track B — per-station check-in cutoff, driven by the backoffice route config API. Follows the + * same convention as config-validation.spec.ts: hits the passenger-api directly with a staff + * bearer token rather than driving the real DOM — the route-stop form has no data-testid hooks, + * so browser automation here would be selector-fragile for no extra coverage value. + * + * Creates its OWN route (not the shared ROUTE_ID from seed-ui) since updateRoute deletes and + * recreates all stops — mutating the shared fixture route would break every other spec that + * depends on its distances/times staying stable for the whole suite run. + */ +function auth() { + return { Authorization: `Bearer ${staffToken()}` }; +} + +test("BC-11 ✅ travelMinutesToStop drives each stop's estimated arrival independently of route-wide departure", async ({ request }) => { + const routeRes = await request.post(`${API_URL}/routes`, { + headers: auth(), + data: { + code: `E2E-CUTOFF-${Date.now()}`, + name: "E2E Check-in Cutoff Route", + effectiveFrom: "2020-01-01T00:00:00Z", + stops: [ + { stationId: STATIONS.A, sequence: 1 }, + { stationId: STATIONS.B, sequence: 2, travelMinutesToStop: 60 }, + { stationId: STATIONS.C, sequence: 3, travelMinutesToStop: 40 }, + ], + }, + }); + expect(routeRes.ok()).toBeTruthy(); + const route = (await routeRes.json())?.data ?? (await routeRes.json()); + const routeId = route.id; + + // Reuse the seeded coach via a route coach template so schedule creation auto-assigns real + // seats (createSchedule auto-applies any route coach template — see schedules.service.ts). + const templateRes = await request.put(`${API_URL}/routes/${routeId}/coaches`, { + headers: auth(), + data: { coaches: [{ coachId: UI_IDS.coach, positionNumber: 1 }] }, + }); + expect(templateRes.ok()).toBeTruthy(); + + // dep only 5 min out: A's cutoff (dep - 30min default) is already ~25 min in the past by the + // time this schedule is queried, but B's arrival (dep + 60min) keeps its own cutoff (arrival - + // 30min default) about 35 min in the future — proving the two stations close independently. + const dep = new Date(Date.now() + 5 * 60_000); + const arr = new Date(dep.getTime() + 100 * 60_000); // A->B 60min + B->C 40min + const scheduleRes = await request.post(`${API_URL}/schedules`, { + headers: auth(), + data: { trainId: UI_IDS.train, routeId, departureAt: dep.toISOString(), arrivalAt: arr.toISOString() }, + }); + expect(scheduleRes.ok()).toBeTruthy(); + const schedule = (await scheduleRes.json())?.data ?? (await scheduleRes.json()); + + const getRes = await request.get(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }); + expect(getRes.ok()).toBeTruthy(); + const full = (await getRes.json())?.data ?? (await getRes.json()); + const stopTimes: any[] = full.stopTimes ?? []; + const bStop = stopTimes.find((s) => s.stationId === STATIONS.B); + const cStop = stopTimes.find((s) => s.stationId === STATIONS.C); + + expect(new Date(bStop.plannedArrivalAt).getTime()).toBe(dep.getTime() + 60 * 60_000); + expect(new Date(cStop.plannedArrivalAt).getTime()).toBe(arr.getTime()); // last stop locked to overall arrival + + // A's own segment (no arrival — falls back to its departure) is already past its cutoff... + const rejectRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.A, + destinationStationId: STATIONS.B, + passengers: [{ passengerId: "44444444-4444-4444-8444-444444444444", seatId: "00000000-0000-4000-8000-000000009999" }], + }, + }); + expect(rejectRes.status()).toBe(400); + expect((await rejectRes.json())?.message ?? "").toMatch(/cannot be held within/i); + + // ...while B, whose own arrival is comfortably later, remains independently bookable. + const seatmapRes = await request.get(`${API_URL}/seats/seatmap/${schedule.id}`, { headers: auth() }); + expect(seatmapRes.ok()).toBeTruthy(); + const seatmap = (await seatmapRes.json())?.data ?? (await seatmapRes.json()); + const seatId = seatmap.coaches?.[0]?.seats?.[0]?.id; + expect(seatId).toBeTruthy(); + + const holdRes = await request.post(`${API_URL}/seats/hold`, { + headers: auth(), + data: { + scheduleId: schedule.id, + originStationId: STATIONS.B, + destinationStationId: STATIONS.C, + passengers: [{ passengerId: "55555555-5555-4555-8555-555555555555", seatId }], + }, + }); + expect(holdRes.ok()).toBeTruthy(); + + await request.delete(`${API_URL}/schedules/${schedule.id}`, { headers: auth() }).catch(() => {}); + await request.delete(`${API_URL}/routes/${routeId}?cascade=true`, { headers: auth() }).catch(() => {}); +}); diff --git a/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts index 6304f78f9..90d5e7f84 100644 --- a/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts +++ b/e2e/freight/cypress/e2e/flows/bulk_import_window_reopen.cy.ts @@ -73,11 +73,16 @@ describe("bulk import: dead first cycle — expire all, reopen, book again", { r ["BRA", "BRB"].forEach((suffix) => forceReservationExpiry(suffix)); withSchedule(DEPARTURE, (s) => { endPaymentPhase(s.id); + // When the reopen instant falls inside office hours, the 10s tick's + // guard-loop chains PRE_WINDOW straight into OPEN within the SAME tick + // (booking-window.service.ts advanceSchedule), so PRE_WINDOW is not a + // reliably observable resting state — assert the cycle left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. pollDb( - "window reopens (PRE_WINDOW, cycle 2 pending)", + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], - (row) => row?.window_phase === "PRE_WINDOW", + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", ); }); }); diff --git a/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts b/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts new file mode 100644 index 000000000..02fdb3efe --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_drawdown.cy.ts @@ -0,0 +1,140 @@ +/** + * GENERAL drawdown ledger (D+25) — one GENERAL contract capped at 60×20ft + * draws down across many bookings; the cap is enforced at CREATE, releases + * when a booking dies, and the contract stays CONTRACT_ACTIVE throughout: + * + * B1 30 → B2 20 → B3 asking 20 REJECTED ("only 10 remain") → B3' 10 → cap + * exhausted → B4 2 REJECTED → B3' expires → its 10 return → B5 10 accepted. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookContainers, + createImportSchedule, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + forceWindowOpen, + pollBookingStatus, + resetCorridorDay, + seedImportContract, + withSchedule, +} from "./import-utils"; + +const DEPARTURE = departureAt(25); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const SUFFIX = "GDCAP"; +const REF = `CTR-IMP-${stamp}-${SUFFIX}`; + +describe("GENERAL drawdown: a 60×20ft cap across many bookings", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + seedImportContract({ suffix: SUFFIX, reference: REF, kind: "GENERAL", cap20: 60 }); + }); + + it("operations prepares the corridor train with an open window (bookings need an open day)", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60)); + }); + + it("B1 draws 30 and B2 draws 20 — 50 of 60 held", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_000, + twenty: 30, + scheduledDate: BOOKING_DAY, + }); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_100, + twenty: 20, + scheduledDate: BOOKING_DAY, + }); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.bookings b + JOIN freight.contracts ct ON ct.id = b.contract_id + WHERE ct.reference = $1`, + [REF], + ).then(({ rows }) => expect(Number(rows[0].n), "two live drawdowns").to.eq(2)); + }); + + it("B3 asking 20 is REJECTED — only 10 of 60 remain", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_200, + twenty: 20, + scheduledDate: BOOKING_DAY, + expectFailure: "remain on this contract", + }); + }); + + it("B3' takes exactly the remaining 10 — the contract COMPLETES and B4 is rejected", () => { + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_300, + twenty: 10, + scheduledDate: BOOKING_DAY, + }); + // Booking the last of the cap flips the GENERAL contract to + // CONTRACT_CLOSED (fully drawn) — the next booking is rejected as such. + db<{ status: string }>( + `SELECT status FROM freight.contracts WHERE reference = $1`, + [REF], + ).then(({ rows }) => + expect(rows[0].status, "fully drawn contract completes").to.eq("CONTRACT_CLOSED"), + ); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_400, + twenty: 2, + scheduledDate: BOOKING_DAY, + expectFailure: "completed", + }); + }); + + it("an EXPIRED booking releases its draw — B5 books the freed 10 and the ledger closes again", () => { + // Kill the newest live drawdown (B3', 10×20ft) — expiry releases its hold. + db( + `UPDATE freight.bookings b SET status = 'EXPIRED' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference = $1 + AND b.id = ( + SELECT b2.id FROM freight.bookings b2 + JOIN freight.contracts c2 ON c2.id = b2.contract_id + WHERE c2.reference = $1 AND b2.status NOT IN ('EXPIRED','CANCELLED','REJECTED') + ORDER BY b2.created_at DESC LIMIT 1 + )`, + [REF], + ); + bookContainers({ + suffix: SUFFIX, + runStamp: stamp, + isoSeed: 18_500, + twenty: 10, + scheduledDate: BOOKING_DAY, + }); + pollBookingStatus(SUFFIX, "AWAITING_DOCUMENTS", 5); + + // Completion tracks the outstanding quantity: the released 10 were + // rebooked, so the ledger is full again and the contract stays CLOSED. + db<{ status: string }>( + `SELECT status FROM freight.contracts WHERE reference = $1`, + [REF], + ).then(({ rows }) => + expect(rows[0].status, "re-drawn contract closed").to.eq("CONTRACT_CLOSED"), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts b/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts new file mode 100644 index 000000000..3eca27de7 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_mixed_reopen.cy.ts @@ -0,0 +1,136 @@ +/** + * GENERAL mixed reopen (D+26) — the same two GENERAL contracts (one + * container, one bulk) book AGAIN after their first bookings die: cycle 1 + * reserves a container + a bulk drawdown, nobody pays, both expire, the + * window reopens — and the SAME contracts issue fresh drawdowns in cycle 2 + * that pay and allocate typed. GENERAL contracts survive dead bookings and + * dead cycles alike. + * + * Sequential steps of one journey — retries off. + */ + +import { + bookBulk, + bookContainers, + clearGeneralBooking, + closeBookingWindow, + completeDocReview, + createImportSchedule, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectWagonType, + forceReservationExpiry, + forceWindowOpen, + markPaid, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + withBooking, + withSchedule, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE = departureAt(26); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +describe("GENERAL mixed reopen: the same contracts book again after a dead cycle", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + seedImportContract({ suffix: "GMC", reference: stampedRef("GMC"), kind: "GENERAL" }); + seedImportContract({ + suffix: "GMB", + reference: stampedRef("GMB"), + kind: "GENERAL", + freight: "BULK", + }); + }); + + it("operations prepares the corridor train — first window opens (cycle 1)", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45)); + withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 1").to.eq(1)); + }); + + it("cycle 1: a container and a bulk GENERAL drawdown clear per booking and are reserved", () => { + bookContainers({ + suffix: "GMC", + runStamp: stamp, + isoSeed: 19_000, + twenty: 12, // 6 wagons + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking("GMC", BOOKING_DAY); + bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); // 10 wagons + clearGeneralBooking("GMB", BOOKING_DAY); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + ["GMC", "GMB"].forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + }); + + it("nobody pays — both drawdowns expire and the window reopens", () => { + ["GMC", "GMB"].forEach((suffix) => forceReservationExpiry(suffix)); + withSchedule(DEPARTURE, (s) => { + endPaymentPhase(s.id); + // Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the + // reopen instant falls inside office hours — assert it left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. + pollDb( + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [s.id], + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", + ); + }); + }); + + it("cycle 2: the SAME contracts issue fresh drawdowns that pay and allocate typed", () => { + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 45)); + withSchedule(DEPARTURE, (s) => expect(s.booking_cycle_no, "cycle 2").to.eq(2)); + + bookContainers({ + suffix: "GMC", + runStamp: stamp, + isoSeed: 19_200, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking("GMC", BOOKING_DAY); + bookBulk({ suffix: "GMB", tons: 700, scheduledDate: BOOKING_DAY }); + clearGeneralBooking("GMB", BOOKING_DAY); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + ["GMC", "GMB"].forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + markPaid("GMC"); + pollAllocations("GMC", 6); + expectWagonType("GMC", "NW5", 6); + markPaid("GMB"); + pollAllocations("GMB", 10); + expectWagonType("GMB", "CW4", 10); + + // The cycle-1 corpses stay dead; both contracts remain ACTIVE. + ["GMC", "GMB"].forEach((suffix) => + withBooking(suffix, (b) => expect(b.status, `${suffix} cycle-2 rides`).to.eq("PAID")), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts b/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts new file mode 100644 index 000000000..298929c00 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts @@ -0,0 +1,179 @@ +/** + * GENERAL rush hour — 20 "users" (20 GENERAL contracts) book one 54-wagon + * import train in the SAME first window (D+23). Every GENERAL booking clears + * PER BOOKING (upload → GL approve → finalize → proceed → ops accept) before + * it may enter the pool. Then: + * + * – a 21st booking whose operation request is never accepted is EXPIRED by + * the batch at doc-review end (it can no longer make the train) + * – the batch reserves the top 9 (9 × 6w = 54/54); 11 wait + * – the payment race: 5 pay, 4 miss their deadline → the freed 24 wagons + * promote the next 4 waiters live (payment phase extends); they pay → + * the train still departs FULL + * – the 7 left-over waiters expire in the day-end sweep; every contract + * stays CONTRACT_ACTIVE (GENERAL survives its bookings) + * + * Sequential steps of one journey — retries off. + */ + +import { + bookContainers, + clearGeneralBooking, + closeBookingWindow, + completeDocReview, + createImportSchedule, + db, + departureAt, + eatDayStr, + endPaymentPhase, + ensureCorridorRoute, + expectWagonType, + forceReservationExpiry, + forceWindowOpen, + markPaid, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + setPriority, + withBooking, + withSchedule, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE = departureAt(23); +const BOOKING_DAY = eatDayStr(DEPARTURE); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +/** 20 virtual users — one GENERAL contract each, 12×20ft = 6 wagons per booking. */ +const USERS = Array.from({ length: 20 }, (_, i) => + `GR${String(i + 1).padStart(2, "0")}`, +); +const SELECTED = USERS.slice(0, 9); // 9 × 6w = 54 +const PAYERS = SELECTED.slice(0, 5); +const DEFAULTERS = SELECTED.slice(5, 9); +const PROMOTED = USERS.slice(9, 13); // take the defaulters' 24 wagons +const LEFTOVER = USERS.slice(13); // 7 expire with the day + +describe("GENERAL rush hour: 20 users, one train, one window", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + [...USERS, "GRNA"].forEach((suffix) => + seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }), + ); + }); + + it("operations prepares the corridor train with an open first window", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE); + createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] }); + withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60)); + }); + + it("20 users book at once — each GENERAL booking clears PER BOOKING into the pool", () => { + USERS.forEach((suffix, i) => { + bookContainers({ + suffix, + runStamp: stamp, + isoSeed: 16_000 + i * 15, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking(suffix, BOOKING_DAY); + }); + USERS.forEach((suffix, i) => setPriority(suffix, i + 1)); + }); + + it("a 21st booking never accepted by operations is expired when doc review ends", () => { + bookContainers({ + suffix: "GRNA", + runStamp: stamp, + isoSeed: 16_500, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + // Clearance done, operation requested — but ops never accept it. + withBooking("GRNA", (b) => { + cy.log(`GRNA ${b.reference} stays OPERATION_REQUEST_PENDING`); + }); + db( + `UPDATE freight.bookings b SET status = 'OPERATION_REQUEST_PENDING' + FROM freight.contracts ct + WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%-GRNA' + AND b.status = 'AWAITING_DOCUMENTS'`, + [], + ); + + withSchedule(DEPARTURE, (s) => { + closeBookingWindow(s.id); + completeDocReview(s.id); + }); + // Never-accepted bookings are swept BEFORE the batch runs. + pollBookingStatus("GRNA", "EXPIRED"); + }); + + it("the batch reserves the top 9 (54/54); 11 wait with no pay window", () => { + SELECTED.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + [...PROMOTED, ...LEFTOVER].forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED"); + expect(b.payment_deadline, `${suffix} no pay window yet`).to.be.null; + }), + ); + }); + + it("payment race: 5 pay, 4 default — the freed 24 wagons promote the next 4 waiters live", () => { + PAYERS.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + }); + DEFAULTERS.forEach((suffix) => forceReservationExpiry(suffix)); + PROMOTED.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + }); + + it("the promoted 4 pay — the train departs FULL at 54/54, typed", () => { + PROMOTED.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + expectWagonType(suffix, "NW5", 6); + }); + withSchedule(DEPARTURE, (s) => { + endPaymentPhase(s.id); + pollDb( + "window FULL + DONE", + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [s.id], + (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE", + ); + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [s.id], + ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54)); + }); + }); + + it("the 7 leftover waiters expire in ONE day-end sweep; every contract stays ACTIVE", () => { + LEFTOVER.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED")); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.contracts + WHERE reference LIKE 'CTR-IMP-%-GR%' AND status = 'CONTRACT_ACTIVE' + AND deleted_at IS NULL AND created_at > now() - interval '1 hour'`, + [], + ).then(({ rows }) => + expect(Number(rows[0].n), "GENERAL contracts survive their bookings").to.be.at.least(21), + ); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts b/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts new file mode 100644 index 000000000..861bf4620 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/general_two_trains.cy.ts @@ -0,0 +1,269 @@ +/** + * GENERAL two-trains-one-day — 20 GENERAL bookings against TWO 54-wagon + * trains sharing one route-day (D+24): ONE window timeline, ONE batch + * release, overflow cascading earliest-departure-first, and a full DUAL + * lifecycle (both trains dispatch, run the corridor and arrive the same day). + * + * – both schedules share the group window (identical clocks); one + * doc-review-complete releases the WHOLE day + * – 20 × 6w = 120w demand vs 108: train 1 takes 9 bookings, the overflow + * lands on train 2 (9 more), 2 wait + * – all 18 pay → 54/54 on EACH train; the 2 waiters expire only after BOTH + * trains conclude (the sweep defers while a sibling is open) + * – both trains finalize, gate-pass, dispatch, checkpoint and arrive — + * every booking ARRIVED under its own train, movements ledger per train + * + * Sequential steps of one journey — retries off. + */ + +import { + apiPost, + bookContainers, + clearGeneralBooking, + completeDocReview, + createImportSchedule, + db, + departureAt, + eatDayStr, + ensureCorridorRoute, + forceReservationExpiry, + markPaid, + opsStaff, + pollAllocations, + pollBookingStatus, + pollDb, + resetCorridorDay, + seedImportContract, + setPriority, + withBooking, + type ScheduleRow, +} from "./import-utils"; + +const DEPARTURE_1 = departureAt(24); +const DEPARTURE_2 = new Date(DEPARTURE_1.getTime() + 2 * 3_600_000); // same EAT day +const BOOKING_DAY = eatDayStr(DEPARTURE_1); + +const stamp = String(Date.now()); +const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`; + +const USERS = Array.from({ length: 20 }, (_, i) => + `GT${String(i + 1).padStart(2, "0")}`, +); +const RIDERS = USERS.slice(0, 18); // 9 per train +const WAITERS = USERS.slice(18); // 2 expire after both trains conclude + +interface DayScheduleRow { + id: string; + window_phase: string; + booking_window_status: string; + status: string; + scheduled_departure_date: string; +} + +/** Both schedules of the day, earliest departure first. */ +function dayShedules() { + return db( + `SELECT ts.id, ts.window_phase, ts.booking_window_status, ts.status, + ts.scheduled_departure_date + FROM freight.train_schedules ts + JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT' + JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY' + WHERE ts.deleted_at IS NULL + AND abs(extract(epoch FROM (ts.scheduled_departure_date - $1::timestamptz))) < 14400 + ORDER BY ts.scheduled_departure_date ASC`, + [DEPARTURE_1.toISOString()], + ); +} + +function withBothSchedules(fn: (first: DayScheduleRow, second: DayScheduleRow) => void) { + dayShedules().then(({ rows }) => { + expect(rows, "two schedules on the day").to.have.length(2); + fn(rows[0], rows[1]); + }); +} + +describe("GENERAL two trains, one day: shared window, overflow, dual lifecycle", { retries: 0 }, () => { + before(() => { + cy.task("db:seedFile", "seed-import-corridor.sql"); + USERS.forEach((suffix) => + seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }), + ); + }); + + it("operations schedules TWO 54-wagon trains on one day — one shared window, forced open", () => { + ensureCorridorRoute(); + resetCorridorDay(DEPARTURE_1); + createImportSchedule({ departure: DEPARTURE_1, locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"] }); + createImportSchedule({ departure: DEPARTURE_2, locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"] }); + + // One clock for the whole day: force IDENTICAL window timestamps on both + // siblings (the group rule's shared timeline, arranged deterministically). + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET window_opens_at = now() - interval '1 minute', + window_closes_at = now() + interval '60 minutes' + WHERE id = ANY($1::uuid[])`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} OPEN`, + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.window_phase === "OPEN" && row?.booking_window_status === "OPEN", + ), + ); + }); + }); + + it("20 users book into the day (never a specific train); every booking clears per booking", () => { + USERS.forEach((suffix, i) => { + bookContainers({ + suffix, + runStamp: stamp, + isoSeed: 17_000 + i * 15, + twenty: 12, + scheduledDate: BOOKING_DAY, + }); + clearGeneralBooking(suffix, BOOKING_DAY); + }); + USERS.forEach((suffix, i) => setPriority(suffix, i + 1)); + }); + + it("ONE doc-review-complete releases the whole day — 18 reserved across both trains, 2 wait", () => { + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET window_closes_at = now() - interval '1 second' + WHERE id = ANY($1::uuid[]) AND window_phase = 'OPEN'`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} DOC_REVIEW`, + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.window_phase === "DOC_REVIEW", + ), + ); + // Staff complete doc review on ONE train — the group stamp releases both. + completeDocReview(first.id); + pollDb( + "sibling released by the same action", + `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, + [second.id], + (row) => row?.window_phase === "PAYMENT" || row?.window_phase === "DONE", + ); + }); + RIDERS.forEach((suffix) => + pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]), + ); + WAITERS.forEach((suffix) => + withBooking(suffix, (b) => { + expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED"); + }), + ); + }); + + it("all 18 pay — 54/54 on EACH train, overflow filled earliest-departure-first", () => { + RIDERS.forEach((suffix) => { + markPaid(suffix); + pollAllocations(suffix, 6); + }); + withBothSchedules((first, second) => { + [first.id, second.id].forEach((id) => { + pollDb<{ n: string }>( + `train ${id} carries 9 bookings`, + `SELECT count(*) AS n FROM freight.train_schedule_bookings + WHERE train_schedule_id = $1 AND deleted_at IS NULL`, + [id], + (row) => Number(row?.n) === 9, + 15, + ); + db<{ n: string }>( + `SELECT count(DISTINCT wba.train_set_wagon_id) AS n + FROM freight.wagon_booking_allocations wba + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1 + WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`, + [id], + ).then(({ rows }) => expect(Number(rows[0].n), "54 wagons").to.eq(54)); + }); + // The top-priority bookings ride the EARLIEST departure. + withBooking("GT01", (b) => + expect(b.train_schedule_id, "GT01 on the first train").to.eq(first.id), + ); + }); + }); + + it("both trains conclude FULL — only then do the 2 waiters expire (sweep defers for siblings)", () => { + withBothSchedules((first, second) => { + db( + `UPDATE freight.train_schedules + SET payment_phase_ends_at = now() - interval '1 second' + WHERE id = ANY($1::uuid[]) AND window_phase = 'PAYMENT'`, + [[first.id, second.id]], + ); + [first.id, second.id].forEach((id) => + pollDb( + `schedule ${id} FULL + DONE`, + `SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE", + ), + ); + }); + WAITERS.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED")); + }); + + it("dual lifecycle: both trains gate-pass, dispatch, run the corridor and arrive", () => { + withBothSchedules((first, second) => { + [first.id, second.id].forEach((id) => { + pollDb( + `schedule ${id} finalized`, + `SELECT status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.status === "SCHEDULED", + 10, + ); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`) + .its("status") + .should("be.oneOf", [200, 201]); + [1, 2, 3, 4].forEach((seq) => { + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, { + sequenceNo: seq, + kind: "PASSED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, { + sequenceNo: 5, + kind: "ARRIVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + pollDb( + `schedule ${id} ARRIVED`, + `SELECT status FROM freight.train_schedules WHERE id = $1`, + [id], + (row) => row?.status === "ARRIVED", + 20, + ); + db<{ n: string }>( + `SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`, + [id], + ).then(({ rows }) => + expect(Number(rows[0].n), `train ${id} movements`).to.be.at.least(54), + ); + }); + }); + RIDERS.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20)); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/import-utils.ts b/e2e/freight/cypress/e2e/flows/import-utils.ts index 62ba74635..67291b0c0 100644 --- a/e2e/freight/cypress/e2e/flows/import-utils.ts +++ b/e2e/freight/cypress/e2e/flows/import-utils.ts @@ -120,6 +120,10 @@ export interface SeedContractOpts { freight?: "CONTAINER" | "BULK"; originCode?: string; destCode?: string; + /** GENERAL = multi-booking drawdown contract (status CONTRACT_ACTIVE). */ + kind?: "ONE_TIME" | "GENERAL"; + /** GENERAL only: quantity cap on the 20ft scope row (40ft stays uncapped). */ + cap20?: number; } export function seedImportContract(opts: SeedContractOpts) { @@ -127,6 +131,8 @@ export function seedImportContract(opts: SeedContractOpts) { const customs = opts.customs ?? false; const direction = opts.direction ?? "IMPORT"; const freight = opts.freight ?? "CONTAINER"; + const kind = opts.kind ?? "ONE_TIME"; + const status = kind === "GENERAL" ? "CONTRACT_ACTIVE" : "FULLY_EXECUTED"; // Pre-booking boundary milestone for Path B contracts differs by direction. const boundary = direction === "EXPORT" ? "EXPORT_RELEASED" : "DO_COLLECTED"; db( @@ -146,11 +152,11 @@ export function seedImportContract(opts: SeedContractOpts) { ELSE 1 END LIMIT 1), - 'ONE_TIME', $2::text, $9::text, + $11::text, $2::text, $9::text, (SELECT st.id FROM freight.service_types st ORDER BY st.created_at LIMIT 1), $3, $4, CASE WHEN $4 THEN 'CLEARANCE_READY_FOR_BOOKING' ELSE 'NOT_APPLICABLE' END, - 'FULLY_EXECUTED', now(), now() - interval '1 day', + $12::text, now(), now() - interval '1 day', now() + interval '60 days', 'E2E import-corridor fixture contract' FROM freight.companies comp WHERE comp.tin = $5 @@ -175,8 +181,10 @@ export function seedImportContract(opts: SeedContractOpts) { RETURNING id ), scope_container AS ( INSERT INTO freight.contract_cargo_scope - (contract_id, container_size, cargo_free_text) - SELECT c.id, v.size, 'E2E import corridor cargo' + (contract_id, container_size, quantity_cap, cargo_free_text) + SELECT c.id, v.size, + CASE WHEN v.size = '20ft' THEN $13::numeric END, + 'E2E import corridor cargo' FROM c CROSS JOIN (VALUES ('20ft'), ('40ft')) AS v(size) WHERE $9::text = 'CONTAINER' ), scope_bulk AS ( @@ -203,6 +211,9 @@ export function seedImportContract(opts: SeedContractOpts) { opts.suffix, freight, boundary, + kind, + status, + opts.cap20 ?? null, ], ); // Backfill the boundary milestone when the insert above was skipped because @@ -414,6 +425,34 @@ export function bookBulk(opts: { }); } +/** + * Walk a GENERAL booking through its PER-BOOKING clearance chain (Path A): + * AWAITING_DOCUMENTS → upload one doc → GL approves it → finalize → + * CLEARANCE_READY → customer proceeds with the shipment day → + * OPERATION_REQUEST_PENDING → ops accept → FULLY_EXECUTED (pool). The e2e + * seed configures no required documents, so one ad-hoc doc satisfies the + * 100%-approved gate. + */ +export function clearGeneralBooking(suffix: string, scheduledDate: string) { + withBooking(suffix, (b) => { + expect(b.status, `${suffix} starts in the clearance gate`).to.eq("AWAITING_DOCUMENTS"); + glUpload(`/api/bookings/${b.id}/clearance/documents`, {}, "custom_e2e"); + apiPost(superAdmin, `/api/bookings/${b.id}/clearance/review`, { + fileKey: "custom_e2e", + status: "APPROVED", + }) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(superAdmin, `/api/bookings/${b.id}/clearance/finalize`) + .its("status") + .should("be.oneOf", [200, 201]); + apiPost(customer, `/api/bookings/${b.id}/clearance/proceed`, { scheduledDate }) + .its("status") + .should("be.oneOf", [200, 201]); + }); + acceptOperation(suffix); +} + /** Ops accepts the operation request → FULLY_EXECUTED (enters the day pool). */ export function acceptOperation(suffix: string) { withBooking(suffix, (b) => { diff --git a/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts index 4d73acd81..9be4636c3 100644 --- a/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts +++ b/e2e/freight/cypress/e2e/flows/mixed_import_reopen_matrix.cy.ts @@ -111,11 +111,14 @@ describe("mixed import: dead mixed cycle, mixed recovery, mixed ride-alongs", { ["MRC", "MRB"].forEach((suffix) => forceReservationExpiry(suffix)); withSchedule(DEPARTURE, (s) => { endPaymentPhase(s.id); + // Same-tick guard-loop can chain PRE_WINDOW straight into OPEN when the + // reopen instant falls inside office hours — assert it left PAYMENT + // without concluding FULL/DONE, whichever phase it lands on. pollDb( - "window reopens (PRE_WINDOW)", + "window concludes not-full (PRE_WINDOW or fast-forwarded to OPEN)", `SELECT window_phase FROM freight.train_schedules WHERE id = $1`, [s.id], - (row) => row?.window_phase === "PRE_WINDOW", + (row) => !!row && row.window_phase !== "PAYMENT" && row.window_phase !== "DONE", ); }); }); diff --git a/e2e/freight/cypress/fixtures/seed-company.sql b/e2e/freight/cypress/fixtures/seed-company.sql index 73b01bee1..f48bfaf81 100644 --- a/e2e/freight/cypress/fixtures/seed-company.sql +++ b/e2e/freight/cypress/fixtures/seed-company.sql @@ -38,11 +38,13 @@ WHERE c.tin = '0102030405' ); -- 2c. Link the demo portal user to the company, onboarding already done. +-- (active_profile_type was dropped by migration 2450 — the "active mode" +-- column no longer exists; bookings resolve the profile per shipment.) INSERT INTO freight.external_profiles (id, user_id, company_id, first_name, last_name, is_primary_contact, - active_profile_type, onboarding_step, onboarding_completed) + onboarding_step, onboarding_completed) SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true, - 'importer', 'done', true + 'done', true FROM iam.users u JOIN freight.companies c ON c.tin = '0102030405' WHERE u.email = 'user@gmail.com' diff --git a/e2e/freight/cypress/fixtures/seed-import-corridor.sql b/e2e/freight/cypress/fixtures/seed-import-corridor.sql index 8ce7766f8..21a2e391c 100644 --- a/e2e/freight/cypress/fixtures/seed-import-corridor.sql +++ b/e2e/freight/cypress/fixtures/seed-import-corridor.sql @@ -165,6 +165,64 @@ WHERE NOT EXISTS ( SELECT 1 FROM freight.wagons w WHERE w.wagon_number = 'ECW' || lpad(g::text, 4, '0') ); +-- 5b3. Ledger-day rolling stock: wheat may also ride PW2 box wagons, and the +-- GRAIN train is a BUILT Train-Builder consist — 37 PW2 wagons coupled at +-- KALITY behind two dedicated locos. A built train's physical wagon count IS +-- its schedule capacity (37), immune to the loco-length slot recompute. +INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id) +SELECT ct.id, wt.id +FROM freight.cargo_types ct +JOIN freight.wagon_types wt ON wt.code = 'PW2' +WHERE ct.code IN ('E2E_IMP_GRAINS', 'E2E_IMP_WHEAT') + AND NOT EXISTS ( + SELECT 1 FROM freight.cargo_type_wagon_types x + WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id + ); + +INSERT INTO freight.locomotives + (id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id) +SELECT gen_random_uuid(), v.code, 9000, 760, y.id +FROM (VALUES ('LOCO-LED-1'), ('LOCO-LED-2')) AS v(code) +JOIN freight.yards y ON y.code = 'KALITY' +WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code); + +INSERT INTO freight.trains + (id, code, train_name, capacity_tons, current_yard_id, + import_train_number, export_train_number) +SELECT gen_random_uuid(), 'TRN-LEDGER-PW2', 'Ledger Grain Carrier', 2600, y.id, + '9102', '9101' +FROM freight.yards y +WHERE y.code = 'KALITY' + AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-LEDGER-PW2'); + +INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no) +SELECT gen_random_uuid(), t.id, l.id, + row_number() OVER (ORDER BY l.code) - 1 +FROM freight.trains t +JOIN freight.locomotives l ON l.code IN ('LOCO-LED-1', 'LOCO-LED-2') +WHERE t.code = 'TRN-LEDGER-PW2' + AND NOT EXISTS ( + SELECT 1 FROM freight.train_locomotives tl + WHERE tl.train_id = t.id AND tl.locomotive_id = l.id + ); + +UPDATE freight.wagons w +SET train_id = t.id, + sequence_number = sub.rn, + current_yard_id = (SELECT id FROM freight.yards WHERE code = 'KALITY') +FROM freight.trains t, + LATERAL ( + SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn + FROM freight.wagons w2 + JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'PW2' + WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL + ORDER BY w2.wagon_number + LIMIT 37 + ) sub +WHERE t.code = 'TRN-LEDGER-PW2' + AND w.id = sub.id + AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id); + -- 5c. Approved exporter profile — export contracts bill against it -- (seed-company.sql only creates the importer). INSERT INTO freight.company_profiles (id, company_id, type, status, reference) diff --git a/packages/payment-providers/src/providers/waafi/waafi.provider.ts b/packages/payment-providers/src/providers/waafi/waafi.provider.ts index e7d78f752..dd5978021 100644 --- a/packages/payment-providers/src/providers/waafi/waafi.provider.ts +++ b/packages/payment-providers/src/providers/waafi/waafi.provider.ts @@ -135,15 +135,18 @@ export class WaafiProvider implements PaymentProvider, OnModuleInit { // Waafi returns transaction info (params.status) ONLY when responseCode is 2001. For an // unpaid or not-yet-existing transaction it returns an error envelope (e.g. 5001 / E10206 - // "Failed to get transaction info") with no status. Treat that as still-pending (PROCESSING), - // never terminal — so the intent keeps waiting for the webhook / its expiry rather than being - // wrongly resolved off a "no info" response. + // "Failed to get transaction info") with no status — i.e. the payer hasn't done anything at + // the hosted page yet. That's REQUIRES_ACTION (still awaiting the payer), NOT PROCESSING: + // returning PROCESSING here would let the reconciliation sweep persist that guess and block + // the payer from switching providers on a session they never touched (see cac-bank.provider's + // queryStatus for the same convention). The intent still resolves correctly either way — via + // the webhook on a genuine payment, or via expiresAt once the 5-minute HPP session lapses. if (response.responseCode !== WAAFI_SUCCESS_CODE) { this.logger.warn( - `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as pending`, + `Waafi HPP_GETTRANINFO ${merchantOrderId}: ${response.responseCode}/${response.errorCode} ${response.responseMsg} — treating as still awaiting the payer`, ); return { - status: ProviderPaymentStatus.PROCESSING, + status: ProviderPaymentStatus.REQUIRES_ACTION, rawResponse: response as unknown as Record, }; } diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 4d0542539..bb5c5fb63 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -42,7 +42,6 @@ export const CONTRACT_STATUSES = [ "FULLY_EXECUTED", // ONE_TIME "CONTRACT_ACTIVE", // GENERAL // customs clearance execution (Path B, pre-booking) - "AWAITING_CLEARANCE_PAYMENT", // clearance service fee invoiced, unpaid "AWAITING_CLEARANCE_DOCUMENTS", "CLEARANCE_UNDER_REVIEW", "CLEARANCE_READY_FOR_BOOKING", @@ -68,7 +67,6 @@ export type ContractStatus = (typeof CONTRACT_STATUSES)[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 @@ -89,6 +87,7 @@ export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [ export type ContractRateUnit = | "per_container" + | "per_wagon" | "per_ton" | "per_item" | "per_km" diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 2fe24773f..618487d91 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -103,8 +103,6 @@ export enum BookingStatus { PendingConsolidation = "PENDING_CONSOLIDATION", Consolidated = "CONSOLIDATED", // Post counter-sign document-clearance gate (GL workflow). - /** Clearance service fee invoiced; docs + GL work locked until paid. */ - AwaitingClearancePayment = "AWAITING_CLEARANCE_PAYMENT", AwaitingDocuments = "AWAITING_DOCUMENTS", DocumentsUnderReview = "DOCUMENTS_UNDER_REVIEW", ClearanceReady = "CLEARANCE_READY",