Files
edr-platform/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts
Marshal 05b13e84a8 enhance booking and contract notification systems
- Added detailed logging for socket connection events in useBookingWindowSocket.
- Introduced new notification types for contract status and schedule updates.
- Updated notification visuals to include new icons for contract status.
- Enhanced notification href resolution for contract status and schedule updates.
- Implemented booking lifecycle notifier service for customer and staff notifications.
- Created contract notifier service for managing contract lifecycle notifications.
- Added end-to-end tests for booking window socket functionality.
2026-07-06 21:03:08 +00:00

203 lines
6.9 KiB
TypeScript

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 { ContractNotifierService } from './contract-notifier.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,
private readonly notifier: ContractNotifierService,
) {}
/** 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();
const request = await 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);
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
return request;
}
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 seq = await this.repo.maxReferenceSequence();
return `SR-${String(seq + 1).padStart(6, '0')}`;
}
}