add booking request functionality for GENERAL customs contracts

- Create migration for booking_requests table with necessary fields and indexes.
- Implement BookingRequestRepository for database operations related to booking requests.
- Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests.
- Create DTOs for creating booking requests and reviewing them.
- Define BookingRequest entity to map to the booking_requests table.
- Add UI components for managing shipment requests, including detail and list pages.
- Implement OperationDatePicker component for selecting available shipment days.
This commit is contained in:
Marshal
2026-06-29 09:30:44 +00:00
parent aeb5e0046e
commit 0f7cac2b68
46 changed files with 2665 additions and 992 deletions

View File

@@ -6,7 +6,6 @@ import { BookingTransitionService } from './booking-transition.service';
* - ACCEPT a train order → FULLY_EXECUTED and enqueued into the batch pool.
* - ACCEPT a road order → ROAD_DISPATCH_PENDING, NOT enqueued.
* - REQUEST_CHANGES requires a note → OPERATION_CHANGES_REQUESTED.
* - ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM.
*/
describe('BookingTransitionService — operation review', () => {
function makeService(serviceTypeCode: string) {
@@ -78,18 +77,4 @@ describe('BookingTransitionService — operation review', () => {
expect.objectContaining({ status: 'OPERATION_CHANGES_REQUESTED' }),
);
});
it('ADJUST_PRICE sets the adjusted total → OPERATION_PRICE_PENDING_CONFIRM', async () => {
const { service, bookingsRepository } = makeService('RAIL_CONTAINER');
await service.reviewOperationRequest('b-1', 'ADJUST_PRICE', 'staff-1', {
amount: 1500,
});
expect(bookingsRepository.update).toHaveBeenCalledWith(
'b-1',
expect.objectContaining({
adjustedTotalAmount: 1500,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
}),
);
});
});

View File

