mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17:43:39 +00:00
Merge pull request #1490 from Tria-plc/freight_feature/usermanagement
feat: implement manual consolidation candidate selection logic and up…
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user