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

@@ -0,0 +1,132 @@
import { BookingTransitionService } from './booking-transition.service';
import { Booking } from './entities/booking.entity';
/**
* Staff decisions on a consolidated pair. Two bookings sharing a wagon must move
* together: accepting one alone 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, neither booking moved.
*/
describe('BookingTransitionService — paired staff decisions', () => {
function makeService(booking: Partial<Booking>) {
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking as Booking),
};
// Runs the callback so a throw propagates, which is what the all-or-nothing
// guarantee reduces to from this service's point of view.
const dataSource = {
transaction: jest.fn(async (cb: () => Promise<unknown>) => cb()),
};
const service = new BookingTransitionService(
{} as never, // bookingsRepository
{} as never, // ruleEngineService
{} as never, // pricingService
{} as never, // contractService
{} as never, // filesService
{} as never, // fileUploadSettingsService
{} as never, // bookingBatchService
bookingsService as never,
{} as never, // bookingClearanceService
{} as never, // workflowService
{} as never, // invoiceService
{} as never, // containerValidationService
{} as never, // notifier
{} as never, // events
undefined, // milestoneService
dataSource as never,
);
return { service, dataSource };
}
const paired = {
id: 'b-1',
reference: 'BK-1',
consolidationPartnerId: 'b-2',
} as Booking;
it('accepts both halves with the same validity window', async () => {
const { service } = makeService(paired);
const accept = jest
.spyOn(service, 'acceptIntake')
.mockImplementation(async (id) => ({ id }) as Booking);
const result = await service.applyPairedDecision('b-1', 'accept', 'staff-1', {
validityDays: 30,
});
expect(accept).toHaveBeenCalledTimes(2);
expect(accept).toHaveBeenNthCalledWith(1, 'b-1', 'staff-1', 30);
expect(accept).toHaveBeenNthCalledWith(2, 'b-2', 'staff-1', 30);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('cancels both halves with the same reason', async () => {
const { service } = makeService(paired);
const cancel = jest
.spyOn(service, 'cancel')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'cancel', 'staff-1', {
reason: 'customer withdrew',
});
expect(cancel).toHaveBeenNthCalledWith(1, 'b-1', 'customer withdrew');
expect(cancel).toHaveBeenNthCalledWith(2, 'b-2', 'customer withdrew');
});
it('propagates a failure on the second half so neither is committed', async () => {
const { service, dataSource } = makeService(paired);
jest
.spyOn(service, 'cancel')
.mockImplementationOnce(async (id) => ({ id }) as Booking)
.mockImplementationOnce(async () => {
throw new Error('partner is already in transit');
});
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow('partner is already in transit');
// Both halves ran inside one transaction, so the throw rolls the first back.
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
});
it('refuses a booking that has no partner', async () => {
const { service } = makeService({
id: 'b-1',
consolidationPartnerId: null,
} as Booking);
await expect(
service.applyPairedDecision('b-1', 'cancel', 'staff-1', { reason: 'x' }),
).rejects.toThrow(/no consolidation partner/i);
});
it('requires a validity window to accept', async () => {
const { service } = makeService(paired);
const accept = jest.spyOn(service, 'acceptIntake');
await expect(
service.applyPairedDecision('b-1', 'accept', 'staff-1', {}),
).rejects.toThrow(/validity/i);
expect(accept).not.toHaveBeenCalled();
});
it('routes operationAccept through the operation review on both halves', async () => {
const { service } = makeService(paired);
const review = jest
.spyOn(service, 'reviewOperationRequest')
.mockImplementation(async (id) => ({ id }) as Booking);
await service.applyPairedDecision('b-1', 'operationAccept', 'staff-1', {});
expect(review).toHaveBeenNthCalledWith(1, 'b-1', 'ACCEPT', 'staff-1', {
note: undefined,
});
expect(review).toHaveBeenNthCalledWith(2, 'b-2', 'ACCEPT', 'staff-1', {
note: undefined,
});
});
});

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, [

View File

@@ -59,6 +59,7 @@ import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
CancelBookingDto,
PairedDecisionDto,
RejectBookingDto,
RequestChangesDto,
ReviewDocumentDto,
@@ -1541,6 +1542,31 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(":id/paired-decision")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({
summary:
"Apply a staff decision (accept / cancel / operationAccept / requestChanges) to BOTH halves of a consolidated pair, all-or-nothing.",
})
async pairedDecision(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: PairedDecisionDto,
@CurrentUser() user: AuthUserPayload,
) {
const { booking, partner } = await this.transitionService.applyPairedDecision(
id,
dto.decision,
resolveAuthUserId(user),
{ reason: dto.reason, note: dto.note, validityDays: dto.validityDays },
);
// Sequential enrichment: both go back so the UI can refresh either tab.
const enrichedBooking =
await this.transitionService.enrichBookingResponse(booking);
const enrichedPartner =
await this.transitionService.enrichBookingResponse(partner);
return { booking: enrichedBooking, partner: enrichedPartner };
}
@Post(":id/cancel")
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: "Cancel booking" })

