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,
}));
}
/**