Files
edr-platform/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts
2026-06-26 23:24:48 +00:00

384 lines
15 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,
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 { 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 {
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 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: only one active booking at a time (also enforced by partial unique index).
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.',
);
}
}
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const reference = await this.generateReference();
const freightType = contract.freightType;
// 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: '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);
}
// Path B side effects: link the clearance cycle, seed post-booking
// milestones onto the booking, and advance the contract.
if (contract.customsClearingEnabled) {
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);
}
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
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 — GL Ethiopia only.
if (!isGlActor) {
throw new ForbiddenException(
'Only Global Logistics Ethiopia can create bookings for customs-clearance contracts.',
);
}
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();
}
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}`;
}
}