mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, [
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user