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(