fix(empty-return-requests): pick the containers off the booking

Typing container numbers let a customer request a return for a box that
never came in on that booking — and gave them a blank field to guess at
when they did not have the numbers to hand.

The portal now lists the booking's own containers, minus any already
spoken for by a live request, as a tick list; the count follows the
ticks. The API backs that up: every submitted number must be one of the
booking's containers, and a booking with no container numbers recorded
says so instead of offering an empty form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hager
2026-09-02 11:24:43 +00:00
parent 3f7b744987
commit de55d809c7
3 changed files with 146 additions and 84 deletions

View File

@@ -174,6 +174,15 @@ describe('EmptyReturnRequestsService — eligibility', () => {
expect(result.reason).toMatch(/already on an empty return request/i);
});
it('refuses a booking with no container numbers to pick from', async () => {
const { service } = build({ queries: [['upper(bcu.container_number)', []]] });
const result = await service.eligibility('b1', null);
expect(result.eligible).toBe(false);
expect(result.reason).toMatch(/no container numbers are recorded/i);
expect(result.availableContainerNumbers).toEqual([]);
});
it('checks booking ownership for a portal caller, and skips it for staff', async () => {
const portal = build();
await portal.service.eligibility('b1', 'user1');
@@ -185,6 +194,66 @@ describe('EmptyReturnRequestsService — eligibility', () => {
});
});
describe('EmptyReturnRequestsService — creating a request', () => {
it('accepts containers that came in on the booking', async () => {
const { service, requests } = build();
await service.create(
{ bookingId: 'b1', containerNumbers: ['temu1111111', 'TEMU2222222'] },
'user1',
);
expect(requests.create).toHaveBeenCalledWith(
expect.objectContaining({
bookingId: 'b1',
containerNumbers: ['TEMU1111111', 'TEMU2222222'],
containerCount: 2,
status: 'SUBMITTED',
}),
);
});
it('refuses a container that is not on the booking', async () => {
const { service, requests } = build();
await expect(
service.create(
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'MSCU9999999'] },
'user1',
),
).rejects.toThrow(/Not on booking BK-2026-000300: MSCU9999999/);
expect(requests.create).not.toHaveBeenCalled();
});
it('refuses the same container twice', async () => {
const { service } = build();
await expect(
service.create(
{ bookingId: 'b1', containerNumbers: ['TEMU1111111', 'TEMU1111111'] },
'user1',
),
).rejects.toThrow(/selected twice/i);
});
it('refuses a container already sitting on a live request', async () => {
const { service } = build({
queries: [['unnest(r.container_numbers)', [{ containerNumber: 'TEMU1111111' }]]],
});
await expect(
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
).rejects.toThrow(/Already on an empty return request/);
});
it('refuses a booking that already ships with return', async () => {
const { service } = build({ booking: { equipmentReturn: 'WITH_RETURN' } });
await expect(
service.create({ bookingId: 'b1', containerNumbers: ['TEMU1111111'] }, 'user1'),
).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('EmptyReturnRequestsService — pricing', () => {
it('prices a container at the route WITH_RETURN rate, converted to birr', async () => {
const { service, booking: b } = build();

View File

@@ -134,19 +134,21 @@ export class EmptyReturnRequestsService {
const all = await this.bookingContainerNumbers(bookingId);
const available = all.filter((number) => !spoken.has(number));
const reason = this.ineligibilityReason(booking, available.length);
const reason = this.ineligibilityReason(booking, all.length, 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)),
maxContainers: available.length,
quote,
};
}
private ineligibilityReason(booking: Booking, availableCount: number): string | null {
private ineligibilityReason(
booking: Booking,
bookingContainerCount: number,
availableCount: number,
): string | null {
if (booking.freightType !== 'CONTAINER') {
return 'Empty container return applies to container freight only.';
}
@@ -156,6 +158,11 @@ export class EmptyReturnRequestsService {
if (!REQUESTABLE_BOOKING_STATUSES.includes(booking.status)) {
return `An empty return can be requested once the booking is in transit (current status: ${booking.status}).`;
}
// The customer picks from this booking's own containers, so a booking that
// never captured its container numbers has nothing to pick.
if (bookingContainerCount === 0) {
return 'No container numbers are recorded on this booking — contact EDR to arrange the return.';
}
if (availableCount === 0) {
return 'Every container on this booking is already on an empty return request.';
}
@@ -239,13 +246,24 @@ export class EmptyReturnRequestsService {
const numbers = dto.containerNumbers.map((n) => n.trim().toUpperCase()).filter(Boolean);
if (numbers.length === 0) {
throw new BadRequestException('Give at least one container number.');
throw new BadRequestException('Select at least one container.');
}
if (new Set(numbers).size !== numbers.length) {
throw new BadRequestException('The same container number appears twice.');
throw new BadRequestException('The same container is selected twice.');
}
const reason = this.ineligibilityReason(booking, numbers.length);
// Only this booking's own containers can be returned against it. The
// portal offers a pick list, so anything else is a stale page or a
// hand-made request.
const onBooking = new Set(await this.bookingContainerNumbers(booking.id));
const foreign = numbers.filter((number) => !onBooking.has(number));
if (foreign.length > 0) {
throw new BadRequestException(
`Not on booking ${booking.reference ?? booking.id}: ${foreign.join(', ')}`,
);
}
const reason = this.ineligibilityReason(booking, onBooking.size, numbers.length);
if (reason) throw new BadRequestException(reason);
await this.assertContainersFree(numbers);
@@ -567,17 +585,6 @@ export class EmptyReturnRequestsService {
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(

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Alert,
Badge,
Box,
Button,
Checkbox,
Group,
NumberInput,
Select,
Stack,
Text,
@@ -51,9 +51,9 @@ const errorMessage = (error: unknown, fallback: string) => {
/**
* 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.
* The customer picks the containers off this booking; EDR prices and approves
* it; the customer pays here and then books the date and the truck that brings
* them in.
*/
export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
@@ -112,8 +112,7 @@ export function EmptyReturnRequestPanel({ bookingId }: { bookingId: string }) {
{eligibility?.eligible ? (
<NewRequestForm
bookingId={bookingId}
maxContainers={eligibility.maxContainers}
suggestedNumbers={eligibility.availableContainerNumbers}
availableNumbers={eligibility.availableContainerNumbers}
unitAmount={eligibility.quote.unitAmount}
currency={eligibility.quote.currency}
onCreated={refresh}
@@ -326,48 +325,32 @@ function ScheduleForm({
}
/**
* 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.
* The booking's own containers, ticked. Only a container that came in on this
* booking can go back on it, so the customer picks from that list rather than
* typing numbers, and the count follows the ticks.
*/
function NewRequestForm({
bookingId,
maxContainers,
suggestedNumbers,
availableNumbers,
unitAmount,
currency,
onCreated,
}: {
bookingId: string;
maxContainers: number;
suggestedNumbers: string[];
availableNumbers: 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 [selected, setSelected] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () =>
emptyReturnRequestsService.create(
bookingId,
numbers.map((n) => n.trim().toUpperCase()),
),
mutationFn: () => emptyReturnRequestsService.create(bookingId, selected),
onSuccess: () => {
toast.success("Empty return requested — EDR will review and price it");
setOpen(false);
setCount(1);
setNumbers([""]);
setSelected([]);
onCreated();
},
onError: (error: unknown) => {
@@ -375,9 +358,6 @@ function NewRequestForm({
},
});
const filled = numbers.filter((n) => n.trim().length > 0);
const ready = filled.length > 0 && filled.length === numbers.length;
if (!open) {
return (
<Stack gap={6}>
@@ -402,39 +382,45 @@ function NewRequestForm({
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,
),
)
<Group justify="space-between" align="center">
<Text fz="12px" fw={600} c="#10202F">
Select the containers you are returning
</Text>
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
onClick={() =>
setSelected(selected.length === availableNumbers.length ? [] : [...availableNumbers])
}
required
/>
))}
>
{selected.length === availableNumbers.length ? "Clear all" : "Select all"}
</Button>
</Group>
{unitAmount != null && typeof count === "number" && (
<Stack gap={6}>
{availableNumbers.map((number) => (
<Checkbox
key={number}
size="xs"
label={number}
checked={selected.includes(number)}
onChange={(event) =>
setSelected((current) =>
event.currentTarget.checked
? [...current, number]
: current.filter((value) => value !== number),
)
}
/>
))}
</Stack>
{unitAmount != null && selected.length > 0 && (
<Alert color="gray" p={10}>
<Text fz="12px">
Estimated {money(unitAmount * count, currency)} for {count} container
{count === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
Estimated {money(unitAmount * selected.length, currency)} for {selected.length} container
{selected.length === 1 ? "" : "s"}. EDR confirms the price when it approves your request.
</Text>
</Alert>
)}
@@ -448,11 +434,11 @@ function NewRequestForm({
radius="md"
fw={700}
color="edr-green"
disabled={!ready}
disabled={selected.length === 0}
loading={mutation.isPending}
onClick={() => mutation.mutate()}
>
Submit request
Submit request{selected.length > 0 ? ` (${selected.length})` : ""}
</Button>
</Group>
</Stack>