mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 04:45:03 +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:
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { CreateBookingRequestDto } from './dto/create-booking-request.dto';
|
||||
|
||||
/**
|
||||
* Customer shipment requests on GENERAL customs (Path B) contracts. The customer
|
||||
* submits a request (date + quantities); GL reviews the queue and, on accept,
|
||||
* creates the booking — after which per-booking clearance begins. ONE_TIME and
|
||||
* Path A do not use this flow.
|
||||
*/
|
||||
@Injectable()
|
||||
export class BookingRequestService {
|
||||
constructor(
|
||||
private readonly repo: BookingRequestRepository,
|
||||
private readonly contractsService: ContractsService,
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
) {}
|
||||
|
||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||
private assertGeneralCustoms(contract: Contract): void {
|
||||
if (
|
||||
contract.contractKind !== 'GENERAL' ||
|
||||
!contract.customsClearingEnabled
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Shipment requests apply only to general customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Customer submits a shipment request. */
|
||||
async submit(
|
||||
contractId: string,
|
||||
dto: CreateBookingRequestDto,
|
||||
userId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||
this.assertGeneralCustoms(contract);
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
);
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const hasLines = isContainer
|
||||
? (dto.containers?.length ?? 0) > 0
|
||||
: Boolean(dto.bulk);
|
||||
if (!hasLines) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? 'Add at least one container line.'
|
||||
: 'Enter the bulk cargo amount.',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate requested container sizes against the contract cargo scope and
|
||||
// remaining draw-down capacity (reuses the booking quantity-cap check).
|
||||
if (isContainer) {
|
||||
const allowed = new Set(
|
||||
(contract.cargoScope ?? [])
|
||||
.map((s) => s.containerSize)
|
||||
.filter((s): s is string => !!s),
|
||||
);
|
||||
for (const line of dto.containers ?? []) {
|
||||
if (allowed.size && !allowed.has(line.containerSize)) {
|
||||
throw new BadRequestException(
|
||||
`Container size ${line.containerSize} is not in this contract's scope.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
|
||||
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||
? {
|
||||
containers: (dto.containers ?? []).map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.quantity,
|
||||
hazardousQuantity: l.hazardousQuantity,
|
||||
reeferQuantity: l.reeferQuantity,
|
||||
})),
|
||||
}
|
||||
: {
|
||||
bulk: {
|
||||
cargoTypeId: dto.bulk?.cargoTypeId ?? null,
|
||||
cargoWeightTons: dto.bulk?.cargoWeightTons,
|
||||
itemCount: dto.bulk?.itemCount,
|
||||
hazardousQuantity: dto.bulk?.hazardousQuantity,
|
||||
},
|
||||
};
|
||||
|
||||
const reference = await this.generateReference();
|
||||
return this.repo.create({
|
||||
reference,
|
||||
contractId,
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: 'PENDING',
|
||||
requestedLines,
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
}
|
||||
|
||||
listForContract(contractId: string): Promise<BookingRequest[]> {
|
||||
return this.repo.findForContract(contractId);
|
||||
}
|
||||
|
||||
async findOne(requestId: string): Promise<BookingRequest> {
|
||||
const request = await this.repo.findById(requestId);
|
||||
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||
return request;
|
||||
}
|
||||
|
||||
queue(): Promise<BookingRequest[]> {
|
||||
return this.repo.findPending();
|
||||
}
|
||||
|
||||
private async findPending(requestId: string): Promise<BookingRequest> {
|
||||
const request = await this.repo.findById(requestId);
|
||||
if (!request) throw new NotFoundException(`Booking request ${requestId} not found`);
|
||||
if (request.status !== 'PENDING') {
|
||||
throw new ConflictException(
|
||||
`This request is already ${request.status.toLowerCase()}.`,
|
||||
);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL accepts a request. The booking itself is created via the GL booking form
|
||||
* (POST /contracts/:id/bookings) which carries the per-unit container data the
|
||||
* request omits; this endpoint records the acceptance + links the created
|
||||
* booking. `bookingId` is supplied by the GL form on success.
|
||||
*/
|
||||
async accept(
|
||||
requestId: string,
|
||||
bookingId: string,
|
||||
staffId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
await this.repo.update(requestId, {
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: bookingId,
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
/** GL rejects a request with a note. */
|
||||
async reject(
|
||||
requestId: string,
|
||||
note?: string,
|
||||
staffId?: string,
|
||||
): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
await this.repo.update(requestId, {
|
||||
status: 'REJECTED',
|
||||
reviewNote: note ?? null,
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
/** Customer cancels their own pending request. */
|
||||
async cancel(requestId: string, userId?: string): Promise<BookingRequest> {
|
||||
const request = await this.findPending(requestId);
|
||||
if (request.requestedByUserId && request.requestedByUserId !== userId) {
|
||||
throw new ForbiddenException('You can only cancel your own requests.');
|
||||
}
|
||||
await this.repo.update(requestId, { status: 'CANCELLED' } as never);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const count = await this.repo.count();
|
||||
const seq = String(count + 1).padStart(6, '0');
|
||||
return `SR-${seq}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user