diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts index 9b8c29e64..5b358b7aa 100644 --- a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.spec.ts @@ -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(); diff --git a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts index a35934fab..aae59de39 100644 --- a/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts +++ b/apps/edr-freight-api/src/modules/empty-return-requests/empty-return-requests.service.ts @@ -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 { - const [row]: Array<{ quantity: string | null }> = await this.dataSource.query( - `SELECT COALESCE(SUM(quantity), 0) AS quantity - FROM freight.booking_container - WHERE booking_id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - return Number(row?.quantity ?? 0); - } - /** Numbers already claimed by a live request on this booking. */ private async spokenForContainers(bookingId: string): Promise> { const rows: Array<{ containerNumber: string }> = await this.dataSource.query( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/EmptyReturnRequestPanel.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/EmptyReturnRequestPanel.tsx index be2991e84..e7d15af8f 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/EmptyReturnRequestPanel.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/EmptyReturnRequestPanel.tsx @@ -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 ? ( void; }) { const [open, setOpen] = useState(false); - const [count, setCount] = useState(1); - const [numbers, setNumbers] = useState([""]); - - // 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([]); 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 ( @@ -402,39 +382,45 @@ function NewRequestForm({ return ( - setCount(typeof value === "number" ? value : value === "" ? "" : Number(value))} - min={1} - max={Math.max(1, maxContainers || 50)} - clampBehavior="strict" - /> - - {numbers.map((number, index) => ( - - setNumbers((current) => - current.map((existing, i) => - i === index ? event.currentTarget.value.toUpperCase() : existing, - ), - ) + + + Select the containers you are returning + + + - {unitAmount != null && typeof count === "number" && ( + + {availableNumbers.map((number) => ( + + setSelected((current) => + event.currentTarget.checked + ? [...current, number] + : current.filter((value) => value !== number), + ) + } + /> + ))} + + + {unitAmount != null && selected.length > 0 && ( - 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. )} @@ -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})` : ""}