feat: implement manual consolidation candidate selection logic and update related tests

This commit is contained in:
marshalyordanos
2026-09-04 13:52:19 +03:00
parent eaa006a932
commit ca84aac456
6 changed files with 210 additions and 93 deletions

View File

@@ -70,4 +70,75 @@ describe('BookingsRepository', () => {
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
});
it('findManualConsolidationCandidates offers an odd-20ft partner booked on another day', async () => {
const qb = mockQueryBuilder();
// Same route/direction, odd 20ft count, but sitting on a different
// scheduled_date than the booking being completed. GL completes both halves
// onto the date chosen on the form, so this is still a legal partner.
qb.getMany.mockResolvedValue([
{
id: 'partner',
reference: 'BK-2026-000303',
scheduledDate: new Date('2026-09-02T08:00:00.000Z'),
bookingContainers: [{ quantity: 1, containerType: { sizeFt: 20 } }],
},
]);
repository.createQueryBuilder.mockReturnValue(qb as never);
const result = await bookingsRepository.findManualConsolidationCandidates({
id: 'own',
originYardId: 'yard-a',
destinationYardId: 'yard-b',
tradeDirection: 'IMPORT',
scheduledDate: new Date('2026-09-01T08:00:00.000Z'),
} as Booking);
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-000303']);
expect(result[0].ft20Quantity).toBe(1);
// The booking day must not narrow this list at all.
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
String(clause).includes('scheduled_date'),
);
expect(dateFilters).toHaveLength(0);
});
it('findManualConsolidationCandidates falls back to the requested lines before cargo is persisted', async () => {
const qb = mockQueryBuilder();
// The ordinary state of a CLEARANCE_READY customs booking: container lines
// are written by completion, so there are none yet and the accepted booking
// request is the only statement of what it will carry.
qb.getMany.mockResolvedValue([
{ id: 'b-odd', reference: 'BK-2026-001116', bookingContainers: [] },
{ id: 'b-even', reference: 'BK-EVEN', bookingContainers: [] },
// No request at all — count unknown, so not offerable.
{ id: 'b-unknown', reference: 'BK-UNKNOWN', bookingContainers: [] },
]);
repository.createQueryBuilder.mockReturnValue(qb as never);
dataSource.getRepository.mockReturnValue({
createQueryBuilder: () => ({
where: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([
{
createdBookingId: 'b-odd',
requestedLines: { containers: [{ containerSize: '20ft', quantity: 1 }] },
},
{
createdBookingId: 'b-even',
requestedLines: { containers: [{ containerSize: '20ft', quantity: 2 }] },
},
]),
}),
});
const result = await bookingsRepository.findManualConsolidationCandidates({
id: 'own',
originYardId: 'yard-a',
destinationYardId: 'yard-b',
tradeDirection: 'EXPORT',
} as Booking);
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']);
expect(result[0].ft20Quantity).toBe(1);
});
});

View File

