mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 13:35:03 +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(
|
||||
|
||||
Reference in New Issue
Block a user