mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 08:33:39 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user