mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
277 lines
10 KiB
TypeScript
277 lines
10 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,
|
|
) {}
|
|
|
|
/**
|
|
* Shipment requests exist because on a CUSTOMS contract the customer never
|
|
* books directly — GL Ethiopia does it for them. The request is how the
|
|
* customer states what to ship and, now, which currency to be invoiced in.
|
|
*
|
|
* GENERAL: each request opens its own per-booking clearance instance.
|
|
* ONE_TIME: clearance already ran at the contract level, so the request only
|
|
* records the customer's intent; GL creates the single booking from it.
|
|
*/
|
|
private assertCustomsContract(contract: Contract): void {
|
|
if (!contract.customsClearingEnabled) {
|
|
throw new BadRequestException(
|
|
'Shipment requests apply only to customs-clearance contracts.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Statuses in which a ONE_TIME customs contract may take a shipment request:
|
|
* both signatures are in and the contract is at (or past) its clearance
|
|
* phase, but GL has not booked yet.
|
|
*/
|
|
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
|
|
'FULLY_EXECUTED',
|
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
|
'CLEARANCE_UNDER_REVIEW',
|
|
'CLEARANCE_READY_FOR_BOOKING',
|
|
];
|
|
|
|
/** 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.assertCustomsContract(contract);
|
|
const isOneTime = contract.contractKind === 'ONE_TIME';
|
|
|
|
if (contract.status === 'CONTRACT_CLOSED') {
|
|
throw new ConflictException(
|
|
'This contract is completed — the full contracted quantity has been booked.',
|
|
);
|
|
}
|
|
if (isOneTime) {
|
|
if (
|
|
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
|
|
contract.status,
|
|
)
|
|
) {
|
|
throw new ConflictException(
|
|
'The contract must be fully executed before requesting its shipment.',
|
|
);
|
|
}
|
|
// A one-time contract carries exactly one shipment, so it carries at most
|
|
// one open request — otherwise GL sees two conflicting currencies.
|
|
const open = (await this.repo.findForContract(contractId)).find(
|
|
(r) => r.status === 'PENDING',
|
|
);
|
|
if (open) {
|
|
throw new ConflictException(
|
|
`Shipment request ${open.reference} is already open on this contract.`,
|
|
);
|
|
}
|
|
} else 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.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
|
|
// shipment is bounded by the contract scope itself, checked when GL books.
|
|
if (!isOneTime) {
|
|
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,
|
|
},
|
|
};
|
|
|
|
// Clearance-first flow: the request immediately initiates a BARE booking
|
|
// instance (no cargo, no date, no price) that enters per-booking phased
|
|
// customs clearance. GL no longer screens the request up front — it
|
|
// reviews the documents in the clearance queue and completes the booking
|
|
// (container numbers, VGM, shipment day) once clearance is ready. The
|
|
// instance is created first so a failure leaves no half-linked request.
|
|
// GENERAL: the request immediately opens a BARE booking instance that runs
|
|
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
|
|
// the contract, so there is nothing to open — the request stays PENDING
|
|
// until GL creates the contract's single booking from it.
|
|
const booking = isOneTime
|
|
? null
|
|
: await this.contractBookingService.initiateForShipmentRequest(contract, {
|
|
contractRouteId: dto.contractRouteId,
|
|
userId,
|
|
paymentCurrency: dto.paymentCurrency,
|
|
});
|
|
|
|
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: booking ? 'ACCEPTED' : 'PENDING',
|
|
createdBookingId: booking?.id ?? null,
|
|
requestedLines,
|
|
// Intercity is invoiced in birr whatever the customer picked.
|
|
paymentCurrency:
|
|
contract.tradeDirection === 'DOMESTIC'
|
|
? 'ETB'
|
|
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
|
|
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.findQueue();
|
|
}
|
|
|
|
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);
|
|
const contract = await this.contractsService.findById(request.contractId);
|
|
this.notifier.shipmentRequestRejected(contract, request.reference, note);
|
|
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')}`;
|
|
}
|
|
}
|