Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts

583 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
import { ContractsRepository } from './contracts.repository';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
}
/**
* The single create path for shipment bookings under a contract.
*
* - Path A (transport only): the customer creates the booking once the contract
* is FULLY_EXECUTED / CONTRACT_ACTIVE and customs is NOT bundled.
* - Path B (customs clearance): only GL Ethiopia creates the booking, once the
* contract reaches CLEARANCE_READY_FOR_BOOKING; the customer never enters
* shipment data.
*
* From booking creation onward the existing batch/payment/allocation pipeline
* runs unchanged. See docs/new-doc.md §8, §13.
*/
@Injectable()
export class ContractBookingService {
private readonly logger = new Logger(ContractBookingService.name);
constructor(
private readonly contractsRepository: ContractsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingPricingService: BookingPricingService,
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
) {}
async createUnderContract(
contractId: string,
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
// GL Ethiopia is identified by the dedicated contract create-booking permission
// (granted to the edr_gl_ethiopia preset).
const isGlActor =
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
const createdByRole = await this.assertGate(contract, isGlActor);
// Validity window must still be open.
if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) {
throw new BadRequestException('Contract validity has expired — no new bookings.');
}
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
// letting the customer re-book within contract validity (doc §10.4).
if (contract.contractKind === 'ONE_TIME') {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
} else {
// GENERAL: draw down against the cargo quantity cap until it is full.
await this.assertWithinQuantityCap(contract, dto);
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
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,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
status: generalCustoms ? 'AWAITING_DOCUMENTS' : 'OPERATION_REQUEST_PENDING',
bookingType: 'ONE_TIME',
contractId: contract.id,
contractRouteId: route?.id ?? null,
contractKind: contract.contractKind,
createdByRole,
createdByUserId: user?.id ?? null,
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
contractType: 'NEW',
customsClearingEnabled: contract.customsClearingEnabled,
customsClearingAgent: contract.customsClearingAgent ?? null,
equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN',
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
firstMilePickupLng: contract.firstMilePickupLng ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
await this.persistContainers(booking.id, contract, dto);
}
// Reload with containers to compute the total from contract unit rates × qty.
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (loaded) {
if (freightType === 'CONTAINER') {
await this.applyWeightResults(loaded);
}
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
await this.bookingsRepository.update(booking.id, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
booking.id,
computed.usedRates,
computed.appliedModifiers,
);
warnings.push(...computed.warnings);
}
// 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);
}
await this.milestoneService.seedPostBookingMilestones(
booking.id,
contract.tradeDirection,
);
await this.contractsRepository.update(contract.id, {
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);
// Contract bookings are born past the billable gate (the contract is already
// executed), so the invoice is generated here — they never pass through the
// legacy marketingApprove → FULLY_EXECUTED path that invoices direct bookings.
// Idempotent and non-blocking: a billing hiccup must not undo the booking.
// Skips silently when unbillable (no company / no priced amount).
await this.invoiceService
.ensureInvoiceForBooking(result ?? booking)
.catch((err) =>
this.logger.error(
`Failed to generate invoice for contract booking ${booking.reference}: ${
err instanceof Error ? err.message : String(err)
}`,
),
);
return { booking: result ?? booking, warnings };
}
/**
* Returns the role to stamp on the booking, or throws if the caller is not
* allowed to create one for this contract's execution path.
*/
private async assertGate(contract: Contract, isGlActor: boolean): Promise<string> {
if (contract.customsClearingEnabled) {
// 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.',
);
}
return 'GL_ET';
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
}
return isGlActor ? 'STAFF' : 'CUSTOMER';
}
private async countActiveBookings(contractId: string): Promise<number> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', { terminal: TERMINAL_BOOKING_STATUSES })
.getCount();
}
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/**
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
* 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,
): Promise<void> {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped contract
if (contract.freightType === 'CONTAINER') {
for (const line of dto.containers ?? []) {
const cap = capacity.find((c) => c.containerSize === line.containerSize);
if (!cap || cap.remaining == null) continue; // size uncapped
if (line.quantity > cap.remaining) {
throw new BadRequestException(
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
);
}
}
} else {
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 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.`,
);
}
}
}
/**
* Remaining bookable quantity per cargo-scope line: cap minus what prior
* bookings already consumed. Returns [] when the contract has no caps.
*/
async computeCapacity(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const scope = contract.cargoScope ?? [];
const capped = scope.filter((s) => s.quantityCap != null);
if (capped.length === 0) return [];
const booked = await this.bookedQuantities(contract);
return capped.map((s) => {
const cap = Number(s.quantityCap);
const used =
contract.freightType === 'CONTAINER'
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
: booked.bulk;
return {
containerSize: s.containerSize,
cargoTypeId: s.cargoTypeId,
cap,
booked: used,
remaining: Math.max(0, cap - used),
};
});
}
/**
* Quantities already booked under a contract that still hold capacity. Excludes
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
*/
private async bookedQuantities(
contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource
.getRepository(BookingContainer)
.createQueryBuilder('bc')
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
.select('bc.container_size', 'size')
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.groupBy('bc.container_size')
.getRawMany<{ size: string | null; qty: string }>();
const bySize = new Map<string, number>();
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
return { bySize, bulk: 0 };
}
const row = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.status NOT IN (:...releasing)', { releasing })
.getRawOne<{ tons: string }>();
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
}
private async resolveRoute(
contract: Contract,
contractRouteId?: string,
): Promise<ContractRoute | null> {
const routes = contract.routes ?? [];
if (contractRouteId) {
const found = routes.find((r) => r.id === contractRouteId);
if (!found) {
throw new BadRequestException('Selected route is not part of this contract.');
}
return found;
}
// ONE_TIME (or single-route GENERAL): auto-select the only route.
if (routes.length === 1) return routes[0];
if (routes.length === 0) return null;
throw new BadRequestException(
'contractRouteId is required for multi-route general contracts.',
);
}
private resolveCargoTypeId(
contract: Contract,
dto: CreateBookingUnderContractDto,
): string | null {
if (contract.freightType === 'BULK') {
const bulk = dto.bulkLines?.[0];
if (bulk?.cargoTypeId) return bulk.cargoTypeId;
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
const scope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
return scope?.cargoTypeId ?? null;
}
private resolveBulkTons(dto: CreateBookingUnderContractDto): number {
if (!dto.bulkLines?.length) return 0;
return dto.bulkLines.reduce(
(sum, l) => sum + Number(l.cargoWeightTons ?? l.itemCount ?? 0),
0,
);
}
/**
* Map each contract-scope container size to a concrete container type and
* persist the booking_container line + its per-unit container numbers. Weight
* rule results are filled in afterward by {@link applyWeightResults} once all
* lines exist (a single rule-engine pass over the booking).
*/
private async persistContainers(
bookingId: string,
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException('At least one container line is required.');
}
const allowedSizes = new Set(
(contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s),
);
const containerRepo = this.dataSource.getRepository(BookingContainer);
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
for (const line of lines) {
if (allowedSizes.size && !allowedSizes.has(line.containerSize)) {
throw new BadRequestException(
`Container size ${line.containerSize} is outside the contract scope.`,
);
}
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const vgmPerUnit = line.units.length
? line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0) / line.units.length
: 0;
const totalVgm = line.units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0);
const containerRow = await containerRepo.save(
containerRepo.create({
bookingId,
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
isOverweight: false,
overweightExcessTons: null,
} as Partial<BookingContainer>),
);
let sortOrder = 0;
for (const unit of line.units) {
await unitRepo.save(
unitRepo.create({
bookingContainerId: containerRow.id,
containerNumber: unit.containerNumber,
sealNumber: unit.sealNumber ?? null,
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
sortOrder: sortOrder++,
}),
);
}
}
}
/**
* Run the rule engine once over the freshly-created booking and persist the
* overweight result per container line (same ordering the engine returns).
*/
private async applyWeightResults(booking: Booking): Promise<void> {
const evalInput = await this.bookingPricingService.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const containers = booking.bookingContainers ?? [];
const containerRepo = this.dataSource.getRepository(BookingContainer);
for (let i = 0; i < containers.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr) continue;
await containerRepo.update(containers[i].id, {
weightLimitRuleId: wr.weightLimitRuleId,
isOverweight: wr.isOverweight,
overweightExcessTons: wr.overweightExcessTons,
});
}
}
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`);
}
if (preferReefer) {
const reefer = types.find((t) => t.isReefer);
if (reefer) return reefer;
}
return types.find((t) => !t.isReefer) ?? types[0];
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
const seq = String(count + 1).padStart(6, '0');
return `BK-${year}-${seq}`;
}
}