From d7688fce210ad308f5f507677c3b11b37dd19ce5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 24 Jun 2026 01:32:40 +0000 Subject: [PATCH] feat(freight-web): polish document clearance, general contracts list and detail UIs --- ...000000010-AddGeneralContractOrderFields.ts | 50 ++ .../booking-orders/booking-orders.module.ts | 2 + .../booking-orders.service.spec.ts | 127 ++++ .../booking-orders/booking-orders.service.ts | 154 +++- .../booking-orders/dto/contract-view.dto.ts | 3 + .../dto/create-booking-order.dto.spec.ts | 28 + .../dto/create-booking-order.dto.ts | 20 + .../entities/booking-order-line.entity.ts | 11 + .../entities/contract-route-line.entity.ts | 8 + .../general-contract.service.ts | 1 + .../modules/booking-orders/road.util.spec.ts | 32 + .../src/modules/booking-orders/road.util.ts | 35 + .../bookings/booking-contract.service.ts | 21 + .../bookings/booking-pricing.service.ts | 4 + .../booking-transition.operation.spec.ts | 95 +++ .../bookings/booking-transition.service.ts | 22 +- .../src/modules/bookings/bookings.module.ts | 2 +- .../src/modules/bookings/bookings.service.ts | 1 + .../bookings/dto/create-booking.dto.ts | 12 + .../bookings/entities/booking.entity.ts | 12 + .../rule-engine/rule-engine.service.ts | 5 +- .../bookings/BookingConfirmDialog.tsx | 19 +- .../bookings/useBookingActionDialog.ts | 28 +- .../bookings/booking-actions.config.ts | 61 +- .../bookings/booking-status.config.ts | 27 +- .../src/hooks/bookings/useBookings.ts | 12 + .../src/pages/bookings/GlClearancePage.tsx | 702 +++++++++++++----- .../backoffice/src/services/api.ts | 12 + .../src/services/bookings.service.ts | 11 + .../bookings/BookingDetailPage/constants.ts | 23 + .../src/pages/bookings/NewBookingPage.tsx | 1 + .../pages/bookings/new-booking-form/schema.ts | 2 + .../bookings/new-booking-form/step4-route.tsx | 18 + .../pages/contracts/ContractDetailPage.tsx | 175 ++++- .../src/pages/contracts/ContractsList.tsx | 244 +++--- .../src/pages/contracts/PlaceOrderDialog.tsx | 99 +++ .../src/pages/contracts/contract-ui.tsx | 45 +- packages/types/src/freight/index.ts | 8 + 38 files changed, 1769 insertions(+), 363 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/road.util.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts diff --git a/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts b/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts new file mode 100644 index 000000000..a23c152b5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1820000000010-AddGeneralContractOrderFields.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * General-contract drawdown order fields: + * - booking_order_lines.hazardous_quantity / reefer_quantity — per-order counts + * the customer enters when toggling hazardous/reefer; drive the surcharge + * rates on the spawned child booking. + * - bookings.is_reefer — booking-level refrigerated flag so REEFER_SURCHARGE + * applies to a contract order even when the container type is not a reefer. + * - contract_route_lines.km — road distance configured with the route; road + * orders bill KM × the PER_KM rate. + * + * NOTE: the shared dev DB has no applied migration history, so these columns + * are also hand-applied there. ADD COLUMN IF NOT EXISTS keeps that idempotent. + */ +export class AddGeneralContractOrderFields1820000000010 + implements MigrationInterface +{ + name = 'AddGeneralContractOrderFields1820000000010'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS hazardous_quantity numeric(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_order_lines ADD COLUMN IF NOT EXISTS reefer_quantity numeric(12,3) NOT NULL DEFAULT 0;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS is_reefer boolean NOT NULL DEFAULT false;`, + ); + await queryRunner.query( + `ALTER TABLE freight.contract_route_lines ADD COLUMN IF NOT EXISTS km numeric(10,2);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contract_route_lines DROP COLUMN IF EXISTS km;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_reefer;`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS reefer_quantity;`, + ); + await queryRunner.query( + `ALTER TABLE freight.booking_order_lines DROP COLUMN IF EXISTS hazardous_quantity;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts index 3347bef97..aafb04474 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; import { CompaniesModule } from '../companies/companies.module'; import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; import { BookingOrdersController } from './booking-orders.controller'; import { BookingOrdersRepository } from './booking-orders.repository'; @@ -18,6 +19,7 @@ import { GeneralContractService } from './general-contract.service'; BookingsModule, CompaniesModule, DropdownSettingsModule, + RuleEngineModule, forwardRef(() => TrainSchedulingModule), ], controllers: [BookingOrdersController], diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts new file mode 100644 index 000000000..b870037f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.spec.ts @@ -0,0 +1,127 @@ +import { BookingOrdersService } from './booking-orders.service'; + +/** + * Phase-0 spine: a drawdown order spawns a PRICED, UNPAID child booking that + * waits for Marketing review (or the customs clearance gate first) — it does + * NOT auto-enter the train batch pool, and the contract is not charged. + */ +describe('BookingOrdersService — child spawn on order create', () => { + function makeService(opts: { includesCustoms: boolean; roadKm?: number | null }) { + const contract = { + id: 'c-1', + bookingType: 'GENERAL_CONTRACT', + status: 'CONTRACT_ACTIVE', + expiresAt: new Date('2030-01-01T00:00:00.000Z'), + freightType: 'BULK', + originYardId: 'o-1', + destinationYardId: 'd-1', + companyId: null, + paymentCurrency: 'ETB', + serviceType: { includesCustoms: opts.includesCustoms, code: 'RAIL_BULK' }, + bookingContainers: [], + }; + + // Capture what status the child is created with. + const created: Record[] = []; + const managerUpdates: Record[] = []; + const fakeManager = { + create: (_entity: unknown, data: Record) => { + created.push(data); + return { id: 'child-1', ...data }; + }, + save: async (row: Record) => ({ id: 'child-1', ...row }), + getRepository: () => ({ + findOne: async () => ({ id: 'child-1', paymentCurrency: 'ETB', bookingContainers: [] }), + update: async (_id: string, data: Record) => { + managerUpdates.push(data); + }, + }), + }; + + const dataSource = { + transaction: async (cb: (m: unknown) => Promise) => cb(fakeManager), + getRepository: () => ({ update: jest.fn() }), + }; + const ordersRepository = { + countByYear: jest.fn().mockResolvedValue(0), + findById: jest.fn().mockResolvedValue({ id: 'order-1', lines: [] }), + }; + const bookingsRepository = { + findById: jest.fn().mockResolvedValue(contract), + countByYear: jest.fn().mockResolvedValue(0), + }; + const generalContractService = { + isGeneralContract: () => true, + getRouteLines: jest.fn().mockResolvedValue([]), + getQuantityLines: jest + .fn() + .mockResolvedValue([ + { containerTypeId: null, remainingQuantity: 100, containerTypeName: null }, + ]), + isExhausted: jest.fn().mockResolvedValue(false), + }; + const pricingService = { + computePriceForBooking: jest.fn().mockResolvedValue({ + totalAmount: 500, + priorityScore: 10, + lineItems: [], + currency: 'ETB', + }), + }; + const ratesService = { findLiveRates: jest.fn().mockResolvedValue([]) }; + const trainSchedulingService = { + existsOpenScheduleOnRouteDay: jest.fn().mockResolvedValue(true), + }; + const companiesService = {}; + + const service = new BookingOrdersService( + dataSource as never, + ordersRepository as never, + bookingsRepository as never, + companiesService as never, + generalContractService as never, + pricingService as never, + ratesService as never, + trainSchedulingService as never, + ); + return { service, created, managerUpdates, pricingService }; + } + + const dto = { + contractBookingId: 'c-1', + scheduledDate: '2026-07-01T00:00:00.000Z', + lines: [{ quantity: 10, hazardousQuantity: 4, reeferQuantity: 0 }], + }; + + it('spawns the child at OPERATION_REQUEST_PENDING (no customs), priced + unpaid', async () => { + const { service, created, managerUpdates, pricingService } = makeService({ + includesCustoms: false, + }); + await service.create(dto as never); + + const child = created.find((c) => c.bookingType === 'ONE_TIME')!; + expect(child.status).toBe('OPERATION_REQUEST_PENDING'); + expect(child.paymentStatus).toBe('PENDING'); + expect(child.isHazardous).toBe(true); // line has hazardousQuantity > 0 + expect(pricingService.computePriceForBooking).toHaveBeenCalled(); + // The computed price is persisted onto the child. + expect(managerUpdates.some((u) => u.totalAmount === 500)).toBe(true); + }); + + it('spawns the child at AWAITING_DOCUMENTS when the service includes customs', async () => { + const { service, created } = makeService({ includesCustoms: true }); + await service.create(dto as never); + const child = created.find((c) => c.bookingType === 'ONE_TIME')!; + expect(child.status).toBe('AWAITING_DOCUMENTS'); + }); + + it('rejects when hazardous quantity exceeds the line quantity', async () => { + const { service } = makeService({ includesCustoms: false }); + await expect( + service.create({ + ...dto, + lines: [{ quantity: 5, hazardousQuantity: 9, reeferQuantity: 0 }], + } as never), + ).rejects.toThrow(/exceed the line quantity/); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts index c306d420b..8eb258a88 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -8,11 +8,13 @@ import { } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingPricingService } from '../bookings/booking-pricing.service'; +import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { Booking } from '../bookings/entities/booking.entity'; import { BookingContainer } from '../bookings/entities/booking-container.entity'; import { CompaniesService } from '../companies/companies.service'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; -import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { RatesService } from '../rule-engine/services/rates.service'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { BookingOrdersRepository } from './booking-orders.repository'; @@ -20,6 +22,7 @@ import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; import { BookingOrder } from './entities/booking-order.entity'; import { BookingOrderLine } from './entities/booking-order-line.entity'; import { GeneralContractService } from './general-contract.service'; +import { isRoadService, roadKmPrice } from './road.util'; @Injectable() export class BookingOrdersService { @@ -31,8 +34,8 @@ export class BookingOrdersService { private readonly bookingsRepository: BookingsRepository, private readonly companiesService: CompaniesService, private readonly generalContractService: GeneralContractService, - @Inject(forwardRef(() => BookingBatchService)) - private readonly bookingBatchService: BookingBatchService, + private readonly pricingService: BookingPricingService, + private readonly ratesService: RatesService, @Inject(forwardRef(() => TrainSchedulingService)) private readonly trainSchedulingService: TrainSchedulingService, ) {} @@ -87,6 +90,7 @@ export class BookingOrdersService { let originYardId = contract.originYardId; let destinationYardId = contract.destinationYardId; let routeLineId: string | null = null; + let routeKm: number | null = null; if (routeLines.length > 0) { if (!dto.routeLineId) { @@ -103,6 +107,7 @@ export class BookingOrdersService { originYardId = chosen.originYardId; destinationYardId = chosen.destinationYardId; routeLineId = chosen.routeLineId; + routeKm = chosen.km ?? null; } // Validate the route has a departure on the chosen day. @@ -122,6 +127,21 @@ export class BookingOrdersService { const isContainer = contract.freightType === 'CONTAINER'; const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + // Hazardous/reefer counts the customer entered cannot exceed the line they + // belong to. Validated for every order regardless of routing. + for (const line of dto.lines) { + const haz = line.hazardousQuantity ?? 0; + const reefer = line.reeferQuantity ?? 0; + if (haz < 0 || reefer < 0) { + throw new BadRequestException('Hazardous/reefer quantities cannot be negative'); + } + if (haz > line.quantity || reefer > line.quantity) { + throw new BadRequestException( + 'Hazardous/reefer quantity cannot exceed the line quantity', + ); + } + } + if (routeLineId) { // Multi-route: validate against the chosen route line's remaining pool. for (const line of dto.lines) { @@ -167,7 +187,7 @@ export class BookingOrdersService { const childBooking = await this.spawnChildBooking( contract, dto, - { originYardId, destinationYardId }, + { originYardId, destinationYardId, km: routeKm }, manager, ); @@ -179,7 +199,9 @@ export class BookingOrdersService { routeLineId, companyId: contract.companyId ?? null, scheduledDate: new Date(dto.scheduledDate), - status: 'PAID', + // The order is a ledger row; the child booking drives the workflow + // (review → pay → allocate), so the order tracks PENDING until done. + status: 'PENDING', schedulingStatus: 'NOT_SCHEDULED', }); const savedOrder = await manager.save(orderRow); @@ -189,6 +211,8 @@ export class BookingOrdersService { orderId: savedOrder.id, containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, quantity: l.quantity, + hazardousQuantity: l.hazardousQuantity ?? 0, + reeferQuantity: l.reeferQuantity ?? 0, }), ); await manager.save(lines); @@ -196,20 +220,12 @@ export class BookingOrdersService { return savedOrder; }); - // Feed the child booking into the day-pool batch so it allocates to a train. - try { - await this.bookingBatchService.processRouteDay({ - originYardId, - destinationYardId, - day, - }); - } catch (err) { - this.logger.error( - `Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } + // The child does NOT enter the train batch pool here. It is priced and + // unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs + // clearance first; the batch enqueue happens only on accept. - // Close the contract once its pool is exhausted. + // Close the contract once its pool is exhausted (pending orders count, so + // the pool reserves quantity as soon as an order is placed). if (await this.generalContractService.isExhausted(contract.id)) { await this.dataSource .getRepository(Booking) @@ -224,16 +240,18 @@ export class BookingOrdersService { /** * Create the ONE_TIME child booking for an order, inheriting the contract's - * shipment context and entering the queue already PAID + FULLY_EXECUTED. + * shipment context. Unlike the contract (which is no longer paid up front), + * the child is PRICED and UNPAID and waits for Marketing review — going + * through the customs clearance gate first when the service includes customs, + * mirroring a one-time booking. It only enters the train pool on accept. */ private async spawnChildBooking( contract: Booking, dto: CreateBookingOrderDto, - route: { originYardId: string; destinationYardId: string }, + route: { originYardId: string; destinationYardId: string; km: number | null }, manager: import('typeorm').EntityManager, ): Promise { const reference = await this.generateChildBookingReference(); - const now = new Date(); const isContainer = contract.freightType === 'CONTAINER'; // Sum line quantities × the contract's per-unit weight for the child total. @@ -251,6 +269,18 @@ export class BookingOrdersService { totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); } + // Per-order hazardous/reefer: set the child flags from the order's line + // counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply. + const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0); + const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0); + + // Customs orders flow through the one-time clearance gate first; others go + // straight to operations review with the chosen shipment day. + const { includesCustoms } = clearanceCodesForBooking(contract); + const spawnStatus = includesCustoms + ? 'AWAITING_DOCUMENTS' + : 'OPERATION_REQUEST_PENDING'; + const child = manager.create(Booking, { reference, companyId: contract.companyId ?? null, @@ -271,16 +301,14 @@ export class BookingOrdersService { cargoFreeText: contract.cargoFreeText ?? null, shippingLineId: contract.shippingLineId ?? null, cargoTotalWeightVgm: totalWeight, - isHazardous: contract.isHazardous, + isHazardous: hasHazardous, + isReefer: hasReefer, paymentCurrency: contract.paymentCurrency, bookingType: 'ONE_TIME', scheduledDate: new Date(dto.scheduledDate), - // Already covered by the contract's one-time payment: enter the pool ready - // and paid so the batch engine reserves → allocates it immediately. - status: 'FULLY_EXECUTED', - paymentStatus: 'PAID', - fullyExecutedAt: now, - customerSignedAt: now, + // Priced + unpaid: the customer pays this order on its own. + status: spawnStatus, + paymentStatus: 'PENDING', priorityScore: contract.priorityScore, totalAmount: 0, schedulingStatus: 'NOT_SCHEDULED', @@ -310,9 +338,79 @@ export class BookingOrdersService { } } + // Price the order: base freight for the drawn quantity + haz/reefer + // surcharges, plus a road KM charge when the service ships by road. + const roadKm = isRoadService(contract.serviceType) ? route.km : null; + await this.priceChildBooking(savedChild.id, roadKm, manager); + return savedChild; } + /** + * Compute and persist the child order's price (base + surcharges) inside the + * order transaction. The contract is no longer paid up front, so each order + * carries its own total that the customer pays. + */ + private async priceChildBooking( + childId: string, + roadKm: number | null, + manager: import('typeorm').EntityManager, + ): Promise { + const child = await manager.getRepository(Booking).findOne({ + where: { id: childId }, + relations: { bookingContainers: true }, + }); + if (!child) return; + + try { + const computed = await this.pricingService.computePriceForBooking(child); + const lineItems = [...computed.lineItems]; + let total = computed.totalAmount; + + // Road KM charge: distance × the live PER_KM rate, added as its own line. + if (roadKm && roadKm > 0) { + const perKmRate = await this.findPerKmRate(child.paymentCurrency); + const kmAmount = roadKmPrice(roadKm, perKmRate); + if (kmAmount > 0) { + lineItems.push({ + code: 'ROAD_KM', + description: `Road transport (${roadKm} km)`, + amount: kmAmount, + unitAmount: perKmRate!, + unit: 'PER_KM', + quantity: roadKm, + currency: child.paymentCurrency, + }); + total += kmAmount; + } + } + + await manager.getRepository(Booking).update(childId, { + totalAmount: total, + priorityScore: computed.priorityScore, + pricingBreakdown: { + lineItems, + totalAmount: total, + currency: computed.currency, + generatedAt: new Date().toISOString(), + }, + } as never); + } catch (err) { + this.logger.error( + `Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** The live PER_KM rate value for road billing, in the given currency. */ + private async findPerKmRate(currency: string): Promise { + const rates = await this.ratesService.findLiveRates(); + const rate = rates.find( + (r) => r.rateUnit === 'PER_KM' && r.currency === currency, + ); + return rate ? Number(rate.rateValue) : null; + } + private async userOwnsContract( userId: string, contract: Booking, diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts index fc2cc0325..3c85ad31e 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -53,4 +53,7 @@ export class ContractRouteLineView { @ApiProperty() remainingQuantity!: number; + + @ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' }) + km!: number | null; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts new file mode 100644 index 000000000..d7d226855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.spec.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { CreateBookingOrderLineDto } from './create-booking-order.dto'; + +/** + * Order line haz/reefer quantities arrive as JSON numbers but must default to 0 + * when omitted and coerce string inputs (defensive) to numbers. + */ +describe('CreateBookingOrderLineDto — haz/reefer coercion', () => { + const toDto = (plain: Record) => + plainToInstance(CreateBookingOrderLineDto, plain, { + enableImplicitConversion: false, + exposeDefaultValues: true, + }) as unknown as CreateBookingOrderLineDto; + + it('defaults hazardous/reefer quantities to 0 when omitted', () => { + const dto = toDto({ quantity: 5 }); + expect(dto.hazardousQuantity).toBe(0); + expect(dto.reeferQuantity).toBe(0); + }); + + it('coerces provided string quantities to numbers', () => { + const dto = toDto({ quantity: '5', hazardousQuantity: '2', reeferQuantity: '3' }); + expect(dto.quantity).toBe(5); + expect(dto.hazardousQuantity).toBe(2); + expect(dto.reeferQuantity).toBe(3); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts index c7712b5b9..4e7382ec4 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -25,6 +25,26 @@ export class CreateBookingOrderLineDto { @Min(0) @Transform(({ value }) => Number(value)) quantity!: number; + + @ApiPropertyOptional({ + description: 'How much of this line is hazardous (≤ quantity). Defaults to 0.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + hazardousQuantity?: number = 0; + + @ApiPropertyOptional({ + description: 'How much of this line is refrigerated (≤ quantity). Defaults to 0.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value ?? 0)) + reeferQuantity?: number = 0; } export class CreateBookingOrderDto { diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts index 8716cd673..7b4728e02 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -27,4 +27,15 @@ export class BookingOrderLine extends BaseEntity { /** Containers (count), tons, or items depending on the contract's freight/UoM. */ @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) quantity!: number; + + /** + * How much of this line is hazardous / refrigerated, entered per order by the + * customer when they toggle the flag. Drives the HAZARD_SURCHARGE / + * REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity. + */ + @Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + hazardousQuantity!: number; + + @Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 }) + reeferQuantity!: number; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts index 0bac5bbd6..e05af758b 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/contract-route-line.entity.ts @@ -50,4 +50,12 @@ export class ContractRouteLine extends BaseEntity { /** Contracted quantity for this (route, container type): containers, tons, or items. */ @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) quantity!: number; + + /** + * Road distance for this route, configured with the route. Road (truck) + * drawdown orders bill KM × the PER_KM rate from this value. Null for + * rail-only routes where KM is not billed. + */ + @Column({ name: 'km', type: 'numeric', precision: 10, scale: 2, nullable: true }) + km?: number | null; } diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts index 107addc9c..4cf5dfe59 100644 --- a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -162,6 +162,7 @@ export class GeneralContractService { contractedQuantity: contracted, orderedQuantity: orderedQty, remainingQuantity: Math.max(0, contracted - orderedQty), + km: rl.km != null ? Number(rl.km) : null, }; }); } diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts new file mode 100644 index 000000000..10258e3b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/road.util.spec.ts @@ -0,0 +1,32 @@ +import { isRoadService, roadKmPrice } from './road.util'; + +describe('road.util', () => { + describe('isRoadService', () => { + it('treats ROAD/TRUCK codes (and prefixes) as road', () => { + expect(isRoadService({ code: 'ROAD' })).toBe(true); + expect(isRoadService({ code: 'TRUCK' })).toBe(true); + expect(isRoadService({ code: 'ROAD_CONTAINER' })).toBe(true); + expect(isRoadService({ code: 'truck_forwarding' })).toBe(true); + }); + + it('treats rail / unknown / missing services as not road', () => { + expect(isRoadService({ code: 'RAIL_CONTAINER' })).toBe(false); + expect(isRoadService({ code: 'OFFROADING' })).toBe(false); + expect(isRoadService(null)).toBe(false); + expect(isRoadService(undefined)).toBe(false); + }); + }); + + describe('roadKmPrice', () => { + it('multiplies distance by the per-km rate', () => { + expect(roadKmPrice(120, 5)).toBe(600); + }); + + it('returns 0 when km or rate is missing/non-positive', () => { + expect(roadKmPrice(null, 5)).toBe(0); + expect(roadKmPrice(120, null)).toBe(0); + expect(roadKmPrice(0, 5)).toBe(0); + expect(roadKmPrice(120, 0)).toBe(0); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/booking-orders/road.util.ts b/apps/edr-freight-api/src/modules/booking-orders/road.util.ts new file mode 100644 index 000000000..cbaad7a9c --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/road.util.ts @@ -0,0 +1,35 @@ +import { ServiceType } from '../rule-engine/entities/service-type.entity'; + +/** + * Road (truck) services are distinguished by their ServiceType.code. Rail + * services are seeded as RAIL_* and go through the train batch pool; a road + * service (code starting ROAD_ or TRUCK_, or exactly ROAD/TRUCK) instead bills + * by distance and dispatches a truck. Prefix-matching keeps this resilient to + * the exact seeded code (e.g. ROAD_CONTAINER, TRUCK_FORWARDING). + */ +export function isRoadService( + serviceType?: Pick | null, +): boolean { + const code = serviceType?.code?.toUpperCase() ?? ''; + return ( + code === 'ROAD' || + code === 'TRUCK' || + code.startsWith('ROAD_') || + code.startsWith('TRUCK_') + ); +} + +/** + * Road freight charge for an order: distance (km, from the route line) × the + * per-km rate. Returns 0 when either input is missing so callers can add it to + * a total without guarding. + */ +export function roadKmPrice( + km: number | null | undefined, + perKmRate: number | null | undefined, +): number { + const distance = Number(km ?? 0); + const rate = Number(perKmRate ?? 0); + if (!(distance > 0) || !(rate > 0)) return 0; + return distance * rate; +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts index abd6377db..fabd3dbca 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-contract.service.ts @@ -23,6 +23,13 @@ import { clearanceSettingCode } from './clearance.util'; import { ContractViewDto } from './dto/contract-view.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ContractSignerRole } from './entities/booking-contract-signature.entity'; + +/** + * Default ordering window (months) for a general contract activated on + * counter-sign. Mirrors GeneralContractService.DEFAULT_CONTRACT_PERIOD_MONTHS; + * defined locally to avoid a circular module dependency on booking-orders. + */ +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { SignaturesService } from '../signatures/signatures.service'; @@ -233,9 +240,23 @@ export class BookingContractService { includesCustoms, ); + const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT'; + if (role === 'CUSTOMER') { updates.status = 'SIGNED_CUSTOMER'; updates.customerSignedAt = now; + } else if (isGeneralContract) { + // A general contract is NOT paid up front — each drawdown order is priced + // and paid on its own. So on counter-sign it becomes ACTIVE directly and + // opens its ordering window; orders spawn their own priced child bookings. + const expiresAt = new Date(now); + expiresAt.setMonth(expiresAt.getMonth() + DEFAULT_CONTRACT_PERIOD_MONTHS); + updates.fullyExecutedAt = now; + updates.marketingApprovedAt = now; + updates.marketingApprovedById = options.signerUserId ?? null; + updates.lockedAt = now; + updates.status = 'CONTRACT_ACTIVE'; + updates.expiresAt = expiresAt; } else { updates.fullyExecutedAt = now; updates.marketingApprovedAt = now; 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 276761ae0..0315de60d 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 @@ -268,6 +268,10 @@ export class BookingPricingService { tradeDirection: booking.tradeDirection, // Coerce defensively in case the stored flag is a string ("true"/"false"). isHazardous: booking.isHazardous === true || (booking.isHazardous as unknown) === 'true', + // Booking-level reefer flag (set by contract drawdown orders that carry a + // reefer quantity) applies the REEFER surcharge even for non-reefer + // container types. ORed with per-container reefer in the engine. + isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true', isGovernment: booking.isGovernment, allowConsolidation, shippingLineId: booking.shippingLineId, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts new file mode 100644 index 000000000..92ab8769e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.operation.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException } from '@nestjs/common'; +import { BookingTransitionService } from './booking-transition.service'; + +/** + * Operation-request review for general-contract drawdown orders: + * - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool. + * - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued. + * - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED. + * - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM. + */ +describe('BookingTransitionService — operation review', () => { + function makeService(serviceTypeCode: string) { + const booking = { + id: 'b-1', + status: 'OPERATION_REQUEST_PENDING', + originYardId: 'o-1', + destinationYardId: 'd-1', + scheduledDate: new Date('2026-07-01T00:00:00.000Z'), + serviceType: { code: serviceTypeCode }, + }; + const bookingsRepository = { + update: jest.fn().mockResolvedValue({ id: 'b-1' }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + }; + const bookingsService = { + findById: jest.fn().mockResolvedValue(booking), + }; + const bookingBatchService = { + enqueueRouteDayProcessing: jest.fn(), + }; + + const service = new BookingTransitionService( + bookingsRepository as never, + {} as never, // ruleEngineService + {} as never, // pricingService + {} as never, // contractService + {} as never, // filesService + {} as never, // fileUploadSettingsService + bookingBatchService as never, + bookingsService as never, + ); + return { service, bookingsRepository, bookingBatchService }; + } + + it('ACCEPT of a train order → FULLY_EXECUTED and enqueues the batch pool', async () => { + const { service, bookingsRepository, bookingBatchService } = + makeService('RAIL_CONTAINER'); + await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'FULLY_EXECUTED' }), + ); + expect(bookingBatchService.enqueueRouteDayProcessing).toHaveBeenCalledTimes(1); + }); + + it('ACCEPT of a road order → ROAD_DISPATCH_PENDING and does NOT enqueue', async () => { + const { service, bookingsRepository, bookingBatchService } = + makeService('ROAD_CONTAINER'); + await service.reviewOperationRequest('b-1', 'ACCEPT', 'staff-1'); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'ROAD_DISPATCH_PENDING' }), + ); + expect(bookingBatchService.enqueueRouteDayProcessing).not.toHaveBeenCalled(); + }); + + it('REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED', async () => { + const { service, bookingsRepository } = makeService('RAIL_CONTAINER'); + await expect( + service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', {}), + ).rejects.toBeInstanceOf(BadRequestException); + + await service.reviewOperationRequest('b-1', 'REQUEST_CHANGES', 'staff-1', { + note: 'Fix the schedule', + }); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }), + ); + }); + + it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => { + const { service, bookingsRepository } = makeService('RAIL_CONTAINER'); + await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', { + amount: 1500, + }); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-1', + expect.objectContaining({ + adjustedTotalAmount: 1500, + status: 'OPERATION_PRICE_PENDING_CONFIRM', + }), + ); + }); +}); 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 521095320..2c2e0ce0e 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 @@ -9,6 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertCanApproveBookingStep } from '../../common/freight-permission.util'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; +import { isRoadService } from '../booking-orders/road.util'; import { RuleEngineService } from '../rule-engine/rule-engine.service'; import { FilesService } from '../files/files.service'; import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service'; @@ -879,12 +880,27 @@ export class BookingTransitionService { } /** - * Move a reviewed operation request into the batch holding pool. The pool query - * (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we set - * those and kick the day-level fill immediately instead of waiting for cron. + * Move a reviewed operation request forward after Marketing accepts. + * + * - Train services enter the batch holding pool: the pool query + * (findBatchPoolByRouteDay) keys on FULLY_EXECUTED + scheduled_date, so we + * set those and kick the day-level fill immediately instead of waiting for + * cron. + * - Road (truck) services skip the train batch entirely and wait for truck + * dispatch at ROAD_DISPATCH_PENDING; they are billed by KM, not wagons. */ private async acceptOperationRequest(booking: Booking): Promise { const now = new Date(); + + if (isRoadService(booking.serviceType)) { + await this.bookingsRepository.update(booking.id, { + status: 'ROAD_DISPATCH_PENDING', + fullyExecutedAt: now, + lockedAt: booking.lockedAt ?? now, + } as never); + return this.bookingsService.findById(booking.id); + } + await this.bookingsRepository.update(booking.id, { status: 'FULLY_EXECUTED', fullyExecutedAt: now, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 9b2caa68b..ded7d0239 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -79,6 +79,6 @@ import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.modu ContractRendererService, ContractPdfService, ], - exports: [BookingsService, BookingsRepository], + exports: [BookingsService, BookingsRepository, BookingPricingService], }) export class BookingsModule {} 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 a2a6db1d1..45d2fbf7c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -463,6 +463,7 @@ export class BookingsService { containerTypeId: dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null, quantity: r.quantity, + km: r.km ?? null, }), ), ); diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index 500c77d79..e66c85522 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -77,6 +77,18 @@ export class CreateContractRouteDto { @Min(0) @Transform(({ value }) => Number(value)) quantity!: number; + + @ApiPropertyOptional({ + description: 'Road distance (km) for this route; used to bill road orders.', + minimum: 0, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => + value === undefined || value === null || value === '' ? undefined : Number(value), + ) + km?: number; } export class CreateBookingDto { 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 6742fad4b..5693cfaf5 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 @@ -47,6 +47,9 @@ export const BOOKING_STATUSES = [ 'AWAITING_DOCUMENTS', 'DOCUMENTS_UNDER_REVIEW', 'CLEARANCE_READY', + // Road (truck) drawdown orders skip the train batch pool and wait here for + // truck dispatch after Marketing accepts; billed by KM, not wagons. + 'ROAD_DISPATCH_PENDING', 'OPERATION_REQUESTED', // Operations review gate: customer picks a schedule day and submits the // operation request; the operations team reviews capacity/docs/route before @@ -288,6 +291,15 @@ export class Booking extends BaseEntity { @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; + /** + * Refrigerated cargo flag. For one-time bookings reefer is derived from the + * container type; for general-contract drawdown orders the customer enters a + * reefer quantity per order, which sets this flag on the spawned child so the + * REEFER_SURCHARGE rate applies even when the container type is not a reefer. + */ + @Column({ name: 'is_reefer', type: 'boolean', default: false }) + isReefer!: boolean; + @Column({ name: 'payment_currency', type: 'varchar', length: 5 }) paymentCurrency!: string; 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 4b0e8ccb3..ce0082f83 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 @@ -51,6 +51,8 @@ export interface BookingEvaluationInput { paymentCurrency: string; tradeDirection: string; isHazardous: boolean; + /** Booking-level reefer flag; ORed with per-container reefer. */ + isReefer?: boolean; isGovernment?: boolean; allowConsolidation?: boolean; shippingLineId?: string | null; @@ -195,7 +197,8 @@ export class RuleEngineService { shippingLineMapped = Boolean(line?.mappedToCode); } - const hasReefer = input.containers.some((c) => c.isReefer); + const hasReefer = + input.isReefer === true || input.containers.some((c) => c.isReefer); const hasOverweight = containerWeightResults.some((r) => r.isOverweight); // Surcharges are now self-describing rates: any LIVE rate whose `trigger` diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx index dac36f7b9..6b8e4b650 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingConfirmDialog.tsx @@ -59,13 +59,17 @@ export function BookingConfirmDialog({ const needsTextInput = action.input === "note" || action.input === "reason"; const needsFileInput = action.input === "file"; const needsDaysInput = action.input === "days"; + const needsAmountInput = action.input === "amount"; const daysValue = Number(inputValue.trim()); const daysValid = Number.isInteger(daysValue) && daysValue >= 1 && daysValue <= 365; + const amountValue = Number(inputValue.trim()); + const amountValid = !!inputValue.trim() && Number.isFinite(amountValue) && amountValue >= 0; const inputMissing = (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile) || - (needsDaysInput && !daysValid); + (needsDaysInput && !daysValid) || + (needsAmountInput && !amountValid); const isDestructive = action.variant === "destructive"; const accent = isDestructive ? "red" : "edr-green"; @@ -168,6 +172,19 @@ export function BookingConfirmDialog({ )} + {needsAmountInput && ( + onInputChange(value === "" ? "" : String(value))} + /> + )} {extra} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts index 56cd95241..bc2aac985 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/useBookingActionDialog.ts @@ -15,6 +15,13 @@ function isValidValidityDays(value: string): boolean { return Number.isInteger(days) && days >= 1 && days <= 365; } +/** An adjusted price must be a non-negative number. */ +function isValidAmount(value: string): boolean { + if (!value.trim()) return false; + const amount = Number(value.trim()); + return Number.isFinite(amount) && amount >= 0; +} + export function useBookingActionDialog( bookingId: string, context: BookingActionContext, @@ -77,6 +84,24 @@ export function useBookingActionDialog( case "reject": mutations.staffReject.mutate(inputValue.trim(), { onSuccess }); break; + case "operationAccept": + mutations.reviewOperation.mutate({ decision: "ACCEPT" }, { onSuccess }); + break; + case "operationRequestChanges": + mutations.reviewOperation.mutate( + { decision: "REQUEST_CHANGES", note: inputValue.trim() }, + { onSuccess }, + ); + break; + case "operationAdjustPrice": { + const amount = Number(inputValue.trim()); + if (!Number.isFinite(amount) || amount < 0) return; + mutations.reviewOperation.mutate( + { decision: "ADJUST_PRICE", amount }, + { onSuccess }, + ); + break; + } case "approve": { const step = getNextPendingApprovalStep(mergedContext.approvalSteps); if (!step) return; @@ -126,7 +151,8 @@ export function useBookingActionDialog( (pendingAction?.input === "file" && !selectedFile) || (pendingAction?.input === "reason" && !inputValue.trim()) || (pendingAction?.input === "note" && !inputValue.trim()) || - (pendingAction?.input === "days" && !isValidValidityDays(inputValue)); + (pendingAction?.input === "days" && !isValidValidityDays(inputValue)) || + (pendingAction?.input === "amount" && !isValidAmount(inputValue)); return { actions, diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts index 04b8a70b9..d5b59d072 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-actions.config.ts @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import { Ban, Check, + Coins, FileSignature, MessageSquareWarning, Play, @@ -34,9 +35,17 @@ export type BookingActionId = | "allocateBooking" | "startTransit" | "complete" + | "operationAccept" + | "operationRequestChanges" + | "operationAdjustPrice" | "cancel"; -export type BookingActionInputKind = "note" | "reason" | "file" | "days"; +export type BookingActionInputKind = + | "note" + | "reason" + | "file" + | "days" + | "amount"; export interface BookingActionDef { id: BookingActionId; @@ -153,6 +162,50 @@ const SUBMITTED_ACTIONS: BookingActionDef[] = [ }, ]; +// Marketing/operations review of a drawdown order's operation request. +const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [ + { + id: "operationAccept", + label: "Accept operation", + shortLabel: "Accept", + description: "Accept the operation request and release it for dispatch", + confirmTitle: "Accept operation request?", + confirmDescription: + "Train orders enter the batch pool; road orders move to truck dispatch.", + variant: "default", + icon: Check, + primary: true, + }, + { + id: "operationRequestChanges", + label: "Request changes", + shortLabel: "Changes", + description: "Ask the customer to adjust the operation request", + confirmTitle: "Request changes to the operation?", + confirmDescription: + "The customer will see your note and can adjust and resubmit the order.", + variant: "outline", + icon: MessageSquareWarning, + input: "note", + inputLabel: "Message to customer", + inputPlaceholder: "Describe what needs to change…", + }, + { + id: "operationAdjustPrice", + label: "Adjust price", + shortLabel: "Price", + description: "Set an adjusted total the customer must confirm", + confirmTitle: "Adjust the order price?", + confirmDescription: + "Enter the new total. The customer must confirm it before the order proceeds.", + variant: "outline", + icon: Coins, + input: "amount", + inputLabel: "Adjusted total", + inputPlaceholder: "0.00", + }, +]; + const CANCEL_ACTION: BookingActionDef = { id: "cancel", label: "Cancel booking", @@ -204,6 +257,9 @@ const ACTION_PERMISSION: Partial> = { signContractStaff: FREIGHT_PERMS.bookings.signStaff, startTransit: FREIGHT_PERMS.bookings.operations, complete: FREIGHT_PERMS.bookings.operations, + operationAccept: FREIGHT_PERMS.bookings.operations, + operationRequestChanges: FREIGHT_PERMS.bookings.operations, + operationAdjustPrice: FREIGHT_PERMS.bookings.operations, allocateBooking: FREIGHT_PERMS.trainScheduling.manage, cancel: FREIGHT_PERMS.bookings.cancel, }; @@ -291,6 +347,9 @@ export function getBookingActions( case "FULLY_EXECUTED": actions = [{ ...VIEW_CONTRACT_ACTION, label: "View executed contract", primary: true }]; break; + case "OPERATION_REQUEST_PENDING": + actions = withCancel(OPERATION_REVIEW_ACTIONS); + break; case "PAID": if (canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })) { actions = [ diff --git a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts index 213cb7c2e..6633b0d39 100644 --- a/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts +++ b/apps/edr-freight-web/backoffice/src/features/bookings/booking-status.config.ts @@ -86,6 +86,22 @@ export const BOOKING_STATUS_STYLES: Record = { label: "Consolidated", color: "bg-indigo-50 text-indigo-700 border-indigo-200", }, + OPERATION_REQUEST_PENDING: { + label: "Operation Review", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + OPERATION_CHANGES_REQUESTED: { + label: "Operation Changes", + color: "bg-orange-50 text-orange-700 border-orange-200", + }, + OPERATION_PRICE_PENDING_CONFIRM: { + label: "Price Confirm", + color: "bg-amber-50 text-amber-700 border-amber-200", + }, + ROAD_DISPATCH_PENDING: { + label: "Truck Dispatch", + color: "bg-blue-50 text-blue-700 border-blue-200", + }, }; export interface StatusMeta { @@ -257,10 +273,19 @@ export const BOOKING_LIST_TABS = [ "EXPIRED", ], }, + { + key: "ops_review", + label: "Ops review", + statuses: [ + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", + ], + }, { key: "operations", label: "Operations", - statuses: ["PAID", "IN_TRANSIT"], + statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"], }, { key: "completed", label: "Completed", statuses: ["COMPLETED"] }, { key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] }, diff --git a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts index f89b0a411..9b5f2b489 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/bookings/useBookings.ts @@ -61,6 +61,16 @@ export function useBookingMutations(bookingId: string) { onError: () => toast.error("Failed to reject booking"), }); + const reviewOperation = useMutation({ + mutationFn: (payload: { + decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE"; + note?: string; + amount?: number; + }) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }), + onSuccess: (data) => onSuccess(data, "Operation request reviewed"), + onError: () => toast.error("Failed to review operation request"), + }); + const approveStep = useMutation({ mutationFn: ({ stepId, @@ -148,12 +158,14 @@ export function useBookingMutations(bookingId: string) { payBooking.isPending || startTransit.isPending || complete.isPending || + reviewOperation.isPending || cancel.isPending; return { staffAccept, requestChanges, staffReject, + reviewOperation, approveStep, rejectStep, generateContract, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx index 6888f71c6..cf51c4967 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/GlClearancePage.tsx @@ -2,27 +2,41 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Card, FileButton, Group, + Loader, + Progress, + ScrollArea, Stack, Text, TextInput, + Textarea, + ThemeIcon, + Tooltip, } from "@mantine/core"; import { AlertCircle, CheckCircle2, Clock, Download, + ExternalLink, FileText, + Inbox, + MessageSquareWarning, + Search, ShieldCheck, Upload, + X, } from "lucide-react"; import toast from "react-hot-toast"; import type { Freight } from "@edr/types"; +import { PageContainer } from "@/components/page/PageContainer"; +import { PageHeader } from "@/components/page/PageHeader"; import { bookingsService } from "@/services/bookings.service"; const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; @@ -30,85 +44,214 @@ const REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW"; export default function GlClearancePage() { const qc = useQueryClient(); const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); // Bookings currently awaiting GL document review. const { data: list, isLoading } = useQuery({ queryKey: ["gl-clearance", "list"], - queryFn: () => bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }), + queryFn: () => + bookingsService.list({ status: REVIEW_STATUS, pageSize: 100 }), }); const bookings = list?.items ?? []; - const activeId = selectedId ?? bookings[0]?.id ?? null; + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return bookings; + return bookings.filter( + (b) => + b.reference?.toLowerCase().includes(q) || + b.tradeDirection?.toLowerCase().includes(q) || + b.freightType?.toLowerCase().includes(q), + ); + }, [bookings, search]); + + const activeId = + selectedId && filtered.some((b) => b.id === selectedId) + ? selectedId + : (filtered[0]?.id ?? null); return ( - - - - - Document Clearance - - + + } + > + {bookings.length} awaiting review + + } + /> -
- - - Awaiting review ({bookings.length}) - - {isLoading && ( - - Loading… +
+ {/* ── Review queue ─────────────────────────────────────────────── */} + + + + Review queue + + {filtered.length} + + + + setSearch(e.currentTarget.value)} + placeholder="Search reference…" + size="xs" + radius="md" + mb="xs" + leftSection={} + rightSection={ + search ? ( + setSearch("")} + /> + ) : null + } + /> + + {isLoading ? ( + + + + Loading… + + + ) : filtered.length === 0 ? ( + + + + + + {search + ? "No bookings match your search." + : "Nothing awaiting document review."} + + + ) : ( + + + {filtered.map((b) => ( + setSelectedId(b.id)} + /> + ))} + + )} - {!isLoading && bookings.length === 0 && ( - - No bookings awaiting document review. - - )} - - {bookings.map((b) => ( - - ))} - + {/* ── Review panel ─────────────────────────────────────────────── */} {activeId ? ( qc.invalidateQueries({ queryKey: ["gl-clearance", "list"] }) } /> ) : ( - - Select a booking to review its documents. - + )}
+ + ); +} + +/** A single booking row in the left-hand review queue. */ +function QueueItem({ + booking, + active, + onSelect, +}: { + booking: Freight.IBooking; + active: boolean; + onSelect: () => void; +}) { + return ( + + + + + {booking.reference} + + + + {booking.tradeDirection} + + + {booking.freightType} + + + + ); } +function EmptyPanel() { + return ( + + + + + + + No booking selected + + + Pick a booking from the review queue to inspect its customer documents + and start clearance. + + + + ); +} + function ClearanceReviewPanel({ bookingId, onChanged, @@ -118,6 +261,7 @@ function ClearanceReviewPanel({ }) { const qc = useQueryClient(); const [queryNotes, setQueryNotes] = useState>({}); + const [openQuery, setOpenQuery] = useState>({}); const [outputFiles, setOutputFiles] = useState>({}); const { data: clearance, isLoading } = useQuery({ @@ -136,15 +280,20 @@ function ClearanceReviewPanel({ status: "APPROVED" | "QUERIED"; note?: string; }) => bookingsService.reviewClearanceDocument(bookingId, p), - onSuccess: () => { - toast.success("Document updated"); + onSuccess: (_d, p) => { + toast.success( + p.status === "APPROVED" ? "Document approved" : "Query sent to customer", + ); + if (p.status === "QUERIED") + setOpenQuery((o) => ({ ...o, [p.fileKey]: false })); refresh(); }, onError: () => toast.error("Could not update document"), }); const outputMutation = useMutation({ - mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles), + mutationFn: () => + bookingsService.uploadClearanceOutput(bookingId, outputFiles), onSuccess: () => { toast.success("Output documents uploaded"); setOutputFiles({}); @@ -160,7 +309,9 @@ function ClearanceReviewPanel({ refresh(); }, onError: (e) => - toast.error(e instanceof Error ? e.message : "Could not finalize clearance"), + toast.error( + e instanceof Error ? e.message : "Could not finalize clearance", + ), }); const customerDocs = useMemo( @@ -172,85 +323,151 @@ function ClearanceReviewPanel({ [clearance], ); + // Review progress across the customer documents — drives the summary bar. + const stats = useMemo(() => { + const total = customerDocs.length; + const approved = customerDocs.filter( + (d) => d.reviewStatus === "APPROVED", + ).length; + const queried = customerDocs.filter( + (d) => d.reviewStatus === "QUERIED", + ).length; + const pending = total - approved - queried; + return { total, approved, queried, pending }; + }, [customerDocs]); + if (isLoading || !clearance) { return ( - - Loading clearance… + + + + Loading clearance… + ); } + const progressPct = + stats.total === 0 ? 0 : Math.round((stats.approved / stats.total) * 100); + return ( - - - - Customer documents - + {/* ── Progress summary ───────────────────────────────────────────── */} + + + + + Customer documents + + + Approve each document, or open a query to tell the customer what to + fix. + + {clearance.allApproved ? ( - - - - All approved - - + } + > + All approved + ) : ( - - - - Review pending - - + } + > + Review pending + )} - - {customerDocs.map((doc) => ( - - setQueryNotes((n) => ({ ...n, [doc.fileKey]: v })) - } - onApprove={() => - reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" }) - } - onQuery={() => - reviewMutation.mutate({ - fileKey: doc.fileKey, - status: "QUERIED", - note: queryNotes[doc.fileKey], - }) - } - busy={reviewMutation.isPending} - /> - ))} - + + + + + + + {stats.approved}/{stats.total} approved + + + {/* ── Document review list ───────────────────────────────────────── */} + + {customerDocs.map((doc) => ( + + setOpenQuery((o) => ({ ...o, [doc.fileKey]: open })) + } + onNote={(v) => setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))} + onApprove={() => + reviewMutation.mutate({ fileKey: doc.fileKey, status: "APPROVED" }) + } + onQuery={() => + reviewMutation.mutate({ + fileKey: doc.fileKey, + status: "QUERIED", + note: queryNotes[doc.fileKey], + }) + } + busy={reviewMutation.isPending} + /> + ))} + + + {/* ── Customs output documents (GL-supplied) ─────────────────────── */} {clearance.outputCode && ( - - - Customs output documents - + + + + + + + Customs output documents + + {glDocs.map((doc) => ( - - + + {doc.label} {doc.required ? " *" : ""} {doc.file ? ( - - - + + + + + ) : ( - + Not uploaded )} @@ -300,25 +517,74 @@ function ClearanceReviewPanel({ )} - - - + {/* ── Finalize bar ───────────────────────────────────────────────── */} + + + + {clearance.allApproved + ? "All required documents are approved. You can finalize clearance." + : "Approve every required document to unlock finalization."} + + + + ); } -function DocReviewRow({ +function StatPill({ + color, + label, + value, +}: { + color: string; + label: string; + value: number; +}) { + return ( + + + + {value} + + + {label} + + + ); +} + +/** Visual treatment for each document review state. */ +const STATUS_META: Record< + Freight.DocumentReviewStatus, + { label: string; color: string } +> = { + APPROVED: { label: "Approved", color: "edr-green" }, + QUERIED: { label: "Queried", color: "red" }, + PENDING: { label: "Pending", color: "edr-slate" }, +}; + +function DocReviewCard({ doc, note, + queryOpen, + onToggleQuery, onNote, onApprove, onQuery, @@ -326,74 +592,174 @@ function DocReviewRow({ }: { doc: Freight.ClearanceDocument; note: string; + queryOpen: boolean; + onToggleQuery: (open: boolean) => void; onNote: (v: string) => void; onApprove: () => void; onQuery: () => void; busy: boolean; }) { + const status = doc.reviewStatus ?? "PENDING"; + const meta = STATUS_META[status]; + const hasFile = !!doc.file; + return ( - - - - + + + + + + - + {doc.label} {doc.required ? " *" : ""} - - {doc.file ? doc.file.name : "Not uploaded"} + + {hasFile ? doc.file!.name : "Not uploaded by customer"} + - {doc.reviewStatus === "APPROVED" && ( - - Approved - - )} - {doc.reviewStatus === "QUERIED" && ( - - Queried - - )} - {doc.file && ( - - - + + {meta.label} + + {hasFile && ( + + + )} - {doc.file && ( - - onNote(e.currentTarget.value)} - style={{ flex: 1 }} - radius="md" - size="xs" - /> - - - + {/* Previously raised query — visible so staff see what was asked. */} + {status === "QUERIED" && doc.note && ( + } + p="xs" + > + + {doc.note} + + )} - + + {/* Action row — only when the customer actually uploaded a file. */} + {hasFile && ( + + {!queryOpen ? ( + + + + + ) : ( + + + + + Describe the problem for the customer + + +