mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
feat: Implement consolidated booking functionality
- Added support for viewing and managing consolidated bookings in BookingRequestDetailPage. - Enhanced BookingRequestsPage to display paired bookings in a single row. - Introduced pairedDecision method in bookings service to handle decisions for both halves of a consolidated pair. - Updated contracts service to include methods for manual consolidation of odd-20ft bookings. - Created new components for selecting and editing consolidation partners. - Added tests for paired decision logic and manual consolidation scenarios. - Updated UI to reflect changes in booking handling and provide user feedback for odd container counts.
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import {
|
||||
CompleteConsolidatedPairDto,
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
@@ -62,6 +63,25 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
|
||||
* booking. `hasCargo` is false for a bare instance whose containers GL still has
|
||||
* to enter on the split completion form.
|
||||
*/
|
||||
export interface ConsolidationCandidate {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractId: string | null;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
tradeDirection: string | null;
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
hasCargo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
@@ -598,6 +618,134 @@ export class ContractBookingService {
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate partners a GL operator may link to an odd-20ft customs booking.
|
||||
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
|
||||
* a customs instance is completed by GL, so GL also chooses who shares its
|
||||
* wagon rather than waiting for the auto-matcher to find an exact complement.
|
||||
*/
|
||||
async listConsolidationCandidates(
|
||||
contractId: string,
|
||||
bookingId: string,
|
||||
): Promise<ConsolidationCandidate[]> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||||
if (!booking || booking.contractId !== contractId) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an odd-20ft customs booking together with the partner booking GL
|
||||
* picked for its shared wagon. Both halves run the ordinary
|
||||
* {@link completeUnderContract} machine — same gates, same pricing, same
|
||||
* per-booking invoice, so each customer still pays only its own shipment — and
|
||||
* are linked as consolidation partners at the end.
|
||||
*
|
||||
* All-or-nothing: the two completions plus the pairing run inside one
|
||||
* transaction, so a failure on either half leaves neither booking completed
|
||||
* and no half-linked wagon behind. `runInTransaction` is used rather than a
|
||||
* manual QueryRunner so the nested services join the same transactional
|
||||
* context through the shared DataSource.
|
||||
*/
|
||||
async completeConsolidatedPair(
|
||||
contractId: string,
|
||||
bookingId: string,
|
||||
dto: CompleteConsolidatedPairDto,
|
||||
actorPermissions?: unknown,
|
||||
): Promise<{
|
||||
booking: Booking;
|
||||
partner: Booking;
|
||||
warnings: string[];
|
||||
}> {
|
||||
if (dto.partnerBookingId === bookingId) {
|
||||
throw new BadRequestException(
|
||||
'A booking cannot be consolidated with itself.',
|
||||
);
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findByIdWithFiles(
|
||||
dto.partnerBookingId,
|
||||
);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(
|
||||
`Partner booking ${dto.partnerBookingId} not found`,
|
||||
);
|
||||
}
|
||||
if (partner.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!partner.contractId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
|
||||
);
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
|
||||
const own = await this.completeUnderContract(
|
||||
contractId,
|
||||
bookingId,
|
||||
{ ...dto.booking, skipAutoConsolidation: true },
|
||||
// Both halves are completed by the same GL actor that reached this
|
||||
// endpoint — the customs gate in completeUnderContract re-checks it.
|
||||
actorPermissions,
|
||||
);
|
||||
warnings.push(...own.warnings);
|
||||
|
||||
const other = await this.completeUnderContract(
|
||||
partner.contractId as string,
|
||||
partner.id,
|
||||
{ ...dto.partner, skipAutoConsolidation: true },
|
||||
actorPermissions,
|
||||
);
|
||||
warnings.push(...other.warnings);
|
||||
|
||||
// Link the two halves. Written directly (not via pairConsolidation) because
|
||||
// both bookings have just been completed into their live status here —
|
||||
// pairConsolidation exists to RESUME bookings parked in
|
||||
// PENDING_CONSOLIDATION and would overwrite that status.
|
||||
await this.bookingsRepository.linkConsolidationPartners(
|
||||
own.booking.id,
|
||||
other.booking.id,
|
||||
);
|
||||
return { ownId: own.booking.id, partnerId: other.booking.id };
|
||||
});
|
||||
|
||||
// Sequential reads: one connection per transaction context.
|
||||
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
|
||||
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
return {
|
||||
booking: finalBooking!,
|
||||
partner: finalPartner ?? partner,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a bare initiated booking after its per-booking clearance is
|
||||
* finalized (CLEARANCE_READY) or operations returned it for changes
|
||||
@@ -826,10 +974,18 @@ export class ContractBookingService {
|
||||
// exactly like a drawdown created with cargo does. The shipment day is
|
||||
// stored first so the pairing event can resume straight into the
|
||||
// operations queue.
|
||||
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
|
||||
// their shared wagon by hand through completeConsolidatedPair, so nothing
|
||||
// may claim a partner for them behind GL's back. A customs half completed
|
||||
// as part of a manual pair carries `skipAutoConsolidation`; one completed
|
||||
// alone still falls through to the automatic gate below, so an odd 20ft
|
||||
// booking can never proceed on a partial wagon. Non-customs drawdowns are
|
||||
// unaffected.
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
if (
|
||||
withContainers &&
|
||||
freightType === 'CONTAINER' &&
|
||||
!dto.skipAutoConsolidation &&
|
||||
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
|
||||
) {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
|
||||
Reference in New Issue
Block a user