mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
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:
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user