Merge pull request #1476 from Tria-plc/Emty-container-return

Emty container return
This commit is contained in:
Hagernesh Tadesse
2026-09-02 14:42:51 +03:00
committed by GitHub
24 changed files with 2832 additions and 10 deletions

View File

@@ -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,

View File

@@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.empty_return_requests`);
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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 {}

View File

@@ -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<EmptyReturnRequest> {
constructor(
@InjectRepository(EmptyReturnRequest)
repository: Repository<EmptyReturnRequest>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,413 @@
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<typeof booking>;
request?: Partial<EmptyReturnRequest>;
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<EmptyReturnRequest>) => ({ ...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('refuses a booking with no container numbers to pick from', async () => {
const { service } = build({ queries: [['upper(bcu.container_number)', []]] });
const result = await service.eligibility('b1', null);
expect(result.eligible).toBe(false);
expect(result.reason).toMatch(/no container numbers are recorded/i);
expect(result.availableContainerNumbers).toEqual([]);
});
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 — creating a request', () => {
it('accepts containers that came in on the booking', async () => {
const { service, requests } = build();
await service.create(
{ bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] },
'user1',
);
expect(requests.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b1',
containerNumbers: ['TEMU1111111', 'TEMU2222222'],
containerCount: 2,
status: 'SUBMITTED',
}),
);
});
it('refuses a container that is not on the booking', async () => {
const { service, requests } = build();
await expect(
service.create(
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] },
'user1',
),
).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/);
expect(requests.create).not.toHaveBeenCalled();
});
it('refuses the same container twice', async () => {
const { service } = build();
await expect(
service.create(
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] },
'user1',
),
).rejects.toThrow(/selected twice/i);
});
it('refuses a container already sitting on a live request', async () => {
const { service } = build({
queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]],
});
await expect(
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
).rejects.toThrow(/Already on an empty return request/);
});
it('refuses a booking that already ships with return', async () => {
const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } });
await expect(
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
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();
});
});

View File

@@ -0,0 +1,617 @@
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<EmptyReturnRequest & { bookingReference: string | null; companyName: string | null }>
> {
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<EmptyReturnRequest> {
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<EmptyReturnRequest[]> {
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<EmptyReturnEligibility> {
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, all.length, available.length);
return {
eligible: reason === null,
reason,
availableContainerNumbers: available,
maxContainers: available.length,
quote,
};
}
private ineligibilityReason(
booking: Booking,
bookingContainerCount: number,
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}).`;
}
// The customer picks from this booking's own containers, so a booking that
// never captured its container numbers has nothing to pick.
if (bookingContainerCount === 0) {
return 'No container numbers are recorded on this booking — contact EDR to arrange the return.';
}
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<EmptyReturnQuote> {
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<EmptyReturnRequest> {
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('Select at least one container.');
}
if (new Set(numbers).size !== numbers.length) {
throw new BadRequestException('The same container is selected twice.');
}
// Only this booking's own containers can be returned against it. The
// portal offers a pick list, so anything else is a stale page or a
// hand-made request.
const onBooking = new Set(await this.bookingContainerNumbers(booking.id));
const foreign = numbers.filter((number) => !onBooking.has(number));
if (foreign.length > 0) {
throw new BadRequestException(
`Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`,
);
}
const reason = this.ineligibilityReason(booking, onBooking.size, 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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<EmptyReturnRequest> {
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<EmptyReturnRequest>);
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<void> {
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<EmptyReturnRequest>);
}
}
// ── payment ──────────────────────────────────────────────────────────────
/** Gateway and manual settlements both land here (`${source}.invoice.paid`). */
@OnEvent(`${INVOICE_SOURCE}.invoice.paid`)
async onInvoicePaid(payload: { sourceId: string }): Promise<void> {
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<EmptyReturnRequest>);
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<string[]> {
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);
}
/** Numbers already claimed by a live request on this booking. */
private async spokenForContainers(bookingId: string): Promise<Set<string>> {
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<void> {
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(', ')}`,
);
}
}
}

View File

@@ -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;
}

View File

@@ -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],

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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 = () => {
</RequirePermission>
}
/>
<Route
path="empty-return-requests"
element={
<RequirePermission
permission={FREIGHT_PERMS.emptyReturnRequests.view}
>
<EmptyReturnRequestsPage />
</RequirePermission>
}
/>
<Route
path="register-full-containers"
element={

View File

@@ -31,6 +31,7 @@ import {
SlidersHorizontal,
Train,
Truck,
Undo2,
Users,
Wallet,
LifeBuoy,
@@ -367,6 +368,12 @@ export const buildSidebarSections = (
icon: <Container />,
permission: FREIGHT_PERMS.warehouseInventory.view,
},
{
label: "Empty Return Requests",
href: "/dashboard/empty-return-requests",
icon: <Undo2 />,
permission: FREIGHT_PERMS.emptyReturnRequests.view,
},
{
label: "Register Full Containers",
href: "/dashboard/register-full-containers",

View File

@@ -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}`,

View File

@@ -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.

View File

@@ -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<EmptyContainerReturn | null>(null);
const [emptyReturnBooking, setEmptyReturnBooking] = useState<EmptyReturnBooking | null>(null);
const [expandedBooking, setExpandedBooking] = useState<string | null>(null);
const [arrivingReturn, setArrivingReturn] = useState<PlannedEmptyReturn | null>(null);
const [documentBusyId, setDocumentBusyId] = useState<string | null>(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() {
</Group>
</Group>
{plannedReturns.length > 0 && (
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
<div>
<Text fw={600}>Planned Empty Returns</Text>
<Text size="sm" c="dimmed">
Customers who paid for an empty return and booked a truck. Confirm the arrival
to record the containers.
</Text>
</div>
<Badge variant="light" size="lg" color="orange">
{plannedReturns.length} expected
</Badge>
</Group>
<Table.ScrollContainer minWidth={900}>
<Table highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking Ref</Table.Th>
<Table.Th>Company</Table.Th>
<Table.Th>Return Date</Table.Th>
<Table.Th>Truck</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th ta="right">Action</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{plannedReturns.map((planned) => {
const outstanding = planned.containers.filter((c) => !c.returnId);
return (
<Table.Tr key={planned.requestId}>
<Table.Td>
<Text fw={600}>{planned.bookingReference ?? planned.bookingId}</Text>
</Table.Td>
<Table.Td>{planned.companyName ?? "—"}</Table.Td>
<Table.Td>{planned.requestedReturnDate ?? "—"}</Table.Td>
<Table.Td>
<Stack gap={2}>
<Text size="sm">{planned.truckPlateNumber ?? "—"}</Text>
<Text size="xs" c="dimmed">
{planned.truckDriverName ?? "—"}
{planned.truckType ? ` · ${planned.truckType}` : ""}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Stack gap={2}>
<Badge color={outstanding.length ? "orange" : "edr-green"}>
{outstanding.length} of {planned.containers.length} outstanding
</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{planned.containers.map((c) => c.containerNumber).join(", ")}
</Text>
</Stack>
</Table.Td>
<Table.Td ta="right">
<Button
size="xs"
variant="light"
disabled={outstanding.length === 0}
onClick={() => setArrivingReturn(planned)}
>
Confirm Arrival
</Button>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
</Card>
)}
<Card withBorder radius="lg" p="md" mb="lg">
<Stack gap="md">
<Group justify="space-between" align="flex-start">
@@ -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. */}
<BookingEmptyReturnModal
title="Confirm Empty Return Arrival"
booking={arrivingReturn ? plannedAsBooking(arrivingReturn) : null}
onClose={() => 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
}
/>
<BulkContainerReturnModal
opened={bulkModalOpen}
onClose={() => 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<string[]>([]);
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
const [returnDate, setReturnDate] = useState<string>(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 (
<Modal
opened={!!booking}
onClose={onClose}
title="Empty Container Return"
size="lg"
>
<Modal opened={!!booking} onClose={onClose} title={title} size="lg">
{booking && (
<Stack gap="md">
<Group gap="sm">

View File

@@ -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<EmptyReturnRequestStatus, { label: string; color: string }> = {
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<string | null>(null);
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(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<EmptyReturnRequest>[] = [
{
id: "booking",
header: "Booking",
cell: ({ row }) => (
<Stack gap={2}>
<Text fw={600} size="sm">
{row.original.bookingReference ?? row.original.bookingId}
</Text>
<Text size="xs" c="dimmed">
{row.original.companyName ?? "—"}
</Text>
</Stack>
),
},
{
id: "containers",
header: "Containers",
cell: ({ row }) => (
<Stack gap={2}>
<Badge size="sm">{row.original.containerCount}</Badge>
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.containerNumbers.join(", ")}
</Text>
</Stack>
),
},
{
id: "submittedAt",
header: "Requested",
cell: ({ row }) => formatDateTime(row.original.submittedAt),
},
{
id: "price",
header: "Price",
cell: ({ row }) =>
row.original.quotedTotalAmount == null ? (
"—"
) : (
<Stack gap={2}>
<Text size="sm" fw={600}>
{money(row.original.quotedTotalAmount, row.original.currency)}
</Text>
<Text size="xs" c="dimmed">
{money(row.original.quotedUnitAmount, row.original.currency)} × {row.original.containerCount}
</Text>
</Stack>
),
},
{
id: "return",
header: "Return",
cell: ({ row }) =>
row.original.requestedReturnDate ? (
<Stack gap={2}>
<Text size="sm">{row.original.requestedReturnDate}</Text>
<Text size="xs" c="dimmed">
{row.original.truckPlateNumber ?? "—"}
{row.original.truckDriverName ? ` · ${row.original.truckDriverName}` : ""}
</Text>
</Stack>
) : (
"—"
),
},
{
id: "status",
header: "Status",
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Stack gap={2}>
<Badge size="sm" color={meta?.color ?? "gray"} variant="light">
{meta?.label ?? row.original.status}
</Badge>
{row.original.rejectionReason && (
<Text size="xs" c="dimmed" lineClamp={2}>
{row.original.rejectionReason}
</Text>
)}
</Stack>
);
},
},
{
id: "action",
header: "Action",
cell: ({ row }) =>
row.original.status === "SUBMITTED" ? (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<Button size="xs" variant="subtle" color="red" onClick={() => setRejecting(row.original)}>
Reject
</Button>
<Button size="xs" variant="light" onClick={() => setApproving(row.original)}>
Approve
</Button>
</Group>
) : (
<Text size="xs" c="dimmed" ta="right">
{row.original.status === "APPROVED" ? "Awaiting customer payment" : "No action"}
</Text>
),
},
];
const pending = (requestsQuery.data ?? []).filter((row) => row.status === "SUBMITTED").length;
return (
<PageContainer>
<PageHeader
title="Empty Return Requests"
subtitle="Customers asking to send empty containers back on bookings sold without equipment return"
/>
<Card withBorder radius="lg" p="md">
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>
Requests
{pending > 0 && (
<Badge ml="sm" color="orange" variant="light">
{pending} awaiting review
</Badge>
)}
</Text>
</Group>
<ListControls
search={controls.search}
onSearchChange={controls.setSearch}
searchPlaceholder="Search booking, company, container…"
dateFrom={controls.dateFrom}
onDateFromChange={controls.setDateFrom}
dateTo={controls.dateTo}
onDateToChange={controls.setDateTo}
dateLabel="Requested"
hasFilters={controls.hasFilters || Boolean(statusFilter)}
onReset={() => {
controls.reset();
setStatusFilter(null);
}}
>
<Select
placeholder="Status"
value={statusFilter}
onChange={setStatusFilter}
data={Object.entries(STATUS_META).map(([value, meta]) => ({
value,
label: meta.label,
}))}
clearable
w={220}
/>
</ListControls>
{requestsQuery.isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : requestsQuery.isError ? (
<Alert color="red">
Could not load empty return requests. {extractErrorMessage(requestsQuery.error)}
</Alert>
) : controls.pagedRows.length === 0 ? (
<Alert color="gray">No empty return requests.</Alert>
) : (
<DataTable
columns={columns}
data={controls.pagedRows}
containerClassName="border-0 shadow-none"
{...controls.tableProps}
/>
)}
</Stack>
</Card>
<ApproveModal
request={approving}
onClose={() => setApproving(null)}
onApprove={(unitAmount) =>
approving && approveMutation.mutate({ id: approving.id, unitAmount })
}
loading={approveMutation.isPending}
/>
<Modal
opened={!!rejecting}
onClose={() => setRejecting(null)}
title="Reject empty return request"
size="md"
>
{rejecting && (
<RejectForm
request={rejecting}
loading={rejectMutation.isPending}
onCancel={() => setRejecting(null)}
onReject={(reason) => rejectMutation.mutate({ id: rejecting.id, reason })}
/>
)}
</Modal>
</PageContainer>
);
}
/**
* The pricing step. The per-container price is prefilled from the booking's
* route WITH_RETURN rate; the reviewer can override it before approving, and
* approving is what issues the customer's invoice.
*/
function ApproveModal({
request,
onClose,
onApprove,
loading,
}: {
request: EmptyReturnRequest | null;
onClose: () => void;
onApprove: (unitAmount?: number) => void;
loading: boolean;
}) {
const [unitAmount, setUnitAmount] = useState<number | "">("");
const quoteQuery = useQuery({
queryKey: ["empty-return-quote", request?.bookingId],
queryFn: () => emptyReturnRequestsService.quote(request!.bookingId),
enabled: Boolean(request),
});
// Prefill from the route rate as soon as it lands, and start clean whenever
// a different request is opened.
useEffect(() => {
setUnitAmount(quoteQuery.data?.unitAmount ?? "");
}, [quoteQuery.data?.unitAmount, request?.id]);
const count = request?.containerCount ?? 0;
const total = typeof unitAmount === "number" ? unitAmount * count : null;
const currency = quoteQuery.data?.currency ?? "ETB";
return (
<Modal opened={!!request} onClose={onClose} title="Approve empty return" size="md">
{request && (
<Stack gap="md">
<Group gap="sm">
<Text fw={600}>{request.bookingReference ?? request.bookingId}</Text>
<Text c="dimmed">{request.companyName ?? "—"}</Text>
</Group>
<div>
<Text size="sm" fw={600} mb={4}>
Containers coming back
</Text>
<Text size="sm" c="dimmed">
{request.containerNumbers.join(", ")}
</Text>
</div>
{quoteQuery.isLoading ? (
<Group justify="center" py="sm">
<Loader size="sm" />
</Group>
) : (
<>
{quoteQuery.data?.unavailableReason && (
<Alert color="yellow">{quoteQuery.data.unavailableReason}</Alert>
)}
<NumberInput
label={`Price per container (${currency})`}
description={
quoteQuery.data?.sourceRateUsd
? `Contract route rate: ${quoteQuery.data.sourceRateUsd} USD per container`
: "No route rate found — enter the amount to bill."
}
value={unitAmount}
onChange={(value) =>
setUnitAmount(typeof value === "number" ? value : value === "" ? "" : Number(value))
}
min={0}
decimalScale={2}
thousandSeparator=","
required
/>
<Divider />
<SimpleGrid cols={2}>
<Text size="sm" c="dimmed">
{count} container{count === 1 ? "" : "s"} ×{" "}
{typeof unitAmount === "number" ? unitAmount.toLocaleString() : "—"}
</Text>
<Text size="lg" fw={700} ta="right">
{total == null
? "—"
: `${total.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
</Text>
</SimpleGrid>
<Text size="xs" c="dimmed">
Approving issues this invoice to the customer. They pay it in the portal, then
choose the return date and give the truck details.
</Text>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
onClick={() => onApprove(typeof unitAmount === "number" ? unitAmount : undefined)}
disabled={typeof unitAmount !== "number" || unitAmount <= 0}
loading={loading}
>
Approve &amp; invoice
</Button>
</Group>
</Stack>
)}
</Modal>
);
}
function RejectForm({
request,
loading,
onCancel,
onReject,
}: {
request: EmptyReturnRequest;
loading: boolean;
onCancel: () => void;
onReject: (reason: string) => void;
}) {
const [reason, setReason] = useState("");
return (
<Stack gap="md">
<Text size="sm">
{request.bookingReference ?? request.bookingId} {request.containerCount} container
{request.containerCount === 1 ? "" : "s"}
</Text>
<Textarea
label="Reason"
description="Shown to the customer."
placeholder="Why this return cannot be accepted"
value={reason}
onChange={(event) => setReason(event.currentTarget.value)}
rows={3}
required
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} disabled={loading}>
Cancel
</Button>
<Button color="red" onClick={() => onReject(reason.trim())} disabled={reason.trim().length < 3} loading={loading}>
Reject request
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,62 @@
import { api as client } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { unwrap } from '@/utils/endpoint';
import type {
EmptyReturnQuote,
EmptyReturnRequest,
EmptyReturnRequestStatus,
PlannedEmptyReturn,
} from '@/types/importOperations';
/**
* Empty container return requests — the customer-initiated path for a booking
* that was sold WITHOUT the return service. Staff price and approve them here;
* the customer pays and books the truck from the portal.
*/
export const emptyReturnRequestsService = {
list: async (params: {
status?: EmptyReturnRequestStatus;
bookingId?: string;
} = {}): Promise<EmptyReturnRequest[]> => {
const response = await client.get<EmptyReturnRequest[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.BASE,
{ params },
);
return unwrap(response.data);
},
/** Scheduled returns the warehouse is expecting, with date and truck. */
planned: async (): Promise<PlannedEmptyReturn[]> => {
const response = await client.get<PlannedEmptyReturn[]>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.PLANNED,
);
return unwrap(response.data);
},
/** The route price staff see prefilled at approval. */
quote: async (bookingId: string): Promise<EmptyReturnQuote> => {
const response = await client.get<{ quote: EmptyReturnQuote }>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.ELIGIBILITY(bookingId),
);
return unwrap(response.data).quote;
},
approve: async (
id: string,
payload: { unitAmount?: number; currency?: string } = {},
): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.APPROVE(id),
payload,
);
return unwrap(response.data);
},
reject: async (id: string, reason: string): Promise<EmptyReturnRequest> => {
const response = await client.post<EmptyReturnRequest>(
URL_CONSTANTS.EMPTY_RETURN_REQUESTS.REJECT(id),
{ reason },
);
return unwrap(response.data);
},
};

View File

@@ -179,3 +179,60 @@ export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperation
wagonAllocationReference?: string;
handoverNote?: string;
}
export type EmptyReturnRequestStatus =
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'PAID'
| 'SCHEDULED'
| 'COMPLETED'
| 'CANCELLED';
/** A customer's request to send empties back on a booking sold without return. */
export interface EmptyReturnRequest {
id: string;
bookingId: string;
bookingReference: string | null;
companyId: string | null;
companyName: string | null;
status: EmptyReturnRequestStatus;
containerNumbers: string[];
containerCount: number;
quotedUnitAmount: number | null;
quotedTotalAmount: number | null;
currency: string | null;
invoiceId: string | null;
paidAt: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
scheduledAt: string | null;
submittedAt: string;
reviewedAt: string | null;
rejectionReason: string | null;
completedAt: string | null;
}
/** Per-container price for an empty return, off the route's WITH_RETURN rate. */
export interface EmptyReturnQuote {
unitAmount: number | null;
currency: string;
sourceRateUsd: number | null;
unavailableReason: string | null;
}
/** A scheduled empty return the warehouse is waiting on. */
export interface PlannedEmptyReturn {
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 }>;
}

View File

@@ -229,6 +229,13 @@ export const URL_CONSTANTS = {
PUBLIC: "/api/support-content",
},
EMPTY_RETURN_REQUESTS: {
BASE: "/api/empty-return-requests",
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
BY_BOOKING: (bookingId: string) => `/api/empty-return-requests/by-booking/${bookingId}`,
SCHEDULE: (id: string) => `/api/empty-return-requests/${id}/schedule`,
},
LAST_MILE_REQUESTS: {
BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`,
BY_ID: (id: string) => `/api/last-mile-requests/${id}`,

View File

@@ -41,6 +41,7 @@ import {
} from "./components/Notices";
import { BookingPaymentPanel } from "./components/BookingPaymentPanel";
import { AdditionalChargesPanel } from "./components/AdditionalChargesPanel";
import { EmptyReturnRequestPanel } from "./components/EmptyReturnRequestPanel";
import { HeaderButton, PageHeader } from "./components/PageHeader";
import { PaymentMethodModal } from "./components/PaymentMethodModal";
import { ScheduleCard } from "./components/ScheduleCard";
@@ -415,6 +416,7 @@ export function ReadonlyBookingView({
showCountdown={showCountdown}
/>
<AdditionalChargesPanel bookingId={booking.id} />
<EmptyReturnRequestPanel bookingId={booking.id} />
<ScheduleCard
booking={booking}
title="Consignment & Schedule"

View File

@@ -0,0 +1,446 @@
import { useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Container as ContainerIcon, CreditCard } from "lucide-react";
import toast from "react-hot-toast";
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
import {
emptyReturnRequestsService,
type EmptyReturnRequest,
type EmptyReturnRequestStatus,
} from "@/services/empty-return-requests.service";
import { PaymentMethodModal } from "./PaymentMethodModal";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const STATUS_META: Record<EmptyReturnRequestStatus, { label: string; color: string }> = {
SUBMITTED: { label: "Awaiting EDR review", color: "#B07D14" },
APPROVED: { label: "Awaiting payment", color: "#B07D14" },
REJECTED: { label: "Rejected", color: "red" },
PAID: { label: "Paid — choose your date", color: "#1F6FEB" },
SCHEDULED: { label: "Scheduled", color: "#0A6F4D" },
COMPLETED: { label: "Returned", color: "#0A6F4D" },
CANCELLED: { label: "Cancelled", color: "#9AA8B5" },
};
const money = (amount: number | null, currency: string | null) =>
amount == null
? "—"
: `${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency ?? "ETB"}`;
const errorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
return data?.message ?? fallback;
};
/**
* Returning empties on a booking that did NOT buy the return service up front.
* The customer picks the containers off this booking; EDR prices and approves
* it; the customer pays here and then books the date and the truck that brings
* them in.
*/
export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
const eligibilityQuery = useQuery({
queryKey: ["empty-return-eligibility", bookingId],
queryFn: () => emptyReturnRequestsService.eligibility(bookingId),
});
const requestsQuery = useQuery({
queryKey: ["empty-return-requests", bookingId],
queryFn: () => emptyReturnRequestsService.listForBooking(bookingId),
});
const requests = requestsQuery.data ?? [];
const live = requests.filter((r) => r.status !== "REJECTED" && r.status !== "CANCELLED");
const eligibility = eligibilityQuery.data;
const refresh = () => {
qc.invalidateQueries({ queryKey: ["empty-return-requests", bookingId] });
qc.invalidateQueries({ queryKey: ["empty-return-eligibility", bookingId] });
};
// Nothing to offer and nothing to show — stay out of the way entirely.
if (!eligibility?.eligible && requests.length === 0) return null;
return (
<SectionCard p={22}>
<CardTitle>Empty container return</CardTitle>
<Stack gap={14} mt={12}>
{live.map((request) => (
<RequestRow key={request.id} request={request} onChanged={refresh} />
))}
{requests
.filter((r) => r.status === "REJECTED")
.map((request) => (
<Box key={request.id} p={12} style={{ borderRadius: 10, border: "1px solid #FDE2E1" }}>
<Group justify="space-between">
<Text fz="13px" fw={600} c="#10202F">
{request.containerCount} container{request.containerCount === 1 ? "" : "s"}
</Text>
<Badge radius="sm" variant="light" color="red">
Rejected
</Badge>
</Group>
{request.rejectionReason && (
<Text fz="12px" c="#9AA8B5" mt={4}>
{request.rejectionReason}
</Text>
)}
</Box>
))}
{eligibility?.eligible ? (
<NewRequestForm
bookingId={bookingId}
availableNumbers={eligibility.availableContainerNumbers}
unitAmount={eligibility.quote.unitAmount}
currency={eligibility.quote.currency}
onCreated={refresh}
/>
) : (
eligibility?.reason &&
live.length === 0 && (
<Text fz="12px" c="#9AA8B5">
{eligibility.reason}
</Text>
)
)}
</Stack>
</SectionCard>
);
}
/** One live request: what it costs, what it is waiting on, and the next step. */
function RequestRow({
request,
onChanged,
}: {
request: EmptyReturnRequest;
onChanged: () => void;
}) {
const meta = STATUS_META[request.status];
return (
<Box p={14} style={{ borderRadius: 10, border: "1px solid #EEF2F6" }}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ minWidth: 0 }}>
<Group gap={6}>
<ContainerIcon size={13} color="#9AA8B5" />
<Text fz="13.5px" fw={700} c="#10202F">
{request.containerCount} empty container{request.containerCount === 1 ? "" : "s"}
</Text>
</Group>
<Text fz="12px" c="#9AA8B5" mt={2}>
{request.containerNumbers.join(", ")}
</Text>
{request.quotedTotalAmount != null && (
<Text fz="12px" c="#9AA8B5" mt={2}>
{money(request.quotedTotalAmount, request.currency)}
{request.quotedUnitAmount != null &&
` · ${money(request.quotedUnitAmount, request.currency)} per container`}
</Text>
)}
{request.requestedReturnDate && (
<Text fz="12px" c="#9AA8B5" mt={2}>
Returning {request.requestedReturnDate} · truck {request.truckPlateNumber}
</Text>
)}
</Box>
<Badge
radius="sm"
variant="light"
styles={{ root: { backgroundColor: `${meta.color}22`, color: meta.color } }}
>
{meta.label}
</Badge>
</Group>
{request.status === "APPROVED" && request.invoiceId && (
<PayButton
invoiceId={request.invoiceId}
amount={request.quotedTotalAmount ?? 0}
currency={request.currency ?? "ETB"}
/>
)}
{request.status === "PAID" && <ScheduleForm request={request} onScheduled={onChanged} />}
</Box>
);
}
function PayButton({
invoiceId,
amount,
currency,
}: {
invoiceId: string;
amount: number;
currency: string;
}) {
const [modalOpen, setModalOpen] = useState(false);
const flow = useInvoicePayment();
const close = () => {
if (!flow.processing) {
setModalOpen(false);
flow.reset();
}
};
return (
<ModalSafeWrapper>
<Button
mt={10}
size="xs"
radius="md"
fw={700}
color="edr-green"
leftSection={<CreditCard size={14} />}
onClick={(event) => {
event.stopPropagation();
setModalOpen(true);
}}
>
Pay now
</Button>
<PaymentMethodModal
opened={modalOpen}
onClose={close}
amountLabel={`${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} ${currency}`}
currency={currency}
processing={flow.processing}
error={flow.error}
otp={flow.otp}
bill={flow.bill}
onConfirm={(method, payerAccount) => flow.pay(invoiceId, method, payerAccount)}
/>
</ModalSafeWrapper>
);
}
/** After payment: when the empties come back, and on whose truck. */
function ScheduleForm({
request,
onScheduled,
}: {
request: EmptyReturnRequest;
onScheduled: () => void;
}) {
const [returnDate, setReturnDate] = useState("");
const [plate, setPlate] = useState("");
const [driver, setDriver] = useState("");
const [truckType, setTruckType] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: () =>
emptyReturnRequestsService.schedule(request.id, {
returnDate,
truckPlateNumber: plate.trim(),
truckDriverName: driver.trim(),
truckType: truckType ?? undefined,
}),
onSuccess: () => {
toast.success("Return date and truck saved");
onScheduled();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not save the return details"));
},
});
const ready = Boolean(returnDate && plate.trim() && driver.trim());
return (
<Stack gap={10} mt={12}>
<Text fz="12px" fw={600} c="#10202F">
Tell us when the containers are coming back
</Text>
<TextInput
size="xs"
type="date"
label="Return date"
value={returnDate}
onChange={(event) => setReturnDate(event.currentTarget.value)}
required
/>
<TextInput
size="xs"
label="Truck plate"
placeholder="3-A12345"
value={plate}
onChange={(event) => setPlate(event.currentTarget.value.toUpperCase())}
required
/>
<TextInput
size="xs"
label="Driver name"
value={driver}
onChange={(event) => setDriver(event.currentTarget.value)}
required
/>
<Select
size="xs"
label="Truck type"
placeholder="Select"
data={TRUCK_TYPES}
value={truckType}
onChange={setTruckType}
clearable
/>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={!ready}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Confirm return details
</Button>
</Stack>
);
}
/**
* The booking's own containers, ticked. Only a container that came in on this
* booking can go back on it, so the customer picks from that list rather than
* typing numbers, and the count follows the ticks.
*/
function NewRequestForm({
bookingId,
availableNumbers,
unitAmount,
currency,
onCreated,
}: {
bookingId: string;
availableNumbers: string[];
unitAmount: number | null;
currency: string;
onCreated: () => void;
}) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () => emptyReturnRequestsService.create(bookingId, selected),
onSuccess: () => {
toast.success("Empty return requested — EDR will review and price it");
setOpen(false);
setSelected([]);
onCreated();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not submit the request"));
},
});
if (!open) {
return (
<Stack gap={6}>
<Button
size="xs"
radius="md"
fw={700}
variant="light"
color="edr-green"
onClick={() => setOpen(true)}
>
Request empty return
</Button>
{unitAmount != null && (
<Text fz="11.5px" c="#9AA8B5">
{money(unitAmount, currency)} per container, payable after EDR approves.
</Text>
)}
</Stack>
);
}
return (
<Stack gap={10}>
<Group justify="space-between" align="center">
<Text fz="12px" fw={600} c="#10202F">
Select the containers you are returning
</Text>
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
onClick={() =>
setSelected(selected.length === availableNumbers.length ? [] : [...availableNumbers])
}
>
{selected.length === availableNumbers.length ? "Clear all" : "Select all"}
</Button>
</Group>
<Stack gap={6}>
{availableNumbers.map((number) => (
<Checkbox
key={number}
size="xs"
label={number}
checked={selected.includes(number)}
onChange={(event) =>
setSelected((current) =>
event.currentTarget.checked
? [...current, number]
: current.filter((value) => value !== number),
)
}
/>
))}
</Stack>
{unitAmount != null && selected.length > 0 && (
<Alert color="gray" p={10}>
<Text fz="12px">
Estimated {money(unitAmount * selected.length, currency)} for {selected.length} container
{selected.length === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
</Text>
</Alert>
)}
<Group gap={8}>
<Button size="xs" variant="default" radius="md" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
size="xs"
radius="md"
fw={700}
color="edr-green"
disabled={selected.length === 0}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Submit request{selected.length > 0 ? ` (${selected.length})` : ""}
</Button>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,87 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const E = URL_CONSTANTS.EMPTY_RETURN_REQUESTS;
export type EmptyReturnRequestStatus =
| "SUBMITTED"
| "APPROVED"
| "REJECTED"
| "PAID"
| "SCHEDULED"
| "COMPLETED"
| "CANCELLED";
export interface EmptyReturnRequest {
id: string;
bookingId: string;
status: EmptyReturnRequestStatus;
containerNumbers: string[];
containerCount: number;
quotedUnitAmount: number | null;
quotedTotalAmount: number | null;
currency: string | null;
invoiceId: string | null;
paidAt: string | null;
requestedReturnDate: string | null;
truckPlateNumber: string | null;
truckDriverName: string | null;
truckType: string | null;
rejectionReason: string | null;
submittedAt: string;
}
/** Per-container price for the return, off the booking's contract route rate. */
export interface EmptyReturnQuote {
unitAmount: number | null;
currency: string;
sourceRateUsd: number | null;
unavailableReason: string | null;
}
export interface EmptyReturnEligibility {
eligible: boolean;
reason: string | null;
availableContainerNumbers: string[];
maxContainers: number;
quote: EmptyReturnQuote;
}
export interface ScheduleEmptyReturnPayload {
returnDate: string;
truckPlateNumber: string;
truckDriverName: string;
truckType?: string;
}
/**
* Returning empties on a booking that was sold WITHOUT the return service:
* the customer names the containers, EDR prices and approves, the customer
* pays and then books the date and truck.
*/
export const emptyReturnRequestsService = {
/** Whether this booking can ask, which containers are free, and the price. */
eligibility: async (bookingId: string): Promise<EmptyReturnEligibility> => {
const { data } = await client.get(E.ELIGIBILITY(bookingId));
return data.data ?? data;
},
listForBooking: async (bookingId: string): Promise<EmptyReturnRequest[]> => {
const { data } = await client.get(E.BY_BOOKING(bookingId));
return data.data ?? data;
},
create: async (bookingId: string, containerNumbers: string[]): Promise<EmptyReturnRequest> => {
const { data } = await client.post(E.BASE, { bookingId, containerNumbers });
return data.data ?? data;
},
/** Date + truck, once the invoice is paid. */
schedule: async (
id: string,
payload: ScheduleEmptyReturnPayload,
): Promise<EmptyReturnRequest> => {
const { data } = await client.post(E.SCHEDULE(id), payload);
return data.data ?? data;
},
};