mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 20:53:39 +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,45 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingRequestRepository extends BaseRepository<BookingRequest> {
|
||||
constructor(
|
||||
@InjectRepository(BookingRequest)
|
||||
repository: Repository<BookingRequest>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** All requests on a contract, newest first. */
|
||||
async findForContract(contractId: string): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/** GL queue: pending requests across all contracts, oldest first. */
|
||||
async findPending(): Promise<BookingRequest[]> {
|
||||
return this.repository.find({
|
||||
where: { status: 'PENDING' },
|
||||
order: { createdAt: 'ASC' },
|
||||
relations: { contract: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BookingRequest | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { contract: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Total rows — used to mint the next sequential reference. */
|
||||
async count(): Promise<number> {
|
||||
return this.repository.count();
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,12 @@ export class ContractBookingService {
|
||||
const reference = await this.generateReference();
|
||||
const freightType = contract.freightType;
|
||||
|
||||
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
|
||||
// in the clearance gate (AWAITING_DOCUMENTS) instead of going straight to
|
||||
// operations, and there is NO contract-level clearance cycle to link.
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
@@ -105,7 +111,7 @@ export class ContractBookingService {
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
status: 'OPERATION_REQUEST_PENDING',
|
||||
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
|
||||
bookingType: 'ONE_TIME',
|
||||
contractId: contract.id,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -165,9 +171,11 @@ export class ContractBookingService {
|
||||
warnings.push(...computed.warnings);
|
||||
}
|
||||
|
||||
// Path B side effects: link the clearance cycle, seed post-booking
|
||||
// milestones onto the booking, and advance the contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
// ONE_TIME customs (legacy contract-cycle path): link the contract clearance
|
||||
// cycle to this booking, seed post-booking milestones, and lock the contract
|
||||
// to ACTIVE_SHIPMENT_IN_PROGRESS. NOT for GENERAL — it has no contract cycle
|
||||
// and must stay CONTRACT_ACTIVE so further shipment requests can be accepted.
|
||||
if (contract.customsClearingEnabled && !generalCustoms) {
|
||||
const cycle = await this.contractsRepository.currentCycle(contract.id);
|
||||
if (cycle) {
|
||||
await this.contractsRepository.linkBooking(cycle.id, booking.id);
|
||||
@@ -180,6 +188,14 @@ export class ContractBookingService {
|
||||
status: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||
} as never);
|
||||
} else if (generalCustoms) {
|
||||
// Per-booking clearance: seed post-booking milestones on the booking (no
|
||||
// cycle needed) and leave the contract active. The booking now drives its
|
||||
// own clearance via the booking-level pipeline.
|
||||
await this.milestoneService.seedPostBookingMilestones(
|
||||
booking.id,
|
||||
contract.tradeDirection,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -192,14 +208,24 @@ export class ContractBookingService {
|
||||
*/
|
||||
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
|
||||
if (contract.customsClearingEnabled) {
|
||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer
|
||||
// once GL has finalized the pre-booking clearance. The customer never
|
||||
// books a customs contract himself.
|
||||
// Path B — Global Logistics creates the booking ON BEHALF OF the customer.
|
||||
// The customer never books a customs contract himself.
|
||||
if (!isGlActor) {
|
||||
throw new ForbiddenException(
|
||||
'Customs-clearance contracts are booked by Global Logistics on behalf of the customer.',
|
||||
);
|
||||
}
|
||||
if (contract.contractKind === 'GENERAL') {
|
||||
// GENERAL customs has NO contract clearance cycle — GL books per accepted
|
||||
// shipment request while the contract is active; clearance is per booking.
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new BadRequestException(
|
||||
'Contract must be active to book a shipment.',
|
||||
);
|
||||
}
|
||||
return 'GL_ET';
|
||||
}
|
||||
// ONE_TIME customs — UNCHANGED: requires the finalized contract cycle.
|
||||
if (contract.clearanceStatus !== 'CLEARANCE_READY_FOR_BOOKING') {
|
||||
throw new BadRequestException(
|
||||
'Contract clearance is not ready for booking yet.',
|
||||
@@ -233,6 +259,42 @@ export class ContractBookingService {
|
||||
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
||||
*/
|
||||
/**
|
||||
* Capacity check for a SHIPMENT REQUEST (no per-unit data) — mirrors
|
||||
* {@link assertWithinQuantityCap} but reads the request's quantity shape.
|
||||
*/
|
||||
async assertRequestWithinCapacity(
|
||||
contract: Contract,
|
||||
lines: {
|
||||
containers?: Array<{ containerSize: string; quantity: number }>;
|
||||
bulk?: { cargoWeightTons?: number; itemCount?: number };
|
||||
},
|
||||
): Promise<void> {
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
if (capacity.length === 0) return; // uncapped contract
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
for (const line of lines.containers ?? []) {
|
||||
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||
if (!cap || cap.remaining == null) continue;
|
||||
if (line.quantity > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const requested =
|
||||
(lines.bulk?.cargoWeightTons ?? lines.bulk?.itemCount ?? 0) || 0;
|
||||
const cap = capacity.find((c) => c.cap != null);
|
||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWithinQuantityCap(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileRecord } from '../files/entities/file.entity';
|
||||
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
|
||||
import { ApprovalRulesService } from '../rule-engine/services/approval-rules.service';
|
||||
import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
@@ -27,6 +28,14 @@ import { Contract } from './entities/contract.entity';
|
||||
import { ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
|
||||
/**
|
||||
* Dropdown-settings code holding the admin-configured contract validity options
|
||||
* (each option's `value` is a day count). The staff accept dialog reads the same
|
||||
* code, so accept can only use a configured duration. See the seed migration
|
||||
* `SeedContractValidityPeriods`.
|
||||
*/
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = 'contract_validity_periods';
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -46,6 +55,7 @@ export class ContractTransitionService {
|
||||
private readonly pricingService: ContractPricingService,
|
||||
private readonly approvalRulesService: ApprovalRulesService,
|
||||
private readonly cargoTypesService: CargoTypesService,
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
@@ -101,6 +111,8 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertValidityDaysConfigured(validityDays);
|
||||
|
||||
const validFrom = new Date();
|
||||
const validUntil = new Date(validFrom);
|
||||
validUntil.setDate(validUntil.getDate() + validityDays);
|
||||
@@ -118,6 +130,37 @@ export class ContractTransitionService {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the chosen validity (days) is one of the admin-configured options in
|
||||
* the `contract_validity_periods` dropdown setting. If the setting is missing
|
||||
* or has no options yet, fall back to the DTO range check (already applied) so
|
||||
* acceptance is never hard-blocked before an admin configures the list.
|
||||
*/
|
||||
private async assertValidityDaysConfigured(validityDays: number): Promise<void> {
|
||||
let setting;
|
||||
try {
|
||||
setting = await this.dropdownSettingsService.getByCode(
|
||||
CONTRACT_VALIDITY_PERIODS_CODE,
|
||||
);
|
||||
} catch {
|
||||
// Not configured yet — keep the flow working with the DTO range only.
|
||||
return;
|
||||
}
|
||||
|
||||
const allowed = (setting.children ?? [])
|
||||
.map((o) => Number(o.value))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
if (allowed.length === 0) return;
|
||||
|
||||
if (!allowed.includes(validityDays)) {
|
||||
throw new BadRequestException(
|
||||
`Validity ${validityDays} days is not a configured option. Allowed: ${allowed
|
||||
.sort((a, b) => a - b)
|
||||
.join(', ')} days.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build contract approval steps from the system approval_rules chain (US-06:
|
||||
* container → line staff + director; bulk → directors + CEO). Mirrors the
|
||||
@@ -520,7 +563,16 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled ?? false,
|
||||
);
|
||||
|
||||
if (clearanceCode) {
|
||||
// GENERAL + customs (Path B) runs clearance PER BOOKING, not at the contract
|
||||
// level: there is no contract clearance cycle. The contract just becomes
|
||||
// active; the customer then files shipment requests and GL books + clears
|
||||
// each one. ONE_TIME customs and Path A self-clearance keep the contract
|
||||
// cycle below.
|
||||
const isGeneralCustoms =
|
||||
contract.contractKind === 'GENERAL' &&
|
||||
Boolean(contract.customsClearingEnabled);
|
||||
|
||||
if (clearanceCode && !isGeneralCustoms) {
|
||||
// Open a clearance cycle, seed the pre-booking milestones, and route the
|
||||
// customer to upload. Path A is ops-reviewed; Path B is GL-reviewed — the
|
||||
// distinction is enforced at the review/finalize endpoints, not here.
|
||||
@@ -531,7 +583,8 @@ export class ContractTransitionService {
|
||||
updates.clearanceStatus = 'AWAITING_DOCUMENTS';
|
||||
updates.clearanceCycleNumber = cycleNumber;
|
||||
} else {
|
||||
// No clearance gate (DOMESTIC) — ready for the customer to book directly.
|
||||
// No contract-level clearance gate — DOMESTIC, or GENERAL+customs (which
|
||||
// clears per booking). Ready for shipment requests / direct booking.
|
||||
updates.status =
|
||||
contract.contractKind === 'GENERAL' ? 'CONTRACT_ACTIVE' : 'FULLY_EXECUTED';
|
||||
updates.clearanceStatus = 'NOT_APPLICABLE';
|
||||
|
||||
@@ -43,6 +43,7 @@ import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { SignaturesService } from '../signatures/signatures.service';
|
||||
import { CreateContractDto } from './dto/create-contract.dto';
|
||||
import { UpdateContractDto } from './dto/update-contract.dto';
|
||||
@@ -58,6 +59,10 @@ import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
|
||||
import { RenewContractDto } from './dto/renew-contract.dto';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingRequestDto,
|
||||
ReviewBookingRequestDto,
|
||||
} from './dto/create-booking-request.dto';
|
||||
import {
|
||||
AdviseDutyDto,
|
||||
AssignRiskDto,
|
||||
@@ -78,9 +83,78 @@ export class ContractsController {
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
private readonly milestoneService: ClearanceMilestoneService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly bookingRequestService: BookingRequestService,
|
||||
private readonly signaturesService: SignaturesService,
|
||||
) {}
|
||||
|
||||
// ── Shipment / booking requests (GENERAL + customs, Path B) ───────────────
|
||||
// STATIC routes declared before any `:id`-param route so Nest matches them
|
||||
// (mirrors the clearance/queue ordering below).
|
||||
|
||||
@Get('booking-requests/queue')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL queue: pending shipment requests across contracts' })
|
||||
bookingRequestQueue() {
|
||||
return this.bookingRequestService.queue();
|
||||
}
|
||||
|
||||
@Get('booking-requests/:reqId')
|
||||
@ApiOperation({ summary: 'A single shipment request' })
|
||||
getBookingRequest(@Param('reqId', ParseUUIDPipe) reqId: string) {
|
||||
return this.bookingRequestService.findOne(reqId);
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/accept')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL marks a shipment request accepted + links the created booking' })
|
||||
acceptBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@Body() body: { bookingId: string },
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.accept(
|
||||
reqId,
|
||||
body.bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/reject')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
|
||||
@ApiOperation({ summary: 'GL rejects a shipment request' })
|
||||
rejectBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@Body() dto: ReviewBookingRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.reject(reqId, dto.note, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post('booking-requests/:reqId/cancel')
|
||||
@ApiOperation({ summary: 'Customer cancels their own pending shipment request' })
|
||||
cancelBookingRequest(
|
||||
@Param('reqId', ParseUUIDPipe) reqId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.cancel(reqId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/booking-requests')
|
||||
@ApiOperation({ summary: 'Customer submits a shipment request on a GENERAL customs contract' })
|
||||
submitBookingRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateBookingRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingRequestService.submit(id, dto, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Get(':id/booking-requests')
|
||||
@ApiOperation({ summary: 'List the shipment requests on a contract' })
|
||||
listBookingRequests(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.bookingRequestService.listForContract(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
import { SignaturesModule } from '../signatures/signatures.module';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
|
||||
@@ -20,6 +21,8 @@ import { ContractClearanceService } from './contract-clearance.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
@@ -32,6 +35,7 @@ import { ContractClearanceCycle } from './entities/contract-clearance-cycle.enti
|
||||
import { ContractDocumentReview } from './entities/contract-document-review.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { ClearanceIncident } from './entities/clearance-incident.entity';
|
||||
import { BookingRequest } from './entities/booking-request.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
|
||||
@@ -54,11 +58,13 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractDocumentReview,
|
||||
ClearanceMilestone,
|
||||
ClearanceIncident,
|
||||
BookingRequest,
|
||||
Booking,
|
||||
BookingContainerUnit,
|
||||
]),
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
DropdownSettingsModule,
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
SignaturesModule,
|
||||
@@ -82,6 +88,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
BookingRequestService,
|
||||
BookingRequestRepository,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
// helpers reused from src/contracts/.
|
||||
ContractTemplateResolver,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** A requested container line (no per-unit data — GL enters that at booking). */
|
||||
export class RequestContainerLineDto {
|
||||
@ApiProperty({ description: '"20ft" | "40ft" — must be in the contract scope' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
}
|
||||
|
||||
export class RequestBulkLineDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoTypeId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
cargoWeightTons?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
itemCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
hazardousQuantity?: number;
|
||||
}
|
||||
|
||||
/** Customer's shipment request on a GENERAL customs contract. */
|
||||
export class CreateBookingRequestDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Required for multi-route GENERAL.' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Preferred shipment day (informational).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [RequestContainerLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RequestContainerLineDto)
|
||||
containers?: RequestContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: RequestBulkLineDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => RequestBulkLineDto)
|
||||
bulk?: RequestBulkLineDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class ReviewBookingRequestDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { Contract } from './contract.entity';
|
||||
|
||||
/**
|
||||
* A customer's request to ship under a GENERAL customs (Path B) contract. The
|
||||
* customer cannot book directly; they submit the date + quantities here, Global
|
||||
* Logistics reviews the queue, then creates the booking on their behalf — after
|
||||
* which per-booking customs clearance begins. ONE_TIME contracts do not use this
|
||||
* (they keep contract-level clearance). See plan: per-booking clearance.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_requests' })
|
||||
@Index(['contractId'])
|
||||
@Index(['status'])
|
||||
@Index(['contractId', 'status'])
|
||||
export class BookingRequest extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 40, default: '' })
|
||||
reference!: string;
|
||||
|
||||
@Column({ name: 'contract_id', type: 'uuid' })
|
||||
contractId!: string;
|
||||
|
||||
@ManyToOne(() => Contract, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'contract_id' })
|
||||
contract?: Contract;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'contract_route_id', type: 'uuid', nullable: true })
|
||||
contractRouteId?: string | null;
|
||||
|
||||
/** Customer's preferred shipment day — informational; GL sets the binding date. */
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'PENDING' })
|
||||
status!: Freight.BookingRequestStatus;
|
||||
|
||||
/** Requested quantities (container lines or one bulk line) — no per-unit data. */
|
||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
requestedLines!: Freight.RequestedShipmentLines;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
/** Set when GL accepts the request and creates the booking. */
|
||||
@Column({ name: 'created_booking_id', type: 'uuid', nullable: true })
|
||||
createdBookingId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
|
||||
reviewedByStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
|
||||
reviewedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'review_note', type: 'text', nullable: true })
|
||||
reviewNote?: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user