@@ -477,30 +477,6 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/**
* Staff adjusts a booking's total price. Stores an override (with who/when/why)
* that supersedes the computed total for the customer, who sees an
* "Adjusted by EDR" badge. Passing null clears the adjustment.
*/
async adjustPrice(
bookingId: string,
amount: number | null,
staffId: string,
reason?: string,
): Promise<Booking> {
await this.bookingsService.findById(bookingId);
if (amount != null && amount < 0) {
throw new BadRequestException('Adjusted amount cannot be negative');
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: amount,
adjustedByStaffId: amount == null ? null : staffId,
adjustedAt: amount == null ? null : new Date(),
adjustmentReason: amount == null ? null : (reason ?? null),
} as never);
return this.bookingsService.findById(bookingId);
}
// ── Document clearance gate (post counter-sign) ───────────────────────────
/**
@@ -861,17 +837,17 @@ export class BookingTransitionService {
/**
* Operations team reviews a pending operation request (capacity, documents,
* route). Three outcomes:
* route). Two outcomes:
* - ACCEPT → booking enters the batch holding pool (FULLY_EXECUTED).
* - REQUEST_CHANGES → returned to the customer with a note to fix and resubmit.
* - ADJUST_PRICE → a new total is set; the customer must re-confirm it
* before the booking can enter the pool.
*
* The booking price is computed from the contract and is never adjusted here.
*/
async reviewOperationRequest(
bookingId: string,
decision: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE',
decision: 'ACCEPT' | 'REQUEST_CHANGES',
actorId: string,
options: { note?: string; amount?: number } = {},
options: { note?: string } = {},
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_REQUEST_PENDING']);
@@ -894,48 +870,10 @@ export class BookingTransitionService {
return this.bookingsService.findById(bookingId);
}
if (decision === 'ADJUST_PRICE') {
if (options.amount == null || options.amount < 0) {
throw new BadRequestException(
'A non-negative adjusted amount is required to adjust the price',
);
}
await this.bookingsRepository.update(bookingId, {
adjustedTotalAmount: options.amount,
adjustedByStaffId: actorId,
adjustedAt: new Date(),
adjustmentReason: options.note ?? null,
status: 'OPERATION_PRICE_PENDING_CONFIRM',
} as never);
return this.bookingsService.findById(bookingId);
}
// ACCEPT — enter the batch holding pool.
return this.acceptOperationRequest(booking);
}
/**
* Customer re-confirms (or rejects) an operations price adjustment. Accepting
* pushes the booking into the pool; rejecting returns it to the customer as an
* operation change request so they can resubmit or cancel.
*/
async confirmOperationPrice(
bookingId: string,
accept: boolean,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['OPERATION_PRICE_PENDING_CONFIRM']);
if (!accept) {
await this.bookingsRepository.update(bookingId, {
status: 'OPERATION_CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(bookingId);
}
return this.acceptOperationRequest(booking);
}
/**
* Move a reviewed operation request forward after Marketing accepts.
*

View File

@@ -43,7 +43,6 @@ import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
AcceptIntakeDto,
AdjustPriceDto,
ApproveStepDto,
CancelBookingDto,
RejectBookingDto,
@@ -52,7 +51,6 @@ import {
ReviewDocumentDto,
RequestOperationDto,
OperationReviewDto,
ConfirmOperationPriceDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
@@ -404,24 +402,7 @@ export class BookingsController {
id,
dto.decision,
resolveAuthUserId(user),
{ note: dto.note, amount: dto.amount },
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operation/confirm-price')
@ApiOperation({
summary:
'Customer confirms or rejects an operations price adjustment ' +
'(OPERATION_PRICE_PENDING_CONFIRM → batch pool | OPERATION_CHANGES_REQUESTED)',
})
async confirmOperationPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ConfirmOperationPriceDto,
) {
const booking = await this.transitionService.confirmOperationPrice(
id,
dto.accept,
{ note: dto.note },
);
return this.transitionService.enrichBookingResponse(booking);
}
@@ -521,25 +502,6 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/adjust-price')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({
summary: 'Staff adjust booking total price (override; null clears it)',
})
async adjustPrice(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AdjustPriceDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.adjustPrice(
id,
dto.amount ?? null,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })

View File

@@ -53,7 +53,14 @@ export function clearanceCodesForBooking(booking: Booking): {
outputCode: string | null;
includesCustoms: boolean;
} {
const includesCustoms = booking.serviceType?.includesCustoms ?? false;
// Customs applies when EITHER the service type bundles it OR the booking was
// created with customsClearingEnabled (copied from the contract). Contract
// bookings carry customsClearingEnabled even when the serviceType relation
// isn't loaded / has includesCustoms=false — without this the per-booking
// clearance grid would resolve empty.
const includesCustoms =
Boolean(booking.serviceType?.includesCustoms) ||
Boolean(booking.customsClearingEnabled);
return {
inputCode: clearanceSettingCode(
booking.tradeDirection,

View File

@@ -1,10 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsBoolean,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
Max,
@@ -70,22 +68,6 @@ export class RejectBookingDto {
reason?: string;
}
export class AdjustPriceDto {
@ApiPropertyOptional({
description:
'New total price. Omit or send null to clear a previous adjustment.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number | null;
@ApiPropertyOptional({ description: 'Reason for the adjustment' })
@IsOptional()
@IsString()
reason?: string;
}
export class ReviewDocumentDto {
@ApiProperty({ description: 'The document fileKey being reviewed' })
@IsString()
@@ -117,12 +99,12 @@ export class OperationReviewDto {
@ApiProperty({
description:
'The operations decision: ACCEPT enters the batch pool; REQUEST_CHANGES ' +
'returns it to the customer with a note; ADJUST_PRICE sets a new total the ' +
'customer must re-confirm before it proceeds.',
enum: ['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'],
'returns it to the customer with a note. The booking price is computed ' +
'from the contract and cannot be adjusted by staff.',
enum: ['ACCEPT', 'REQUEST_CHANGES'],
})
@IsIn(['ACCEPT', 'REQUEST_CHANGES', 'ADJUST_PRICE'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES' | 'ADJUST_PRICE';
@IsIn(['ACCEPT', 'REQUEST_CHANGES'])
decision!: 'ACCEPT' | 'REQUEST_CHANGES';
@ApiPropertyOptional({
description: 'Required for REQUEST_CHANGES (what the customer must fix).',
@@ -130,22 +112,4 @@ export class OperationReviewDto {
@IsOptional()
@IsString()
note?: string;
@ApiPropertyOptional({
description: 'New total price — required for ADJUST_PRICE.',
})
@IsOptional()
@IsNumber()
@Min(0)
amount?: number;
}
export class ConfirmOperationPriceDto {
@ApiProperty({
description:
'true to accept the operations price adjustment and proceed to the ' +
'batch pool; false to reject it (returns to operation changes requested).',
})
@IsBoolean()
accept!: boolean;
}