From 3f7b744987af814b05312e1c03d7c64d81a1c0d3 Mon Sep 17 00:00:00 2001 From: hager Date: Wed, 2 Sep 2026 11:05:03 +0000 Subject: [PATCH] feat(empty-return-requests): customer-requested empty return, priced and paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A booking sold WITHOUT the return service had no way to send its empties back: the containers were the customer's problem and nothing in the system priced, billed or planned the movement. Adds the request flow end to end. The customer opens the booking, says how many containers are coming back and types each number; the request lands in a new backoffice queue (Empty Return Requests). Approval prices it off the same live WITH_RETURN route rate the rule engine bills when the service IS bought up front — per container, converted to birr, and overridable by the reviewer — and issues the invoice there and then. Payment settles through the normal invoice path, whose `empty_return_request.invoice.paid` event moves the request to PAID; the customer then books the return date and the truck. Scheduled requests surface on Container Returns as Planned Empty Returns, where confirming the arrival records the containers through the existing empty-container-return flow — which in turn closes the request once its last container is in. Container freight only, never a booking that already ships WITH_RETURN, and only from IN_TRANSIT onward: the empty comes back after delivery, so the option has to outlive ARRIVED. Co-Authored-By: Claude Opus 5 (1M context) --- apps/edr-freight-api/src/app.module.ts | 2 + .../3840000000000-EmptyReturnRequests.ts | 67 ++ .../dto/empty-return-request.dto.ts | 87 +++ .../empty-return-requests.controller.ts | 126 ++++ .../empty-return-requests.module.ts | 27 + .../empty-return-requests.repository.ts | 16 + .../empty-return-requests.service.spec.ts | 344 ++++++++++ .../empty-return-requests.service.ts | 610 ++++++++++++++++++ .../entities/empty-return-request.entity.ts | 127 ++++ .../import-operations.module.ts | 4 + .../import-operations.service.ts | 4 + .../src/seed/freight-permissions.registry.ts | 21 + apps/edr-freight-web/backoffice/src/App.tsx | 11 + .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/constants/URLS.ts | 9 + .../backoffice/src/lib/permissions.ts | 4 + .../pages/warehouses/ContainerReturnsPage.tsx | 162 ++++- .../warehouses/EmptyReturnRequestsPage.tsx | 477 ++++++++++++++ .../services/emptyReturnRequests.service.ts | 62 ++ .../backoffice/src/types/importOperations.ts | 57 ++ .../portal/src/constants/URLS.ts | 7 + .../BookingDetailPage/ReadonlyBookingView.tsx | 2 + .../components/EmptyReturnRequestPanel.tsx | 460 +++++++++++++ .../services/empty-return-requests.service.ts | 87 +++ 24 files changed, 2770 insertions(+), 10 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts create mode 100644 apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/emptyReturnRequests.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/EmptyReturnRequestPanel.tsx create mode 100644 apps/edr-freight-web/portal/src/services/empty-return-requests.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index a310e496b..8ff24fd8e 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -116,6 +116,7 @@ import { FacilitiesModule } from "./modules/facilities/facilities.module"; import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module"; import { FirstMileModule } from "./modules/first-mile/first-mile.module"; import { LastMileModule } from "./modules/last-mile/last-mile.module"; +import { EmptyReturnRequestsModule } from "./modules/empty-return-requests/empty-return-requests.module"; import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module"; import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; @@ -258,6 +259,7 @@ if (!process.env.APPLICATION_NAME) { FirstMileModule, LastMileModule, LastMileRequestsModule, + EmptyReturnRequestsModule, InterchangeDocumentsModule, ImportOperationsModule, VerifaydaModule, diff --git a/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts new file mode 100644 index 000000000..157b95570 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3840000000000-EmptyReturnRequests.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Customer-initiated empty container return, for a booking that did NOT buy + * the return service up front. The customer names the containers coming back, + * operations approves and prices it off the contract's WITH_RETURN rate, the + * customer pays that invoice and then books the date and truck. The empty + * itself is still recorded through `empty_container_returns` when the truck + * actually arrives — this table only carries the request up to that point. + */ +export class EmptyReturnRequests3840000000000 implements MigrationInterface { + name = 'EmptyReturnRequests3840000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.empty_return_requests ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + booking_id uuid NOT NULL, + company_id uuid, + status varchar(30) NOT NULL DEFAULT 'SUBMITTED', + container_numbers text[] NOT NULL DEFAULT '{}', + container_count smallint NOT NULL DEFAULT 0, + quoted_unit_amount numeric(14,2), + quoted_total_amount numeric(14,2), + currency varchar(8), + invoice_id uuid, + paid_at timestamptz, + requested_return_date date, + truck_plate_number varchar(32), + truck_driver_name varchar(120), + truck_type varchar(60), + scheduled_at timestamptz, + submitted_by_user_id uuid, + submitted_at timestamptz NOT NULL DEFAULT now(), + reviewed_by_staff_id uuid, + reviewed_at timestamptz, + rejection_reason text, + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ) + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_booking + ON freight.empty_return_requests (booking_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_status + ON freight.empty_return_requests (status) + `); + + // A container number may only be owed back once at a time. That guard is + // per array element, so it lives in the service (see assertContainersFree) + // rather than in a unique index — this GIN index is what makes the check + // cheap. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_empty_return_requests_containers + ON freight.empty_return_requests USING gin (container_numbers) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts new file mode 100644 index 000000000..c588a5b03 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/dto/empty-return-request.dto.ts @@ -0,0 +1,87 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateEmptyReturnRequestDto { + @ApiProperty({ description: 'Booking the empties came in on.' }) + @IsUUID() + bookingId!: string; + + @ApiProperty({ + type: [String], + description: + 'One container number per empty being returned — the customer types as many as they said they are sending back.', + example: ['TEMU1234567', 'MSCU7654321'], + }) + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsString({ each: true }) + @MinLength(4, { each: true }) + @MaxLength(64, { each: true }) + containerNumbers!: string[]; +} + +export class ApproveEmptyReturnRequestDto { + @ApiPropertyOptional({ + description: + 'Per-container price to bill. Defaults to the route WITH_RETURN rate the quote was built from.', + }) + @IsOptional() + @IsNumber() + @IsPositive() + unitAmount?: number; + + @ApiPropertyOptional({ + description: 'Currency of `unitAmount`. Defaults to the quote currency (ETB).', + }) + @IsOptional() + @IsString() + @MaxLength(8) + currency?: string; +} + +export class RejectEmptyReturnRequestDto { + @ApiProperty({ description: 'Why the request was turned down — shown to the customer.' }) + @IsString() + @MinLength(3) + reason!: string; +} + +export class ScheduleEmptyReturnRequestDto { + @ApiProperty({ + description: 'The day the customer will hand the empties over.', + example: '2026-09-20', + }) + @IsDateString() + returnDate!: string; + + @ApiProperty({ description: 'Plate of the truck bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(32) + truckPlateNumber!: string; + + @ApiProperty({ description: 'Driver bringing the empties back.' }) + @IsString() + @MinLength(2) + @MaxLength(120) + truckDriverName!: string; + + @ApiPropertyOptional({ description: 'Truck type (flatbed, container chassis…).' }) + @IsOptional() + @IsString() + @MaxLength(60) + truckType?: string; +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts new file mode 100644 index 000000000..1451f86c7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.controller.ts @@ -0,0 +1,126 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { BookingStaff, MixedAudience, PortalCustomer } from '../../common/booking-guards'; +import { hasFreightPermission } from '../../common/freight-permission.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity'; + +@ApiTags('empty-return-requests') +@ApiBearerAuth() +@Controller('empty-return-requests') +export class EmptyReturnRequestsController { + constructor(private readonly service: EmptyReturnRequestsService) {} + + @Get() + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: 'Empty container return requests queue' }) + findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) { + return this.service.findAll({ + status: status as EmptyReturnRequestStatus | undefined, + bookingId, + }); + } + + @Get('planned') + @BookingStaff(FREIGHT_PERMS.warehouseInventory.view) + @ApiOperation({ + summary: 'Scheduled empty returns the warehouse is expecting, with date and truck', + }) + planned() { + return this.service.plannedReturns(); + } + + @Get('eligibility/:bookingId') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ + summary: + 'Whether a booking may request an empty return, its free containers, and the price per container', + }) + eligibility( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.eligibility(bookingId, this.portalUserId(user)); + } + + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: "A booking's empty return requests, newest first" }) + findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) { + return this.service.findForBooking(bookingId); + } + + @Get(':id') + @MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view) + @ApiOperation({ summary: 'Get an empty return request by ID' }) + findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) { + return this.service.findById(id, this.portalUserId(user)); + } + + @Post() + @PortalCustomer() + @ApiOperation({ + summary: 'Customer requests to return empty containers on a booking sold without return', + }) + create(@Body() dto: CreateEmptyReturnRequestDto, @CurrentUser() user: TCurrentUser) { + return this.service.create(dto, user?.id ?? null); + } + + @Post(':id/schedule') + @PortalCustomer() + @ApiOperation({ + summary: 'Customer sets the return date and the truck bringing the empties back', + }) + schedule( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ScheduleEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.schedule(id, user?.id ?? null, dto); + } + + @Post(':id/approve') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ + summary: + 'Approve and bill the request — the price defaults to the route WITH_RETURN rate per container', + }) + approve( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.approve(id, user?.id ?? null, dto); + } + + @Post(':id/reject') + @BookingStaff(FREIGHT_PERMS.emptyReturnRequests.review) + @ApiOperation({ summary: 'Reject the request with a reason shown to the customer' }) + reject( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectEmptyReturnRequestDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.reject(id, user?.id ?? null, dto); + } + + /** + * Staff read any booking's request; a customer is held to their own. Passing + * the user id is what turns the ownership check on, so staff pass null. + */ + private portalUserId(user: TCurrentUser): string | null { + if (hasFreightPermission(user, FREIGHT_PERMS.emptyReturnRequests.review)) return null; + return user?.id ?? null; + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts new file mode 100644 index 000000000..f682f80e5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.module.ts @@ -0,0 +1,27 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { registerExchangeModule } from '../exchange-settings/exchange-module-options'; +import { BillingModule } from '../billing/billing.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; +import { RuleEngineModule } from '../rule-engine/rule-engine.module'; +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsController } from './empty-return-requests.controller'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; +import { EmptyReturnRequestsService } from './empty-return-requests.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EmptyReturnRequest]), + BillingModule, + forwardRef(() => BookingsModule), + NotificationInboxModule, + RuleEngineModule, + registerExchangeModule(), + ], + controllers: [EmptyReturnRequestsController], + providers: [EmptyReturnRequestsRepository, EmptyReturnRequestsService], + exports: [EmptyReturnRequestsService], +}) +export class EmptyReturnRequestsModule {} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts new file mode 100644 index 000000000..067c869ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BaseRepository } from '@edr/api-common'; + +import { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +@Injectable() +export class EmptyReturnRequestsRepository extends BaseRepository { + constructor( + @InjectRepository(EmptyReturnRequest) + repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts new file mode 100644 index 000000000..9b8c29e64 --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts @@ -0,0 +1,344 @@ +import { BadRequestException } from '@nestjs/common'; + +import { EmptyReturnRequestsService } from './empty-return-requests.service'; +import type { EmptyReturnRequest } from './entities/empty-return-request.entity'; + +/** + * The service is mostly gates and pricing over raw SQL, so the SQL is stubbed + * by matching a distinctive fragment of each statement. Every stub returns the + * shape the real query returns. + */ +type QueryStub = Array<[string, unknown]>; + +const booking = { + id: 'b1', + reference: 'BK-2026-000300', + companyId: 'co1', + companyProfileId: 'cp1', + status: 'ARRIVED', + freightType: 'CONTAINER', + equipmentReturn: 'WITHOUT_RETURN', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + paymentCurrency: 'ETB', +}; + +function build( + overrides: { + booking?: Partial; + request?: Partial; + rates?: unknown[]; + queries?: QueryStub; + } = {}, +) { + const merged = { ...booking, ...overrides.booking }; + + const requestRow: EmptyReturnRequest = { + id: 'r1', + bookingId: merged.id, + companyId: merged.companyId, + status: 'SUBMITTED', + containerNumbers: ['TEMU1111111', 'TEMU2222222', 'TEMU3333333'], + containerCount: 3, + submittedAt: new Date(), + ...overrides.request, + } as EmptyReturnRequest; + + const stubs: QueryStub = [ + ['FROM freight.booking_container\n', [{ containerTypeId: 'ct-40' }]], + [ + 'upper(bcu.container_number)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ['COALESCE(SUM(quantity), 0)', [{ quantity: '5' }]], + ['unnest(r.container_numbers)', []], + ['COUNT(*) AS outstanding', [{ outstanding: '0' }]], + ...(overrides.queries ?? []), + ]; + + const query = jest.fn(async (sql: string) => { + // Later stubs win, so a test can override one of the defaults. + for (let i = stubs.length - 1; i >= 0; i -= 1) { + if (sql.includes(stubs[i][0])) return stubs[i][1]; + } + return []; + }); + + const requests = { + findById: jest.fn(async () => requestRow), + findAll: jest.fn(async () => [requestRow]), + create: jest.fn(async (data: Partial) => ({ ...requestRow, ...data })), + update: jest.fn(async () => requestRow), + }; + const bookingsService = { + findById: jest.fn(async () => merged), + assertCustomerCanAccessBooking: jest.fn(async () => undefined), + }; + const billing = { generateInvoice: jest.fn(async () => ({ id: 'inv1' })) }; + const notifications = { notify: jest.fn(async () => undefined) }; + const ratesService = { + findLiveRatesDetailed: jest.fn( + async () => + overrides.rates ?? [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: 'ct-40', + rateValue: '100', + }, + ], + ), + }; + const exchange = { getRate: jest.fn(async () => 120) }; + + const service = new EmptyReturnRequestsService( + requests as never, + { findById: jest.fn(async () => merged) } as never, + bookingsService as never, + billing as never, + notifications as never, + ratesService as never, + exchange as never, + { query } as never, + ); + + return { + service, + requests, + bookingsService, + billing, + notifications, + query, + requestRow, + booking: merged, + }; +} + +describe('EmptyReturnRequestsService — eligibility', () => { + it('lets an arrived container booking sold without return ask for one', async () => { + const { service } = build(); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(true); + expect(result.reason).toBeNull(); + expect(result.availableContainerNumbers).toEqual(['TEMU1111111', 'TEMU2222222']); + }); + + it('refuses bulk freight — there is no equipment to give back', async () => { + const { service } = build({ booking: { freightType: 'BULK' } }); + const result = await service.eligibility('b1', 'user1'); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/container freight only/i); + }); + + it('refuses a booking that already bought the return service', async () => { + const withReturn = build({ booking: { equipmentReturn: 'WITH_RETURN' } }); + const legacy = build({ booking: { equipmentReturn: 'RETURN' } }); + + expect((await withReturn.service.eligibility('b1', null)).reason).toMatch( + /already ships with/i, + ); + expect((await legacy.service.eligibility('b1', null)).reason).toMatch(/already ships with/i); + }); + + it('refuses a booking that has not shipped yet', async () => { + const { service } = build({ booking: { status: 'PAID' } }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/once the booking is in transit/i); + }); + + it('allows it after delivery, when the empty actually comes back', async () => { + const { service } = build({ booking: { status: 'COMPLETED' } }); + expect((await service.eligibility('b1', null)).eligible).toBe(true); + }); + + it('refuses when every container is already on a request', async () => { + const { service } = build({ + queries: [ + [ + 'unnest(r.container_numbers)', + [{ containerNumber: 'TEMU1111111' }, { containerNumber: 'TEMU2222222' }], + ], + ], + }); + const result = await service.eligibility('b1', null); + + expect(result.eligible).toBe(false); + expect(result.reason).toMatch(/already on an empty return request/i); + }); + + it('checks booking ownership for a portal caller, and skips it for staff', async () => { + const portal = build(); + await portal.service.eligibility('b1', 'user1'); + expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled(); + + const staff = build(); + await staff.service.eligibility('b1', null); + expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled(); + }); +}); + +describe('EmptyReturnRequestsService — pricing', () => { + it('prices a container at the route WITH_RETURN rate, converted to birr', async () => { + const { service, booking: b } = build(); + const quote = await service.quote(b as never); + + // 100 USD × 120 ETB/USD + expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 }); + expect(quote.unavailableReason).toBeNull(); + }); + + it('falls back to the route rate that names no container type', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'IMPORT', + originYardId: 'y-dj', + destinationYardId: 'y-mojo', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + + expect((await service.quote(b as never)).unitAmount).toBe(9600); + }); + + it('reports no price when no rate covers the route', async () => { + const { service, booking: b } = build({ + rates: [ + { + trigger: 'WITH_RETURN', + currency: 'USD', + tradeDirection: 'EXPORT', + originYardId: 'other', + destinationYardId: 'other', + containerTypeId: null, + rateValue: '80', + }, + ], + }); + const quote = await service.quote(b as never); + + expect(quote.unitAmount).toBeNull(); + expect(quote.unavailableReason).toMatch(/no empty-return rate/i); + }); +}); + +describe('EmptyReturnRequestsService — approval', () => { + it('bills container count × the route rate and stores the invoice', async () => { + const { service, billing, requests } = build(); + await service.approve('r1', 'staff1', {}); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'empty_return_request', + sourceId: 'r1', + currency: 'ETB', + totalAmount: 36000, // 3 × 12,000 + }), + ); + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'APPROVED', + quotedUnitAmount: 12000, + quotedTotalAmount: 36000, + invoiceId: 'inv1', + }), + ); + }); + + it("bills the reviewer's override instead of the route rate", async () => { + const { service, billing } = build(); + await service.approve('r1', 'staff1', { unitAmount: 5000 }); + + expect(billing.generateInvoice).toHaveBeenCalledWith( + expect.objectContaining({ totalAmount: 15000 }), + ); + }); + + it('refuses to approve without a price when no rate covers the route', async () => { + const { service } = build({ rates: [] }); + await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('only approves a submitted request', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/); + }); +}); + +describe('EmptyReturnRequestsService — scheduling', () => { + const details = { + returnDate: '2026-09-20', + truckPlateNumber: '3-a12345', + truckDriverName: 'Abebe K.', + }; + + it('takes the date and truck once the invoice is paid', async () => { + const { service, requests } = build({ request: { status: 'PAID' } }); + await service.schedule('r1', 'user1', details); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ + status: 'SCHEDULED', + requestedReturnDate: '2026-09-20', + truckPlateNumber: '3-A12345', + }), + ); + }); + + it('tells an unpaid customer to pay first', async () => { + const { service } = build({ request: { status: 'APPROVED' } }); + await expect(service.schedule('r1', 'user1', details)).rejects.toThrow( + /Pay the empty return invoice/, + ); + }); +}); + +describe('EmptyReturnRequestsService — payment and completion', () => { + it('moves an approved request to PAID when its invoice settles', async () => { + const { service, requests } = build({ request: { status: 'APPROVED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' })); + }); + + it('ignores a settlement for a request that is not awaiting payment', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.onInvoicePaid({ sourceId: 'r1' }); + + expect(requests.update).not.toHaveBeenCalled(); + }); + + it('completes a scheduled request once every container is recorded back', async () => { + const { service, requests } = build({ request: { status: 'SCHEDULED' } }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).toHaveBeenCalledWith( + 'r1', + expect.objectContaining({ status: 'COMPLETED' }), + ); + }); + + it('leaves it scheduled while any container is still outstanding', async () => { + const { service, requests } = build({ + request: { status: 'SCHEDULED' }, + queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]], + }); + await service.settleScheduledForBooking('b1'); + + expect(requests.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts new file mode 100644 index 000000000..a35934fab --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -0,0 +1,610 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; +import { DataSource } from 'typeorm'; + +import { ExchangeService } from '@edr/api-common'; +import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types'; + +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { BillingService } from '../billing/billing.service'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { BookingsService } from '../bookings/bookings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { RatesService } from '../rule-engine/services/rates.service'; +import { + ApproveEmptyReturnRequestDto, + CreateEmptyReturnRequestDto, + RejectEmptyReturnRequestDto, + ScheduleEmptyReturnRequestDto, +} from './dto/empty-return-request.dto'; +import { + EmptyReturnRequest, + type EmptyReturnRequestStatus, +} from './entities/empty-return-request.entity'; +import { EmptyReturnRequestsRepository } from './empty-return-requests.repository'; + +/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */ +const INVOICE_SOURCE = 'empty_return_request'; + +/** + * Booking statuses that may still ask for an empty return. The empty only goes + * back after the cargo is delivered, so everything from departure onward + * qualifies — cutting it off at ARRIVED would take the option away exactly + * when the customer needs it. + */ +const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED']; + +/** Requests that still hold their container numbers — a rejected one releases them. */ +const OPEN_STATUSES: EmptyReturnRequestStatus[] = [ + 'SUBMITTED', + 'APPROVED', + 'PAID', + 'SCHEDULED', + 'COMPLETED', +]; + +export interface EmptyReturnQuote { + /** Per-container price in `currency`; null when no rate covers this route. */ + unitAmount: number | null; + currency: string; + /** The USD route rate the quote came from, before conversion. */ + sourceRateUsd: number | null; + /** Why there is no price, for the UI to show instead of a number. */ + unavailableReason: string | null; +} + +export interface EmptyReturnEligibility { + eligible: boolean; + /** Why the customer cannot request one, when `eligible` is false. */ + reason: string | null; + /** Containers on the booking that are not already spoken for. */ + availableContainerNumbers: string[]; + maxContainers: number; + quote: EmptyReturnQuote; +} + +@Injectable() +export class EmptyReturnRequestsService { + constructor( + private readonly requests: EmptyReturnRequestsRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly bookingsService: BookingsService, + private readonly billing: BillingService, + private readonly notifications: NotificationInboxService, + private readonly ratesService: RatesService, + private readonly exchange: ExchangeService, + private readonly dataSource: DataSource, + ) {} + + // ── reads ──────────────────────────────────────────────────────────────── + + async findAll(filter: { + status?: EmptyReturnRequestStatus; + bookingId?: string; + }): Promise< + Array + > { + return this.dataSource.query( + `SELECT r.*, + b.reference AS "bookingReference", + c.name AS "companyName" + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND ($1::text IS NULL OR r.status = $1) + AND ($2::uuid IS NULL OR r.booking_id = $2) + ORDER BY r.submitted_at DESC`, + [filter.status ?? null, filter.bookingId ?? null], + ); + } + + /** One request. A portal caller must own the booking; staff pass `null`. */ + async findById(id: string, userId: string | null = null): Promise { + const request = await this.requests.findById(id); + if (!request) throw new NotFoundException(`Empty return request ${id} not found`); + if (userId) { + const booking = await this.bookingsService.findById(request.bookingId); + await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + } + return request; + } + + /** A booking's own requests — the portal card's history. */ + findForBooking(bookingId: string): Promise { + return this.requests.findAll({ + where: { bookingId }, + order: { submittedAt: 'DESC' }, + }); + } + + /** + * Can this booking ask for an empty return, how many containers are left to + * ask for, and what one would cost. Drives the portal card: the customer + * sees the price before committing, and staff see the same number prefilled + * at approval. + */ + async eligibility(bookingId: string, userId: string | null): Promise { + const booking = await this.bookingsService.findById(bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const quote = await this.quote(booking); + const spoken = await this.spokenForContainers(bookingId); + const all = await this.bookingContainerNumbers(bookingId); + const available = all.filter((number) => !spoken.has(number)); + + const reason = this.ineligibilityReason(booking, available.length); + return { + eligible: reason === null, + reason, + availableContainerNumbers: available, + // A booking whose container numbers were never captured still gets to + // ask — the customer types the numbers, so the line quantity is the cap. + maxContainers: available.length || (await this.bookingContainerQuantity(bookingId)), + quote, + }; + } + + private ineligibilityReason(booking: Booking, availableCount: number): string | null { + if (booking.freightType !== 'CONTAINER') { + return 'Empty container return applies to container freight only.'; + } + if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') { + return 'This booking already ships with empty container return included.'; + } + if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) { + return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`; + } + if (availableCount === 0) { + return 'Every container on this booking is already on an empty return request.'; + } + return null; + } + + // ── pricing ────────────────────────────────────────────────────────────── + + /** + * Per-container price for returning an empty on this booking, taken from the + * same live WITH_RETURN rate the rule engine bills when the service is + * bought up front (route + trade direction + container type, priced in USD). + * Billed in ETB, converted at the current rate, because this is collected + * locally rather than on the freight invoice. + * + * ponytail: prices off the booking's FIRST container line. A booking mixing + * 20ft and 40ft therefore quotes one size's rate for every box — split the + * quote per container if mixed-size bookings start returning empties. + */ + async quote(booking: Booking): Promise { + const currency = 'ETB'; + if (booking.freightType !== 'CONTAINER') { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: 'Not container freight.', + }; + } + + const [line]: Array<{ containerTypeId: string | null }> = await this.dataSource.query( + `SELECT container_type_id AS "containerTypeId" + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL + ORDER BY created_at ASC + LIMIT 1`, + [booking.id], + ); + + const rates = await this.ratesService.findLiveRatesDetailed(); + const onLeg = rates.filter( + (rate) => + rate.trigger === 'WITH_RETURN' && + rate.currency === 'USD' && + rate.tradeDirection === booking.tradeDirection && + rate.originYardId === booking.originYardId && + rate.destinationYardId === booking.destinationYardId, + ); + const rate = + onLeg.find((r) => r.containerTypeId === (line?.containerTypeId ?? null)) ?? + onLeg.find((r) => !r.containerTypeId); + + if (!rate) { + return { + unitAmount: null, + currency, + sourceRateUsd: null, + unavailableReason: + 'No empty-return rate covers this route and container type — enter the amount manually.', + }; + } + + const usdToEtb = await this.exchange.getRate('USD', 'ETB'); + const rateUsd = Number(rate.rateValue); + return { + unitAmount: Math.round(rateUsd * usdToEtb * 100) / 100, + currency, + sourceRateUsd: rateUsd, + unavailableReason: null, + }; + } + + // ── customer actions ───────────────────────────────────────────────────── + + async create( + dto: CreateEmptyReturnRequestDto, + userId: string | null, + ): Promise { + const booking = await this.bookingsService.findById(dto.bookingId); + if (userId) await this.bookingsService.assertCustomerCanAccessBooking(userId, booking); + + const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean); + if (numbers.length === 0) { + throw new BadRequestException('Give at least one container number.'); + } + if (new Set(numbers).size !== numbers.length) { + throw new BadRequestException('The same container number appears twice.'); + } + + const reason = this.ineligibilityReason(booking, numbers.length); + if (reason) throw new BadRequestException(reason); + + await this.assertContainersFree(numbers); + + const saved = await this.requests.create({ + bookingId: booking.id, + companyId: booking.companyId ?? null, + status: 'SUBMITTED', + containerNumbers: numbers, + containerCount: numbers.length, + submittedByUserId: userId, + submittedAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return requested', + body: `${booking.reference ?? booking.id}: a customer asked to return ${numbers.length} empty container${ + numbers.length === 1 ? '' : 's' + }.`, + link: '/dashboard/empty-return-requests', + data: { bookingId: booking.id, requestId: saved.id }, + priority: NotificationPriority.HIGH, + }); + + return saved; + } + + /** Date + truck, once the invoice is settled. This is what the warehouse then expects. */ + async schedule( + id: string, + userId: string | null, + dto: ScheduleEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id, userId); + if (request.status !== 'PAID' && request.status !== 'SCHEDULED') { + throw new BadRequestException( + request.status === 'APPROVED' + ? 'Pay the empty return invoice before booking a date.' + : `This request cannot be scheduled (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'SCHEDULED', + requestedReturnDate: dto.returnDate, + truckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(), + truckDriverName: dto.truckDriverName.trim(), + truckType: dto.truckType?.trim() ?? null, + scheduledAt: new Date(), + } as Partial); + + void this.notifications.notify({ + recipients: { permissionKeys: [FREIGHT_PERMS.emptyReturnRequests.review] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: 'Empty return scheduled', + body: `${request.containerCount} empty container${request.containerCount === 1 ? '' : 's'} arriving ${ + dto.returnDate + } on truck ${dto.truckPlateNumber}.`, + link: '/dashboard/container-returns', + data: { bookingId: request.bookingId, requestId: id }, + }); + + return this.findById(id); + } + + // ── staff actions ──────────────────────────────────────────────────────── + + /** + * Approve and bill. The reviewer's `unitAmount` wins; otherwise the route + * rate stands. The invoice is issued here, so the customer can pay straight + * away — payment lands back on `onInvoicePaid`. + */ + async approve( + id: string, + staffId: string | null, + dto: ApproveEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be approved (current status: ${request.status}).`, + ); + } + + const booking = await this.bookingsService.findById(request.bookingId); + // `chk_invoices_single_payer` requires exactly one payer, and this invoice + // is always billed to the customer — so a booking with no company cannot + // be invoiced at all. Say so here rather than at the constraint. + if (!booking.companyId) { + throw new BadRequestException( + `Booking ${booking.reference ?? booking.id} has no company to bill — the empty return cannot be invoiced.`, + ); + } + + const quote = await this.quote(booking); + const unitAmount = dto.unitAmount ?? quote.unitAmount; + if (!unitAmount || unitAmount <= 0) { + throw new BadRequestException( + quote.unavailableReason ?? 'No price for this return — enter the per-container amount.', + ); + } + + const currency = dto.currency ?? quote.currency; + const totalAmount = Math.round(unitAmount * request.containerCount * 100) / 100; + + const invoice = await this.billing.generateInvoice({ + source: INVOICE_SOURCE as Freight.InvoiceSource, + sourceId: request.id, + type: 'EMPTY_RETURN', + companyId: booking.companyId, + companyProfileId: booking.companyProfileId || '', + currency, + lines: [ + { + chargeType: 'CONTAINER_WITH_RETURN', + description: `Empty container return — ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + } on booking ${booking.reference ?? booking.id}`, + amount: totalAmount, + }, + ], + totalAmount, + }); + + await this.requests.update(id, { + status: 'APPROVED', + quotedUnitAmount: unitAmount, + quotedTotalAmount: totalAmount, + currency, + invoiceId: invoice.id, + reviewedByStaffId: staffId, + reviewedAt: new Date(), + } as Partial); + + if (booking.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.INVOICE_ISSUED, + title: 'Empty container return approved — payment due', + body: `Your empty return request for booking ${booking.reference ?? booking.id} was approved: ${totalAmount.toLocaleString()} ${currency} for ${request.containerCount} container${ + request.containerCount === 1 ? '' : 's' + }. Pay the invoice, then choose your return date and truck.`, + link: `/bookings/${booking.id}`, + data: { bookingId: booking.id, requestId: id, invoiceId: invoice.id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + async reject( + id: string, + staffId: string | null, + dto: RejectEmptyReturnRequestDto, + ): Promise { + const request = await this.findById(id); + if (request.status !== 'SUBMITTED') { + throw new BadRequestException( + `Only a submitted request can be rejected (current status: ${request.status}).`, + ); + } + + await this.requests.update(id, { + status: 'REJECTED', + reviewedByStaffId: staffId, + reviewedAt: new Date(), + rejectionReason: dto.reason, + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (booking?.companyId) { + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: 'Empty container return rejected', + body: `Your empty return request for booking ${booking.reference ?? request.bookingId} was rejected: ${dto.reason}`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: id }, + priority: NotificationPriority.HIGH, + }); + } + + return this.findById(id); + } + + // ── warehouse handoff ──────────────────────────────────────────────────── + + /** + * Scheduled requests the warehouse is waiting on — the planned side of the + * Container Returns screen. Containers already recorded as returned are + * carried per request so staff confirm only what is still outstanding. + */ + async plannedReturns(): Promise< + Array<{ + requestId: string; + bookingId: string; + bookingReference: string | null; + companyName: string | null; + companyId: string | null; + requestedReturnDate: string | null; + truckPlateNumber: string | null; + truckDriverName: string | null; + truckType: string | null; + containers: Array<{ containerNumber: string; returnId: string | null }>; + }> + > { + return this.dataSource.query( + `SELECT r.id AS "requestId", + r.booking_id AS "bookingId", + b.reference AS "bookingReference", + c.name AS "companyName", + r.company_id AS "companyId", + r.requested_return_date AS "requestedReturnDate", + r.truck_plate_number AS "truckPlateNumber", + r.truck_driver_name AS "truckDriverName", + r.truck_type AS "truckType", + ( + SELECT json_agg(json_build_object( + 'containerNumber', n, + 'returnId', ( + SELECT er.id FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = r.booking_id + AND upper(er.container_number) = upper(n) + ORDER BY er.created_at DESC LIMIT 1 + ) + ) ORDER BY ord) + FROM unnest(r.container_numbers) WITH ORDINALITY AS t(n, ord) + ) AS containers + FROM freight.empty_return_requests r + LEFT JOIN freight.bookings b ON b.id = r.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies c ON c.id = r.company_id + WHERE r.deleted_at IS NULL + AND r.status = 'SCHEDULED' + ORDER BY r.requested_return_date ASC NULLS LAST, r.scheduled_at ASC`, + ); + } + + /** + * Close a scheduled request once every container it covers has been recorded + * as returned. Called after the warehouse records the returns; a request + * with anything still outstanding stays SCHEDULED. + */ + async settleScheduledForBooking(bookingId: string): Promise { + const open = await this.requests.findAll({ + where: { bookingId, status: 'SCHEDULED' }, + }); + + for (const request of open) { + const [{ outstanding }]: Array<{ outstanding: string }> = await this.dataSource.query( + `SELECT COUNT(*) AS outstanding + FROM unnest($2::text[]) AS n + WHERE NOT EXISTS ( + SELECT 1 FROM freight.empty_container_returns er + WHERE er.deleted_at IS NULL + AND er.booking_id = $1 + AND upper(er.container_number) = upper(n) + )`, + [bookingId, request.containerNumbers], + ); + if (Number(outstanding) > 0) continue; + + await this.requests.update(request.id, { + status: 'COMPLETED', + completedAt: new Date(), + } as Partial); + } + } + + // ── payment ────────────────────────────────────────────────────────────── + + /** Gateway and manual settlements both land here (`${source}.invoice.paid`). */ + @OnEvent(`${INVOICE_SOURCE}.invoice.paid`) + async onInvoicePaid(payload: { sourceId: string }): Promise { + const request = await this.requests.findById(payload.sourceId); + if (!request || request.status !== 'APPROVED') return; + + await this.requests.update(request.id, { + status: 'PAID', + paidAt: new Date(), + } as Partial); + + const booking = await this.bookingsRepository.findById(request.bookingId); + if (!booking?.companyId) return; + void this.notifications.notify({ + recipients: { companyId: booking.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.PAYMENT_RECEIVED, + title: 'Empty return paid — choose your return date', + body: `Payment received for the empty return on booking ${booking.reference ?? request.bookingId}. Tell us the date and the truck bringing the containers back.`, + link: `/bookings/${request.bookingId}`, + data: { bookingId: request.bookingId, requestId: request.id }, + priority: NotificationPriority.HIGH, + }); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + /** Container numbers captured on the booking, upper-cased. */ + private async bookingContainerNumbers(bookingId: string): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(bcu.container_number) AS "containerNumber" + FROM freight.booking_container_units bcu + JOIN freight.booking_container bc + ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL + WHERE bc.booking_id = $1 + AND bcu.deleted_at IS NULL + AND bcu.container_number IS NOT NULL + ORDER BY 1`, + [bookingId], + ); + return rows.map((row) => row.containerNumber); + } + + /** How many containers the booking bought, for a booking with no captured numbers. */ + private async bookingContainerQuantity(bookingId: string): Promise { + const [row]: Array<{ quantity: string | null }> = await this.dataSource.query( + `SELECT COALESCE(SUM(quantity), 0) AS quantity + FROM freight.booking_container + WHERE booking_id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + return Number(row?.quantity ?? 0); + } + + /** Numbers already claimed by a live request on this booking. */ + private async spokenForContainers(bookingId: string): Promise> { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.booking_id = $1 + AND r.status = ANY($2)`, + [bookingId, OPEN_STATUSES], + ); + return new Set(rows.map((row) => row.containerNumber)); + } + + /** A container may only sit on one live request at a time, on any booking. */ + private async assertContainersFree(numbers: string[]): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource.query( + `SELECT DISTINCT upper(n) AS "containerNumber" + FROM freight.empty_return_requests r, unnest(r.container_numbers) AS n + WHERE r.deleted_at IS NULL + AND r.status = ANY($1) + AND upper(n) = ANY($2)`, + [OPEN_STATUSES, numbers], + ); + if (rows.length > 0) { + throw new BadRequestException( + `Already on an empty return request: ${rows.map((r) => r.containerNumber).join(', ')}`, + ); + } + } +} diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts new file mode 100644 index 000000000..6bb8257be --- /dev/null +++ b/apps/edr-freight-api/src/modules/empty-return-requests/entities/empty-return-request.entity.ts @@ -0,0 +1,127 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { Booking } from '../../bookings/entities/booking.entity'; + +export const EMPTY_RETURN_REQUEST_STATUSES = [ + /** Customer named the containers; waiting on operations. */ + 'SUBMITTED', + /** Operations approved and priced it; the invoice is out, waiting on payment. */ + 'APPROVED', + 'REJECTED', + /** Invoice settled; waiting on the customer to book a date and a truck. */ + 'PAID', + /** Date and truck given — the warehouse now expects these empties. */ + 'SCHEDULED', + /** The empties arrived and were recorded as returns. */ + 'COMPLETED', + 'CANCELLED', +] as const; + +export type EmptyReturnRequestStatus = (typeof EMPTY_RETURN_REQUEST_STATUSES)[number]; + +/** + * A customer's request to return empties on a booking that did NOT buy the + * return service up front (`equipment_return` is not WITH_RETURN). Container + * freight only — a bulk booking has no equipment to give back. + * + * The request carries the commercial half of the flow: which containers, what + * operations priced it at, the invoice, and the date/truck the customer + * booked. The physical return is still recorded in `empty_container_returns` + * when the truck arrives, which is what closes this row out as COMPLETED. + */ +@Entity({ schema: 'freight', name: 'empty_return_requests' }) +@Index(['bookingId']) +@Index(['status']) +export class EmptyReturnRequest extends BaseEntity { + @Column({ name: 'booking_id', type: 'uuid' }) + bookingId!: string; + + @ManyToOne(() => Booking, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking; + + /** Denormalised at submit so the queue and the invoice agree on the payer. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @Column({ name: 'status', type: 'varchar', length: 30, default: 'SUBMITTED' }) + status!: EmptyReturnRequestStatus; + + /** The container numbers the customer is sending back, as typed. */ + @Column({ name: 'container_numbers', type: 'text', array: true, default: () => "'{}'" }) + containerNumbers!: string[]; + + @Column({ name: 'container_count', type: 'smallint', default: 0 }) + containerCount!: number; + + /** Per-container price at approval — the route's WITH_RETURN rate, or the reviewer's override. */ + @Column({ + name: 'quoted_unit_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedUnitAmount?: number | null; + + @Column({ + name: 'quoted_total_amount', + type: 'numeric', + precision: 14, + scale: 2, + nullable: true, + transformer: { + to: (v?: number | null) => v, + from: (v?: string | null) => (v == null ? null : Number(v)), + }, + }) + quotedTotalAmount?: number | null; + + @Column({ name: 'currency', type: 'varchar', length: 8, nullable: true }) + currency?: string | null; + + @Column({ name: 'invoice_id', type: 'uuid', nullable: true }) + invoiceId?: string | null; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt?: Date | null; + + /** Customer's chosen day for handing the empties over. */ + @Column({ name: 'requested_return_date', type: 'date', nullable: true }) + requestedReturnDate?: string | null; + + @Column({ name: 'truck_plate_number', type: 'varchar', length: 32, nullable: true }) + truckPlateNumber?: string | null; + + @Column({ name: 'truck_driver_name', type: 'varchar', length: 120, nullable: true }) + truckDriverName?: string | null; + + @Column({ name: 'truck_type', type: 'varchar', length: 60, nullable: true }) + truckType?: string | null; + + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt?: Date | null; + + @Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true }) + submittedByUserId?: string | null; + + @Column({ name: 'submitted_at', type: 'timestamptz', default: () => 'now()' }) + submittedAt!: Date; + + @Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true }) + reviewedByStaffId?: string | null; + + @Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason?: string | null; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts index 8cdc83853..d6cb00fd2 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BookingsModule } from '../bookings/bookings.module'; +import { EmptyReturnRequestsModule } from '../empty-return-requests/empty-return-requests.module'; import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module'; import { NotificationsModule } from '../notifications/notifications.module'; import { WarehousesModule } from '../warehouses/warehouses.module'; @@ -26,6 +27,9 @@ import { ImportOperationsService } from './import-operations.service'; BookingsModule, NotificationInboxModule, NotificationsModule, + // Recording a return is what closes out the customer's scheduled empty + // return request, once every container on it is back. + EmptyReturnRequestsModule, ], controllers: [ImportOperationsController], providers: [ImportOperationsService], diff --git a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts index e9c53f882..6333af97e 100644 --- a/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts +++ b/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts @@ -8,6 +8,7 @@ import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util' import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { NotificationsService } from '../notifications/notifications.service'; import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service'; import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; import { BulkCreateEmptyContainerReturnsDto, @@ -64,6 +65,7 @@ export class ImportOperationsService { private readonly logoSettings: LogoSettingsService, private readonly inbox: NotificationInboxService, private readonly notifications: NotificationsService, + private readonly emptyReturnRequests: EmptyReturnRequestsService, ) {} listIncidents(bookingId?: string) { @@ -288,6 +290,8 @@ export class ImportOperationsService { // Standalone returns (no booking) have no company to notify. if (saved.bookingId) { await this.notifyEquipmentInterchangeReady(saved); + // Closes the customer's scheduled request once its last container is in. + await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId); } return saved; } 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 2a8ac578e..cf574f588 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1490,6 +1490,22 @@ export const ADDITIONAL_CHARGE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// E''. Empty container return requests — customer asks to send empties back on +// a booking that was sold without the return service; operations price and +// approve it, the customer pays, then books the date and truck. +export const EMPTY_RETURN_REQUEST_PERMISSIONS: FreightPermissionSeed[] = [ + perm( + "f2e00002-0001-4000-8000-000000000001", + "edr_freight_app:empty_return_requests:view", + "View empty container return requests", + ), + perm( + "f2e00002-0001-4000-8000-000000000002", + "edr_freight_app:empty_return_requests:review", + "Approve or reject an empty container return request", + ), +]; + // E'. Train-scheduling finer actions (augment existing view/manage) export const SCHEDULING_EXTRA_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1937,6 +1953,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...WAREHOUSE_PERMISSIONS, ...PORT_TERMINAL_PERMISSIONS, ...ADDITIONAL_CHARGE_PERMISSIONS, + ...EMPTY_RETURN_REQUEST_PERMISSIONS, ...SCHEDULING_EXTRA_PERMISSIONS, ...CONFIG_SETTINGS_PERMISSIONS, ...STAFF_IAM_PERMISSIONS, @@ -2418,6 +2435,10 @@ export const FREIGHT_PERMS = { // Notification selector, not a route guard — see NOTIFICATION_PERMISSIONS. getNotification: "edr_freight_app:additional_charges:get_notification", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, settings: { fileUpload: { view: "edr_freight_app:settings:file_upload:view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 539f2ee98..ff03bd5da 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -96,6 +96,7 @@ import TrainBuilderDetailPage from "./pages/trainBuilder/TrainBuilderDetailPage" import TrainBuilderListPage from "./pages/trainBuilder/TrainBuilderListPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import ContainerReturnsPage from "./pages/warehouses/ContainerReturnsPage"; +import EmptyReturnRequestsPage from "./pages/warehouses/EmptyReturnRequestsPage"; import RegisterFullContainersPage from "./pages/warehouses/RegisterFullContainersPage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import ExportDjiboutiUnloadingQueuePage from "./pages/warehouses/ExportDjiboutiUnloadingQueuePage"; @@ -709,6 +710,16 @@ const App = () => { } /> + + + + } + /> , permission: FREIGHT_PERMS.warehouseInventory.view, }, + { + label: "Empty Return Requests", + href: "/dashboard/empty-return-requests", + icon: , + permission: FREIGHT_PERMS.emptyReturnRequests.view, + }, { label: "Register Full Containers", href: "/dashboard/register-full-containers", diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 1e8a1c602..8de3bd320 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -800,6 +800,15 @@ export const URL_CONSTANTS = { CANCEL: (id: string) => `/interchange-documents/${id}/cancel`, }, + EMPTY_RETURN_REQUESTS: { + BASE: "/empty-return-requests", + PLANNED: "/empty-return-requests/planned", + ELIGIBILITY: (bookingId: string) => `/empty-return-requests/eligibility/${bookingId}`, + BY_BOOKING: (bookingId: string) => `/empty-return-requests/by-booking/${bookingId}`, + APPROVE: (id: string) => `/empty-return-requests/${id}/approve`, + REJECT: (id: string) => `/empty-return-requests/${id}/reject`, + }, + IMPORT_OPERATIONS: { DJIBOUTI_INCIDENTS: "/import-operations/djibouti-incidents", CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 62137496f..bc03bfcb5 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -358,6 +358,10 @@ export const FREIGHT_PERMS = { send: "edr_freight_app:additional_charges:send", cancel: "edr_freight_app:additional_charges:cancel", }, + emptyReturnRequests: { + view: "edr_freight_app:empty_return_requests:view", + review: "edr_freight_app:empty_return_requests:review", + }, /** * Audit trail. View-only — the API exposes no write routes for audit rows, * so there is no manage/delete counterpart to grant. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 4b931d9cd..60b349437 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -40,11 +40,13 @@ import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses"; import { api } from "@/services/api"; import { warehouseService } from "@/services/warehouse.service"; import { importOperationsService } from "@/services/importOperations.service"; +import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service"; import type { EmptyContainerReturn, EmptyContainerReturnStatus, EmptyContainerSize, EmptyReturnBooking, + PlannedEmptyReturn, } from "@/types/importOperations"; import type { TrainScheduleListItem } from "@/types/trainScheduling"; import { formatDateTime, localNowForInput } from "@/lib/format"; @@ -78,6 +80,32 @@ const RETURNED_BY_SERIES = [ { key: "customer", label: "Customer Self-Haul", color: "#b45309" }, ]; +/** + * A scheduled request seen as the booking shape `BookingEmptyReturnModal` + * takes, so confirming an arrival runs through exactly the same recording + * path as any other empty return. + */ +const plannedAsBooking = (planned: PlannedEmptyReturn): EmptyReturnBooking => ({ + bookingId: planned.bookingId, + bookingReference: planned.bookingReference ?? planned.bookingId, + bookingStatus: "SCHEDULED_RETURN", + equipmentReturn: "REQUESTED", + customerId: planned.companyId, + companyName: planned.companyName, + containers: planned.containers.map((container) => ({ + key: `${planned.requestId}-${container.containerNumber}`, + unitId: `${planned.requestId}-${container.containerNumber}`, + containerNumber: container.containerNumber, + containerSize: null, + containerType: null, + returnId: container.returnId, + returnStatus: null, + })), + expectedCount: planned.containers.length, + recordedCount: planned.containers.filter((c) => c.returnId).length, + pendingCount: planned.containers.filter((c) => !c.returnId).length, +}); + interface ContainerReturnRow { key: string; containerNumber: string; @@ -114,6 +142,7 @@ export default function ContainerReturnsPage() { const [allocateRow, setAllocateRow] = useState(null); const [emptyReturnBooking, setEmptyReturnBooking] = useState(null); const [expandedBooking, setExpandedBooking] = useState(null); + const [arrivingReturn, setArrivingReturn] = useState(null); const [documentBusyId, setDocumentBusyId] = useState(null); const viewInterchangeDocument = async (ret: EmptyContainerReturn) => { @@ -159,6 +188,14 @@ export default function ContainerReturnsPage() { }); const emptyReturnBookings = emptyReturnBookingsQuery.data ?? []; + // Requests the customer already paid for and booked a truck against — the + // warehouse confirms these on arrival, which is what records the containers. + const plannedReturnsQuery = useQuery({ + queryKey: ["planned-empty-returns"], + queryFn: () => emptyReturnRequestsService.planned(), + }); + const plannedReturns = plannedReturnsQuery.data ?? []; + const bookingIds = unloadedQueue.map((item) => item.bookingId).filter(Boolean) as string[]; const containerReturnsQuery = useQuery({ queryKey: ["container-returns", bookingIds], @@ -350,10 +387,12 @@ export default function ContainerReturnsPage() { qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] }); qc.invalidateQueries({ queryKey: ["empty-container-returns"] }); qc.invalidateQueries({ queryKey: ["empty-return-bookings"] }); + qc.invalidateQueries({ queryKey: ["planned-empty-returns"] }); setReturnModalOpen(false); setStandaloneModalOpen(false); setActiveKey(null); setEmptyReturnBooking(null); + setArrivingReturn(null); }, onError: (error: any) => { toast({ @@ -567,6 +606,83 @@ export default function ContainerReturnsPage() { + {plannedReturns.length > 0 && ( + + + +
+ Planned Empty Returns + + Customers who paid for an empty return and booked a truck. Confirm the arrival + to record the containers. + +
+ + {plannedReturns.length} expected + +
+ + + + + + Booking Ref + Company + Return Date + Truck + Containers + Action + + + + {plannedReturns.map((planned) => { + const outstanding = planned.containers.filter((c) => !c.returnId); + return ( + + + {planned.bookingReference ?? planned.bookingId} + + {planned.companyName ?? "—"} + {planned.requestedReturnDate ?? "—"} + + + {planned.truckPlateNumber ?? "—"} + + {planned.truckDriverName ?? "—"} + {planned.truckType ? ` · ${planned.truckType}` : ""} + + + + + + + {outstanding.length} of {planned.containers.length} outstanding + + + {planned.containers.map((c) => c.containerNumber).join(", ")} + + + + + + + + ); + })} + +
+
+
+
+ )} + @@ -904,6 +1020,24 @@ export default function ContainerReturnsPage() { loading={createReturnsMutation.isPending} /> + {/* A scheduled return arrives on the customer's own truck, so the modal + opens pre-set to self-haul with that truck already noted. */} + setArrivingReturn(null)} + onSubmit={(payload) => createReturnsMutation.mutate(payload)} + loading={createReturnsMutation.isPending} + defaultReturnedBy="CUSTOMER" + defaultHandoverNote={ + arrivingReturn + ? `Scheduled empty return · truck ${arrivingReturn.truckPlateNumber ?? "—"}${ + arrivingReturn.truckDriverName ? ` · driver ${arrivingReturn.truckDriverName}` : "" + }` + : undefined + } + /> + setBulkModalOpen(false)} @@ -1217,6 +1351,11 @@ interface BookingEmptyReturnModalProps { onClose: () => void; onSubmit: (payload: any) => void; loading: boolean; + /** Pre-set for a scheduled return, where the truck type is already known. */ + defaultReturnedBy?: "EDR" | "CUSTOMER"; + /** Pre-set for a scheduled return — the truck the customer told us about. */ + defaultHandoverNote?: string; + title?: string; } /** @@ -1226,7 +1365,15 @@ interface BookingEmptyReturnModalProps { * cannot be ticked again. A legacy booking that never captured container * numbers shows numberless slots — the number is typed here instead. */ -function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: BookingEmptyReturnModalProps) { +function BookingEmptyReturnModal({ + booking, + onClose, + onSubmit, + loading, + defaultReturnedBy, + defaultHandoverNote, + title = "Empty Container Return", +}: BookingEmptyReturnModalProps) { const [selected, setSelected] = useState([]); const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null); const [returnDate, setReturnDate] = useState(localNowForInput()); @@ -1242,14 +1389,14 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin // ticks, typed numbers, or placement. useEffect(() => { setSelected([]); - setReturnedBy(null); + setReturnedBy(defaultReturnedBy ?? null); setReturnDate(localNowForInput()); setWarehouse(null); setYardId(null); setZoneId(null); setCondition(""); - setHandoverNote(""); - }, [bookingId]); + setHandoverNote(defaultHandoverNote ?? ""); + }, [bookingId, defaultReturnedBy, defaultHandoverNote]); const { data: warehousesResponse } = useQuery({ queryKey: ["warehouses-list"], @@ -1332,12 +1479,7 @@ function BookingEmptyReturnModal({ booking, onClose, onSubmit, loading }: Bookin }; return ( - + {booking && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx new file mode 100644 index 000000000..0a3765d01 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/EmptyReturnRequestsPage.tsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Badge, + Button, + Card, + Divider, + Group, + Loader, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Text, + Textarea, +} from "@mantine/core"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +import { PageContainer, PageHeader } from "@/components/page"; +import ListControls from "@/components/common/ListControls"; +import { extractErrorMessage } from "@/components/warehouses/options"; +import { useListControls } from "@/hooks/useListControls"; +import { useToast } from "@/hooks/use-toast"; +import { emptyReturnRequestsService } from "@/services/emptyReturnRequests.service"; +import type { + EmptyReturnRequest, + EmptyReturnRequestStatus, +} from "@/types/importOperations"; +import { formatDateTime } from "@/lib/format"; + +const STATUS_META: Record = { + SUBMITTED: { label: "Awaiting review", color: "orange" }, + APPROVED: { label: "Awaiting payment", color: "yellow" }, + REJECTED: { label: "Rejected", color: "red" }, + PAID: { label: "Paid — awaiting date", color: "blue" }, + SCHEDULED: { label: "Scheduled", color: "edr-green" }, + COMPLETED: { label: "Returned", color: "gray" }, + CANCELLED: { label: "Cancelled", color: "gray" }, +}; + +const money = (amount: number | null | undefined, currency: string | null | undefined) => + amount == null + ? "—" + : `${Number(amount).toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? ""}`.trim(); + +/** + * The queue for customer-initiated empty container returns: a booking sold + * WITHOUT the return service, whose customer now wants to send the empties + * back. Staff price and approve — which invoices the customer — or reject with + * a reason. Everything after payment (date, truck) happens in the portal, and + * the containers themselves are recorded on Container Returns. + */ +export default function EmptyReturnRequestsPage() { + const { toast } = useToast(); + const qc = useQueryClient(); + const [statusFilter, setStatusFilter] = useState(null); + const [approving, setApproving] = useState(null); + const [rejecting, setRejecting] = useState(null); + + const requestsQuery = useQuery({ + queryKey: ["empty-return-requests"], + queryFn: () => emptyReturnRequestsService.list(), + }); + + const requests = useMemo(() => { + const rows = requestsQuery.data ?? []; + return statusFilter ? rows.filter((row) => row.status === statusFilter) : rows; + }, [requestsQuery.data, statusFilter]); + + const controls = useListControls(requests, { + dateKey: "submittedAt", + searchValue: (row) => + `${row.bookingReference ?? ""} ${row.companyName ?? ""} ${row.containerNumbers.join(" ")}`, + }); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["empty-return-requests"] }); + qc.invalidateQueries({ queryKey: ["planned-empty-returns"] }); + }; + + const approveMutation = useMutation({ + mutationFn: ({ id, unitAmount }: { id: string; unitAmount?: number }) => + emptyReturnRequestsService.approve(id, { unitAmount }), + onSuccess: () => { + toast({ title: "Approved — invoice sent to the customer" }); + invalidate(); + setApproving(null); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Could not approve the request", + description: extractErrorMessage(error), + }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: ({ id, reason }: { id: string; reason: string }) => + emptyReturnRequestsService.reject(id, reason), + onSuccess: () => { + toast({ title: "Request rejected" }); + invalidate(); + setRejecting(null); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Could not reject the request", + description: extractErrorMessage(error), + }); + }, + }); + + const columns: ColumnDef[] = [ + { + id: "booking", + header: "Booking", + cell: ({ row }) => ( + + + {row.original.bookingReference ?? row.original.bookingId} + + + {row.original.companyName ?? "—"} + + + ), + }, + { + id: "containers", + header: "Containers", + cell: ({ row }) => ( + + {row.original.containerCount} + + {row.original.containerNumbers.join(", ")} + + + ), + }, + { + id: "submittedAt", + header: "Requested", + cell: ({ row }) => formatDateTime(row.original.submittedAt), + }, + { + id: "price", + header: "Price", + cell: ({ row }) => + row.original.quotedTotalAmount == null ? ( + "—" + ) : ( + + + {money(row.original.quotedTotalAmount, row.original.currency)} + + + {money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount} + + + ), + }, + { + id: "return", + header: "Return", + cell: ({ row }) => + row.original.requestedReturnDate ? ( + + {row.original.requestedReturnDate} + + {row.original.truckPlateNumber ?? "—"} + {row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""} + + + ) : ( + "—" + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => { + const meta = STATUS_META[row.original.status]; + return ( + + + {meta?.label ?? row.original.status} + + {row.original.rejectionReason && ( + + {row.original.rejectionReason} + + )} + + ); + }, + }, + { + id: "action", + header: "Action", + cell: ({ row }) => + row.original.status === "SUBMITTED" ? ( + + + + + ) : ( + + {row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"} + + ), + }, + ]; + + const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length; + + return ( + + + + + + + + Requests + {pending > 0 && ( + + {pending} awaiting review + + )} + + + + { + controls.reset(); + setStatusFilter(null); + }} + > +