View File

@@ -308,6 +308,72 @@ export class BookingsRepository extends BaseRepository<Booking> {
.find({ where: { contractId } });
}
/**
* Bookings a GL operator may manually link to `booking` as its odd-20ft
* consolidation partner (Path B customs flow). Unlike
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
* quantity complement — this lists CANDIDATES for a human to choose from, so
* the filter is deliberately looser: any other customs booking on the same
* route/direction that is itself carrying an odd 20ft count. Two odd counts
* always sum to even, so any pick fills the shared wagon.
*
* Bare instances awaiting completion have no persisted containers yet, so the
* odd-count test runs on the requested container lines when they exist and the
* booking is offered as a candidate when they do not (GL enters its cargo on
* the split form).
*/
async findManualConsolidationCandidates(
booking: Booking,
limit = 50,
): Promise<Booking[]> {
const rows = await this.repository
.createQueryBuilder('b')
.leftJoinAndSelect('b.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('b.company', 'company')
.where('b.id != :bookingId', { bookingId: booking.id })
// Never offer a booking that already shares a wagon with someone else.
.andWhere('b.consolidationPartnerId IS NULL')
// Customs-only: this manual flow exists because a customs (Path B)
// instance is completed by GL, not by the customer.
.andWhere('b.customsClearingEnabled = true')
// Same physical wagon ⇒ same route and same direction.
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
})
.andWhere('b.destinationYardId = :destinationYardId', {
destinationYardId: booking.destinationYardId,
})
.andWhere('b.tradeDirection = :tradeDirection', {
tradeDirection: booking.tradeDirection,
})
// Bookable = clearance finished and the booking is waiting to be completed,
// the same set completeUnderContract accepts, plus one already parked for a
// partner.
.andWhere('b.status IN (:...statuses)', {
statuses: [
'CLEARANCE_READY',
'OPERATION_CHANGES_REQUESTED',
'PENDING_CONSOLIDATION',
],
})
.orderBy('b.createdAt', 'ASC')
.take(limit)
.getMany();
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
// them on the split form) and stays a candidate; one that already carries
// cargo qualifies only when its 20ft total is odd.
return rows.filter((row) => {
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
return ft20 % 2 === 1;
});
}
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
@@ -508,6 +574,25 @@ export class BookingsRepository extends BaseRepository<Booking> {
} as never);
}
/**
* Link two bookings as consolidation partners WITHOUT touching their statuses.
* Used by the manual GL pairing, where both bookings have just been completed
* into their live status — unlike {@link pairConsolidation}, which exists to
* resume bookings parked in PENDING_CONSOLIDATION and rewrites status as part
* of that resume.
*/
async linkConsolidationPartners(
bookingId: string,
partnerId: string,
): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
} as never);
}
/** Un-pair a consolidation. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {

View File

@@ -125,3 +125,36 @@ export class OperationReviewDto {
@IsString()
note?: string;
}
/**
* A staff decision applied to BOTH halves of a consolidated pair. The two
* bookings share a wagon, so they advance or cancel together — never one alone.
*/
export class PairedDecisionDto {
@ApiProperty({
enum: ["accept", "cancel", "operationAccept", "requestChanges"],
description: 'Which staff decision to apply to both bookings.',
})
@IsIn(["accept", "cancel", "operationAccept", "requestChanges"])
decision!: "accept" | "cancel" | "operationAccept" | "requestChanges";
@ApiPropertyOptional({ description: "Cancellation reason (decision=cancel)." })
@IsOptional()
@IsString()
reason?: string;
@ApiPropertyOptional({
description: "Message to the customer (decision=requestChanges).",
})
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: "Contract validity window in days (decision=accept).",
})
@IsOptional()
@IsInt()
@Min(1)
validityDays?: number;
}