feat(empty-return-requests): customer-requested empty return, priced and paid

A booking sold WITHOUT the return service had no way to send its empties
back: the containers were the customer's problem and nothing in the
system priced, billed or planned the movement.

Adds the request flow end to end. The customer opens the booking, says
how many containers are coming back and types each number; the request
lands in a new backoffice queue (Empty Return Requests). Approval prices
it off the same live WITH_RETURN route rate the rule engine bills when
the service IS bought up front — per container, converted to birr, and
overridable by the reviewer — and issues the invoice there and then.
Payment settles through the normal invoice path, whose
`empty_return_request.invoice.paid` event moves the request to PAID; the
customer then books the return date and the truck.

Scheduled requests surface on Container Returns as Planned Empty
Returns, where confirming the arrival records the containers through the
existing empty-container-return flow — which in turn closes the request
once its last container is in.

Container freight only, never a booking that already ships WITH_RETURN,
and only from IN_TRANSIT onward: the empty comes back after delivery, so
the option has to outlive ARRIVED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-02 11:05:03 +00:00
parent 2e51342d1e
commit 3f7b744987
24 changed files with 2770 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,344 @@
import { BadRequestException } from '@nestjs/common';
import { EmptyReturnRequestsService } from './empty-return-requests.service';
import type { EmptyReturnRequest } from './entities/empty-return-request.entity';
/**
* The service is mostly gates and pricing over raw SQL, so the SQL is stubbed
* by matching a distinctive fragment of each statement. Every stub returns the
* shape the real query returns.
*/
type QueryStub = Array<[string, unknown]>;
const booking = {
id: 'b1',
reference: 'BK-2026-000300',
companyId: 'co1',
companyProfileId: 'cp1',
status: 'ARRIVED',
freightType: 'CONTAINER',
equipmentReturn: 'WITHOUT_RETURN',
tradeDirection: 'IMPORT',
originYardId: 'y-dj',
destinationYardId: 'y-mojo',
paymentCurrency: 'ETB',
};
function build(
overrides: {
booking?: Partial<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('checks booking ownership for a portal caller, and skips it for staff', async () => {
const portal = build();
await portal.service.eligibility('b1', 'user1');
expect(portal.bookingsService.assertCustomerCanAccessBooking).toHaveBeenCalled();
const staff = build();
await staff.service.eligibility('b1', null);
expect(staff.bookingsService.assertCustomerCanAccessBooking).not.toHaveBeenCalled();
});
});
describe('EmptyReturnRequestsService — pricing', () => {
it('prices a container at the route WITH_RETURN rate, converted to birr', async () => {
const { service, booking: b } = build();
const quote = await service.quote(b as never);
// 100 USD × 120 ETB/USD
expect(quote).toMatchObject({ unitAmount: 12000, currency: 'ETB', sourceRateUsd: 100 });
expect(quote.unavailableReason).toBeNull();
});
it('falls back to the route rate that names no container type', async () => {
const { service, booking: b } = build({
rates: [
{
trigger: 'WITH_RETURN',
currency: 'USD',
tradeDirection: 'IMPORT',
originYardId: 'y-dj',
destinationYardId: 'y-mojo',
containerTypeId: null,
rateValue: '80',
},
],
});
expect((await service.quote(b as never)).unitAmount).toBe(9600);
});
it('reports no price when no rate covers the route', async () => {
const { service, booking: b } = build({
rates: [
{
trigger: 'WITH_RETURN',
currency: 'USD',
tradeDirection: 'EXPORT',
originYardId: 'other',
destinationYardId: 'other',
containerTypeId: null,
rateValue: '80',
},
],
});
const quote = await service.quote(b as never);
expect(quote.unitAmount).toBeNull();
expect(quote.unavailableReason).toMatch(/no empty-return rate/i);
});
});
describe('EmptyReturnRequestsService — approval', () => {
it('bills container count × the route rate and stores the invoice', async () => {
const { service, billing, requests } = build();
await service.approve('r1', 'staff1', {});
expect(billing.generateInvoice).toHaveBeenCalledWith(
expect.objectContaining({
source: 'empty_return_request',
sourceId: 'r1',
currency: 'ETB',
totalAmount: 36000, // 3 × 12,000
}),
);
expect(requests.update).toHaveBeenCalledWith(
'r1',
expect.objectContaining({
status: 'APPROVED',
quotedUnitAmount: 12000,
quotedTotalAmount: 36000,
invoiceId: 'inv1',
}),
);
});
it("bills the reviewer's override instead of the route rate", async () => {
const { service, billing } = build();
await service.approve('r1', 'staff1', { unitAmount: 5000 });
expect(billing.generateInvoice).toHaveBeenCalledWith(
expect.objectContaining({ totalAmount: 15000 }),
);
});
it('refuses to approve without a price when no rate covers the route', async () => {
const { service } = build({ rates: [] });
await expect(service.approve('r1', 'staff1', {})).rejects.toBeInstanceOf(BadRequestException);
});
it('only approves a submitted request', async () => {
const { service } = build({ request: { status: 'APPROVED' } });
await expect(service.approve('r1', 'staff1', {})).rejects.toThrow(/Only a submitted request/);
});
});
describe('EmptyReturnRequestsService — scheduling', () => {
const details = {
returnDate: '2026-09-20',
truckPlateNumber: '3-a12345',
truckDriverName: 'Abebe K.',
};
it('takes the date and truck once the invoice is paid', async () => {
const { service, requests } = build({ request: { status: 'PAID' } });
await service.schedule('r1', 'user1', details);
expect(requests.update).toHaveBeenCalledWith(
'r1',
expect.objectContaining({
status: 'SCHEDULED',
requestedReturnDate: '2026-09-20',
truckPlateNumber: '3-A12345',
}),
);
});
it('tells an unpaid customer to pay first', async () => {
const { service } = build({ request: { status: 'APPROVED' } });
await expect(service.schedule('r1', 'user1', details)).rejects.toThrow(
/Pay the empty return invoice/,
);
});
});
describe('EmptyReturnRequestsService — payment and completion', () => {
it('moves an approved request to PAID when its invoice settles', async () => {
const { service, requests } = build({ request: { status: 'APPROVED' } });
await service.onInvoicePaid({ sourceId: 'r1' });
expect(requests.update).toHaveBeenCalledWith('r1', expect.objectContaining({ status: 'PAID' }));
});
it('ignores a settlement for a request that is not awaiting payment', async () => {
const { service, requests } = build({ request: { status: 'SCHEDULED' } });
await service.onInvoicePaid({ sourceId: 'r1' });
expect(requests.update).not.toHaveBeenCalled();
});
it('completes a scheduled request once every container is recorded back', async () => {
const { service, requests } = build({ request: { status: 'SCHEDULED' } });
await service.settleScheduledForBooking('b1');
expect(requests.update).toHaveBeenCalledWith(
'r1',
expect.objectContaining({ status: 'COMPLETED' }),
);
});
it('leaves it scheduled while any container is still outstanding', async () => {
const { service, requests } = build({
request: { status: 'SCHEDULED' },
queries: [['COUNT(*) AS outstanding', [{ outstanding: '2' }]]],
});
await service.settleScheduledForBooking('b1');
expect(requests.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,610 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Freight, NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BillingService } from '../billing/billing.service';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { RatesService } from '../rule-engine/services/rates.service';
import {
ApproveEmptyReturnRequestDto,
CreateEmptyReturnRequestDto,
RejectEmptyReturnRequestDto,
ScheduleEmptyReturnRequestDto,
} from './dto/empty-return-request.dto';
import {
EmptyReturnRequest,
type EmptyReturnRequestStatus,
} from './entities/empty-return-request.entity';
import { EmptyReturnRequestsRepository } from './empty-return-requests.repository';
/** The invoice `source` this module owns — also the `${source}.invoice.paid` event prefix. */
const INVOICE_SOURCE = 'empty_return_request';
/**
* Booking statuses that may still ask for an empty return. The empty only goes
* back after the cargo is delivered, so everything from departure onward
* qualifies — cutting it off at ARRIVED would take the option away exactly
* when the customer needs it.
*/
const REQUESTABLE_BOOKING_STATUSES = ['IN_TRANSIT', 'ARRIVED', 'COMPLETED'];
/** Requests that still hold their container numbers — a rejected one releases them. */
const OPEN_STATUSES: EmptyReturnRequestStatus[] = [
'SUBMITTED',
'APPROVED',
'PAID',
'SCHEDULED',
'COMPLETED',
];
export interface EmptyReturnQuote {
/** Per-container price in `currency`; null when no rate covers this route. */
unitAmount: number | null;
currency: string;
/** The USD route rate the quote came from, before conversion. */
sourceRateUsd: number | null;
/** Why there is no price, for the UI to show instead of a number. */
unavailableReason: string | null;
}
export interface EmptyReturnEligibility {
eligible: boolean;
/** Why the customer cannot request one, when `eligible` is false. */
reason: string | null;
/** Containers on the booking that are not already spoken for. */
availableContainerNumbers: string[];
maxContainers: number;
quote: EmptyReturnQuote;
}
@Injectable()
export class EmptyReturnRequestsService {
constructor(
private readonly requests: EmptyReturnRequestsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly billing: BillingService,
private readonly notifications: NotificationInboxService,
private readonly ratesService: RatesService,
private readonly exchange: ExchangeService,
private readonly dataSource: DataSource,
) {}
// ── reads ────────────────────────────────────────────────────────────────
async findAll(filter: {
status?: EmptyReturnRequestStatus;
bookingId?: string;
}): Promise<
Array<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, available.length);
return {
eligible: reason === null,
reason,
availableContainerNumbers: available,
// A booking whose container numbers were never captured still gets to
// ask — the customer types the numbers, so the line quantity is the cap.
maxContainers: available.length || (await this.bookingContainerQuantity(bookingId)),
quote,
};
}
private ineligibilityReason(booking: Booking, availableCount: number): string | null {
if (booking.freightType !== 'CONTAINER') {
return 'Empty container return applies to container freight only.';
}
if (booking.equipmentReturn === 'WITH_RETURN' || booking.equipmentReturn === 'RETURN') {
return 'This booking already ships with empty container return included.';
}
if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) {
return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`;
}
if (availableCount === 0) {
return 'Every container on this booking is already on an empty return request.';
}
return null;
}
// ── pricing ──────────────────────────────────────────────────────────────
/**
* Per-container price for returning an empty on this booking, taken from the
* same live WITH_RETURN rate the rule engine bills when the service is
* bought up front (route + trade direction + container type, priced in USD).
* Billed in ETB, converted at the current rate, because this is collected
* locally rather than on the freight invoice.
*
* ponytail: prices off the booking's FIRST container line. A booking mixing
* 20ft and 40ft therefore quotes one size's rate for every box — split the
* quote per container if mixed-size bookings start returning empties.
*/
async quote(booking: Booking): Promise<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('Give at least one container number.');
}
if (new Set(numbers).size !== numbers.length) {
throw new BadRequestException('The same container number appears twice.');
}
const reason = this.ineligibilityReason(booking, numbers.length);
if (reason) throw new BadRequestException(reason);
await this.assertContainersFree(numbers);
const saved = await this.requests.create({
bookingId: booking.id,
companyId: booking.companyId ?? null,
status: 'SUBMITTED',
containerNumbers: numbers,
containerCount: numbers.length,
submittedByUserId: userId,
submittedAt: new Date(),
} as Partial<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);
}
/** How many containers the booking bought, for a booking with no captured numbers. */
private async bookingContainerQuantity(bookingId: string): Promise<number> {
const [row]: Array<{ quantity: string | null }> = await this.dataSource.query(
`SELECT COALESCE(SUM(quantity), 0) AS quantity
FROM freight.booking_container
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
return Number(row?.quantity ?? 0);
}
/** Numbers already claimed by a live request on this booking. */
private async spokenForContainers(bookingId: string): Promise<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,460 @@
import { useEffect, useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Group,
NumberInput,
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 says how many containers are coming back and types their
* numbers; 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}
maxContainers={eligibility.maxContainers}
suggestedNumbers={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>
);
}
/**
* How many containers are coming back, then one number per container. The
* count drives the inputs, exactly as the customer is asked at the counter.
*/
function NewRequestForm({
bookingId,
maxContainers,
suggestedNumbers,
unitAmount,
currency,
onCreated,
}: {
bookingId: string;
maxContainers: number;
suggestedNumbers: string[];
unitAmount: number | null;
currency: string;
onCreated: () => void;
}) {
const [open, setOpen] = useState(false);
const [count, setCount] = useState<number | "">(1);
const [numbers, setNumbers] = useState<string[]>([""]);
// The number of inputs follows the count the customer entered, keeping
// whatever they have already typed.
useEffect(() => {
const size = typeof count === "number" ? Math.max(0, Math.min(count, 50)) : 0;
setNumbers((current) =>
Array.from({ length: size }, (_, index) => current[index] ?? suggestedNumbers[index] ?? ""),
);
}, [count, suggestedNumbers]);
const mutation = useMutation({
mutationFn: () =>
emptyReturnRequestsService.create(
bookingId,
numbers.map((n) => n.trim().toUpperCase()),
),
onSuccess: () => {
toast.success("Empty return requested — EDR will review and price it");
setOpen(false);
setCount(1);
setNumbers([""]);
onCreated();
},
onError: (error: unknown) => {
toast.error(errorMessage(error, "Could not submit the request"));
},
});
const filled = numbers.filter((n) => n.trim().length > 0);
const ready = filled.length > 0 && filled.length === numbers.length;
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}>
<NumberInput
size="xs"
label="How many containers are you returning?"
value={count}
onChange={(value) => setCount(typeof value === "number" ? value : value === "" ? "" : Number(value))}
min={1}
max={Math.max(1, maxContainers || 50)}
clampBehavior="strict"
/>
{numbers.map((number, index) => (
<TextInput
key={index}
size="xs"
label={`Container ${index + 1}`}
placeholder="TEMU1234567"
value={number}
onChange={(event) =>
setNumbers((current) =>
current.map((existing, i) =>
i === index ? event.currentTarget.value.toUpperCase() : existing,
),
)
}
required
/>
))}
{unitAmount != null && typeof count === "number" && (
<Alert color="gray" p={10}>
<Text fz="12px">
Estimated {money(unitAmount * count, currency)} for {count} container
{count === 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={!ready}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Submit request
</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;
},
};