@@ -20,6 +20,7 @@ import { Contract } from '../contracts/entities/contract.entity';
import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingRequest } from '../contracts/entities/booking-request.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
import {
CARGO_TYPE_SUBTREE_SQL,
@@ -336,19 +337,35 @@ export class BookingsRepository extends BaseRepository<Booking> {
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, but
* every row must still be a legal pick: another customs booking on the same
* route/direction, riding the same booking day, that is itself carrying an odd
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
* wagon.
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
*
* A booking whose cargo is not entered yet is NOT a candidate: with no
* container lines its 20ft count is unknown, so pairing with it cannot be
* shown to fill the wagon. Same rule as
* {@link findRebookConsolidationCandidates}.
* Deliberately NOT filtered on booking day, unlike
* {@link findComplementaryConsolidationPartner} and
* {@link findRebookConsolidationCandidates}. Those pair bookings that keep the
* dates they already hold, so a mismatched day means two different trains. Here
* both halves are completed together by GL in one shot and
* `completeConsolidatedPair` writes the SAME operator-chosen scheduled_date and
* train to each — see CompleteConsolidatedPairDto — so a partner's stored date
* is about to be overwritten and says nothing about whether it can share the
* wagon. Filtering on it only hid legal partners whose customs clearance
* happened to finish on another day.
*
* The 20ft count comes from the booking's persisted container lines when it
* has them, and otherwise from the accepted booking request that created it.
* That fallback is the normal case here, not an edge case: on a customs
* contract the container lines are written BY completion, so a booking still
* sitting in CLEARANCE_READY — exactly what this list is for — has none yet,
* and its requested quantities are the only statement of what it will carry.
* Reading only the persisted lines left the picker permanently empty.
*
* A booking with neither source is still NOT a candidate: its 20ft count is
* unknown, so pairing with it cannot be shown to fill the wagon.
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
): Promise<Array<{ booking: Booking; ft20Quantity: number }>> {
const qb = this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
@@ -381,30 +398,65 @@ export class BookingsRepository extends BaseRepository<Booking> {
],
});
// Same EAT booking day — the pair shares one physical wagon, so it must
// board one train. Applied only when this booking has a date of its own;
// without one there is no day to match against and route/direction stand
// alone, mirroring findComplementaryConsolidationPartner.
if (booking.scheduledDate) {
qb.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: booking.scheduledDate },
);
}
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
// Odd-20ft test in memory. A booking with no container lines has an unknown
// 20ft count, so it cannot be shown to complete the wagon and is not
// offered.
return rows.filter((row) => {
// Requested 20ft quantities for the rows that carry no persisted cargo yet,
// keyed by booking id. One query for the whole page rather than per row.
const pendingIds = rows
.filter((row) => (row.bookingContainers ?? []).length === 0)
.map((row) => row.id);
const requested = await this.findRequested20ftByBooking(pendingIds);
// Odd-20ft test in memory: two 20ft to a wagon, so odd + odd = whole wagons.
// The resolved count rides along so callers render the same number this
// decision was made on rather than re-deriving it from the empty lines.
const candidates: Array<{ booking: Booking; ft20Quantity: number }> = [];
for (const row of rows) {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return false;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
const ft20 =
lines.length > 0
? lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0)
: requested.get(row.id);
// Neither persisted nor requested cargo — the count is unknown, so this
// booking cannot be shown to fill the wagon.
if (ft20 === undefined) continue;
if (ft20 % 2 !== 1) continue;
candidates.push({ booking: row, ft20Quantity: ft20 });
}
return candidates;
}
/**
* 20ft quantity each of `bookingIds` was requested with, from the accepted
* booking request that created it. Used to judge bookings whose container
* lines are not written yet — on a customs contract that is every booking
* before completion. Bookings with no request are absent from the map, which
* the caller reads as "unknown", not zero.
*/
private async findRequested20ftByBooking(
bookingIds: string[],
): Promise<Map<string, number>> {
const byBooking = new Map<string, number>();
if (bookingIds.length === 0) return byBooking;
const requests = await this.dataSource
.getRepository(BookingRequest)
.createQueryBuilder('r')
.where('r.createdBookingId IN (:...bookingIds)', { bookingIds })
.getMany();
for (const request of requests) {
if (!request.createdBookingId) continue;
// containerSize is free text on the request ('20ft', '20FT'), so parse the
// leading number rather than comparing strings.
const ft20 = (request.requestedLines?.containers ?? [])
.filter((line) => parseInt(String(line.containerSize), 10) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
byBooking.set(request.createdBookingId, ft20);
}
return byBooking;
}
/**

View File

@@ -165,52 +165,34 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
).rejects.toThrow(/cannot be consolidated with itself/i);
});
it('offers only bookings whose own 20ft count is odd', async () => {
// Two odd counts always sum to even, so an odd partner is exactly what fills
// the wagon; an even one would leave the pair partial again.
const rows = [
{
id: 'odd',
reference: 'BK-ODD',
bookingContainers: [
{ quantity: 3, containerType: { sizeFt: 20 } },
],
},
{
id: 'even',
reference: 'BK-EVEN',
bookingContainers: [
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
// shown to fill the wagon and is not offered.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
// Which bookings qualify is the repository's decision (and its own spec's);
// what matters here is that the odd 20ft count it resolved survives into the
// response. A booking awaiting completion has no container lines of its own,
// so re-deriving the count from bookingContainers would report 0 and the
// picker would show every candidate as empty.
it('reports the 20ft count the repository resolved, not the persisted lines', async () => {
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
// Mirror the repository's in-memory odd filter.
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return false;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
return ft20 % 2 === 1;
}),
),
findManualConsolidationCandidates: jest.fn().mockResolvedValue([
{
// Cargo not persisted yet — the count came from its booking request.
booking: {
id: 'odd',
reference: 'BK-2026-001116',
bookingContainers: [],
},
ft20Quantity: 1,
},
]),
},
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates.map((c) => c.reference)).toEqual(['BK-2026-001116']);
expect(candidates[0].ft20Quantity).toBe(1);
expect(candidates[0].hasCargo).toBe(true);
});
});

View File

@@ -660,24 +660,25 @@ export class ContractBookingService {
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
booking,
);
return rows.map((row) => {
const lines = row.bookingContainers ?? [];
return {
id: row.id,
reference: row.reference,
contractId: row.contractId ?? null,
companyName: row.company?.name ?? null,
status: row.status,
tradeDirection: row.tradeDirection ?? null,
originYardId: row.originYardId ?? null,
destinationYardId: row.destinationYardId ?? null,
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
ft20Quantity: lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
hasCargo: lines.length > 0,
};
});
// ft20Quantity comes back from the repository already resolved — persisted
// container lines when the booking has them, otherwise the quantities its
// booking request was accepted with. Recomputing it here from
// bookingContainers would report 0 for every not-yet-completed booking.
return rows.map(({ booking: row, ft20Quantity }) => ({
id: row.id,
reference: row.reference,
contractId: row.contractId ?? null,
companyName: row.company?.name ?? null,
status: row.status,
tradeDirection: row.tradeDirection ?? null,
originYardId: row.originYardId ?? null,
destinationYardId: row.destinationYardId ?? null,
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
ft20Quantity,
// Cargo is known — from either source — since a candidate with an unknown
// count is never offered.
hasCargo: true,
}));
}
/**

View File

@@ -2007,7 +2007,7 @@ export default function GlCreateBookingForm() {
shared wagon — each booking is still priced and invoiced
separately.
</Text>
<Switch
{/* <Switch
checked={consolidateOdd}
color="edr-green"
label="Share a wagon with another booking"
@@ -2015,7 +2015,7 @@ export default function GlCreateBookingForm() {
consolidateTouchedRef.current = true;
setConsolidateOdd(e.currentTarget.checked);
}}
/>
/> */}
{consolidateOdd ? (
<Group gap={10} align="center" wrap="wrap">
<Button

View File

@@ -18,8 +18,11 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, same booking day, customs clearing, an odd 20ft count of their own
* and not already linked to someone else — so every row here is a valid choice.
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice. Booking day is
* deliberately not part of that filter: both halves are completed together onto
* the departure date chosen on this form, so the partner's current date is
* overwritten either way.
*/
interface Props {
opened: boolean;
@@ -55,8 +58,9 @@ export function ConsolidationPartnerPicker({
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route and booking day that also carry
an odd number of 20ft containers.
Customs bookings on the same route that also carry an odd number
of 20ft containers. Both halves ride the departure date you pick on
this form.
</Text>
</Box>
</Group>
@@ -84,10 +88,9 @@ export function ConsolidationPartnerPicker({
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route and booking day currently
carries an odd number of 20ft containers. Either wait for one, or
switch the shared-wagon option off and book an even number of 20ft
containers.
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
</Text>
</Alert>
) : (
@@ -116,6 +119,14 @@ export function ConsolidationPartnerPicker({
{" · "}
{`${candidate.ft20Quantity} × 20ft`}
</Text>
{/* The partner's current date, shown because completing the
pair moves it onto the date chosen on this form. */}
{candidate.scheduledDate ? (
<Text fz={12} c="dimmed" mt={2}>
Currently booked for{" "}
{new Date(candidate.scheduledDate).toLocaleDateString()}
</Text>
) : null}
</Box>
<Button
size="xs"