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:
Marshal
2026-08-18 12:50:35 +00:00
parent c723b660e2
commit 22a3fb98ee
25 changed files with 2121 additions and 34 deletions

View File

@@ -455,6 +455,75 @@ export class BookingTransitionService {
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
}
/**
* Run a staff decision across BOTH halves of a consolidated pair.
*
* Two bookings that share a wagon must move together: accepting one while the
* other stays behind would put half a wagon into the approval chain, and
* cancelling one alone would strand the other on a wagon it can no longer
* fill. All-or-nothing — if either half throws, the transaction rolls back and
* neither booking moved.
*
* Each half still runs the ordinary single-booking transition, so pricing,
* invoicing and notifications stay per booking: the customers are billed and
* notified separately, exactly as they are today.
*/
async applyPairedDecision(
bookingId: string,
decision: "accept" | "cancel" | "operationAccept" | "requestChanges",
actorId: string,
options: { reason?: string; note?: string; validityDays?: number } = {},
): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.bookingsService.findById(bookingId);
const partnerId = booking.consolidationPartnerId;
if (!partnerId) {
throw new BadRequestException(
"This booking has no consolidation partner — use the single-booking action.",
);
}
const runOne = async (id: string): Promise<Booking> => {
switch (decision) {
case "accept":
// Same requirement as the single-booking accept: the approval chain
// needs a contract validity window.
if (!(Number(options.validityDays) > 0)) {
throw new BadRequestException(
"Contract validity (days) is required to accept.",
);
}
return this.acceptIntake(id, actorId, Number(options.validityDays));
case "cancel":
return this.cancel(
id,
options.reason ?? "Cancelled with its consolidation partner",
);
case "operationAccept":
return this.reviewOperationRequest(id, "ACCEPT", actorId, {
note: options.note,
});
case "requestChanges":
return this.requestChanges(id, options.note ?? "", actorId);
}
};
// Without a DataSource (unit tests hand-construct this service) fall back to
// running the two halves directly — the ordering guarantee still holds, only
// the rollback does not.
if (!this.dataSource) {
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
}
return this.dataSource.transaction(async () => {
// Sequential: one connection per transaction context.
const own = await runOne(bookingId);
const other = await runOne(partnerId);
return { booking: own, partner: other };
});
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [