mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
583 lines
23 KiB
TypeScript
583 lines
23 KiB
TypeScript
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}`;
|
||
}
|
||
}
|