mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added status and cargo filters to the ShipmentRequestsPage. - Implemented date range filtering for preferred dates. - Introduced sorting options for shipment requests based on submission date and reference. - Enhanced the display of shipment request details, including status badges and customer information. - Updated the UI to include a search input with clear functionality and improved layout for filters. feat: add equipment return option in new shipment form - Introduced a toggle for equipment return in the NewShipmentPage. - Updated form schema to include field for container contracts. - Enhanced user experience with visual feedback on the equipment return selection. fix: update booking DTO to include equipment return option - Added field to CreateBookingUnderContractDto for per-shipment override. - Updated related types and schemas to accommodate the new field for better contract handling.
1073 lines
43 KiB
TypeScript
1073 lines
43 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
Inject,
|
||
Injectable,
|
||
Logger,
|
||
NotFoundException,
|
||
forwardRef,
|
||
} from '@nestjs/common';
|
||
import { DataSource } from 'typeorm';
|
||
import { OnEvent } from '@nestjs/event-emitter';
|
||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||
|
||
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 { ConsolidationService } from '../bookings/consolidation.service';
|
||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.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 { ClearanceWorkflowService } from './clearance-workflow.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 consolidationService: ConsolidationService,
|
||
private readonly containerTypesService: ContainerTypesService,
|
||
private readonly ruleEngineService: RuleEngineService,
|
||
private readonly milestoneService: ClearanceMilestoneService,
|
||
private readonly workflowService: ClearanceWorkflowService,
|
||
private readonly invoiceService: BookingInvoiceService,
|
||
private readonly dataSource: DataSource,
|
||
@Inject(forwardRef(() => TrainSchedulingService))
|
||
private readonly trainSchedulingService: TrainSchedulingService,
|
||
) {}
|
||
|
||
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`);
|
||
|
||
// A contract whose quantity cap was fully booked is completed — no further
|
||
// bookings, even while contract validity and a booking window are still
|
||
// open. Capacity released after closure (a cancelled/expired booking)
|
||
// reopens the contract on the next booking attempt.
|
||
if (contract.status === 'CONTRACT_CLOSED') {
|
||
const capacity = await this.computeCapacity(contract);
|
||
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
|
||
if (!hasRoom) {
|
||
throw new BadRequestException(
|
||
'This contract is completed — the full contracted quantity has been booked.',
|
||
);
|
||
}
|
||
await this.contractsRepository.update(contract.id, {
|
||
status: 'CONTRACT_ACTIVE',
|
||
} as never);
|
||
contract.status = 'CONTRACT_ACTIVE';
|
||
}
|
||
|
||
// 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 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);
|
||
|
||
// GENERAL without customs (Path A) ALSO clears per booking: the customer
|
||
// uploads his own clearance proof on each booking and Operations reviews it
|
||
// (legacy AWAITING_DOCUMENTS → DOCUMENTS_UNDER_REVIEW → CLEARANCE_READY →
|
||
// requestOperation machine). DOMESTIC has no border, so no gate.
|
||
const generalSelfClear =
|
||
contract.contractKind === 'GENERAL' &&
|
||
!contract.customsClearingEnabled &&
|
||
contract.tradeDirection !== 'DOMESTIC';
|
||
|
||
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
|
||
// there is no window and no date — staff accept them onto a train at
|
||
// finalize time, so both the window gate and scheduledDate are skipped.
|
||
const isIntercity = contract.tradeDirection === 'DOMESTIC';
|
||
if (isIntercity && dto.scheduledDate) {
|
||
throw new BadRequestException(
|
||
'Intercity bookings do not pick a date — staff assign them to a passing train',
|
||
);
|
||
}
|
||
// Every other direction keeps the binding shipment day (the DTO field went
|
||
// optional only for intercity).
|
||
if (!isIntercity && !dto.scheduledDate) {
|
||
throw new BadRequestException('A binding shipment day is required');
|
||
}
|
||
|
||
// Booking-window gate (config-driven): an operations booking may only be
|
||
// created while the route's booking window is open — import: the day's window
|
||
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
|
||
// export: within exportBookingLeadHours of departure. Bookings that enter the
|
||
// clearance gate first (Path B customs AND Path A per-booking self-clearance)
|
||
// are scheduled later, so they are not gated here.
|
||
if (!generalCustoms && !generalSelfClear && !isIntercity) {
|
||
await this.trainSchedulingService.assertBookingWindowOpen({
|
||
originYardId: route?.originYardId ?? null,
|
||
destinationYardId: route?.destinationYardId ?? null,
|
||
scheduledDate: dto.scheduledDate ?? null,
|
||
direction: contract.tradeDirection ?? null,
|
||
});
|
||
}
|
||
|
||
// Hard capacity gate: a container line whose total weight exceeds the
|
||
// container type's max capacity can never be booked — no surcharge path,
|
||
// no override. Checked before any row is written.
|
||
if (freightType === 'CONTAINER') {
|
||
await this.assertWithinMaxCapacity(contract, dto);
|
||
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
|
||
// ≤ the cap, and drawdown bookings never pass through submit — so this is
|
||
// their only chance to hard-block an unbalanceable set. Entry order is
|
||
// irrelevant (the check sorts by weight before pairing).
|
||
await this.assert20ftPairableAtCreate(dto);
|
||
}
|
||
|
||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
||
const booking = await insertWithGeneratedReference(
|
||
() => this.generateReference(),
|
||
(reference) =>
|
||
this.bookingsRepository.create({
|
||
reference,
|
||
companyId: contract.companyId ?? null,
|
||
companyProfileId: contract.companyProfileId ?? null,
|
||
isGovernment: contract.isGovernment,
|
||
governmentInstitution: contract.governmentInstitution ?? null,
|
||
status:
|
||
generalCustoms || generalSelfClear
|
||
? '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: dto.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);
|
||
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||
// matched the route/container (or the rate is unset), so the booking is not
|
||
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||
// does NOT occupy the one-time contract's single active-booking slot — else
|
||
// the customer's retry hits "already has an active booking" against a broken
|
||
// draft. The customer must fix the contract's rates, then rebook.
|
||
if (!(computed.totalAmount > 0)) {
|
||
await this.bookingsRepository.deleteContainers(booking.id);
|
||
await this.bookingsRepository.hardDelete(booking.id);
|
||
throw new BadRequestException(
|
||
'Booking price came out as 0 — no contract rate matches this ' +
|
||
'route/cargo. Set the contract rate and try again.',
|
||
);
|
||
}
|
||
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);
|
||
}
|
||
|
||
// Wagon consolidation gate. A container drawdown whose lines leave a partial
|
||
// wagon (e.g. 21× 20FT → one leftover) must share that wagon with a partner
|
||
// before it can ship. Direct bookings do this at submit; drawdowns have no
|
||
// submit step, so we run it here — BEFORE invoicing/milestones. When it parks
|
||
// for a partner the booking is NOT invoiced or scheduled: those steps run
|
||
// later in finalizeContractBooking, triggered by the pairing event. When it
|
||
// pairs (or needs no consolidation) we finalize inline.
|
||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||
booking.id,
|
||
);
|
||
const intendedStatus =
|
||
generalCustoms || generalSelfClear
|
||
? 'AWAITING_DOCUMENTS'
|
||
: 'OPERATION_REQUEST_PENDING';
|
||
if (
|
||
withContainers &&
|
||
freightType === 'CONTAINER' &&
|
||
(await this.consolidationService.needsConsolidationFromBooking(
|
||
withContainers,
|
||
))
|
||
) {
|
||
const parked = await this.consolidateDrawdown(
|
||
withContainers,
|
||
intendedStatus,
|
||
);
|
||
warnings.push(parked.message);
|
||
if (!parked.paired) {
|
||
// Waiting for a partner — stop here. The booking sits in
|
||
// PENDING_CONSOLIDATION, unbilled and unscheduled, until it pairs.
|
||
// A parked booking still holds contract capacity, so the cap may
|
||
// already be exhausted by it.
|
||
await this.maybeCompleteContract(contract);
|
||
const pendingResult = await this.bookingsRepository.findByIdWithFiles(
|
||
booking.id,
|
||
);
|
||
return { booking: pendingResult ?? booking, warnings };
|
||
}
|
||
}
|
||
|
||
await this.finalizeContractBooking(
|
||
booking.id,
|
||
contract,
|
||
generalCustoms,
|
||
);
|
||
|
||
await this.maybeCompleteContract(contract);
|
||
|
||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||
return { booking: result ?? booking, warnings };
|
||
}
|
||
|
||
/**
|
||
* Search for a complementary partner for a parked-eligible drawdown, pair it or
|
||
* park it in PENDING_CONSOLIDATION with the resume status it should return to.
|
||
* Pairing (via BookingsRepository.pairConsolidation) resumes both partners and
|
||
* emits booking.consolidation.paired, which finalizes any deferred contract
|
||
* booking. Returns whether a partner was found plus a customer-facing message.
|
||
*/
|
||
private async consolidateDrawdown(
|
||
booking: Booking,
|
||
resumeStatus: string,
|
||
): Promise<{ paired: boolean; message: string }> {
|
||
const slots = await this.consolidationService.slotsFromBooking(booking);
|
||
if (!slots.length) {
|
||
return { paired: false, message: '' };
|
||
}
|
||
|
||
const partner = await this.bookingsRepository.findConsolidationPartner(
|
||
booking,
|
||
slots,
|
||
);
|
||
|
||
if (partner) {
|
||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||
return {
|
||
paired: true,
|
||
message: this.consolidationService.describePaired(
|
||
partner.reference,
|
||
slots,
|
||
),
|
||
};
|
||
}
|
||
|
||
await this.bookingsRepository.parkForConsolidation(booking.id, resumeStatus);
|
||
return {
|
||
paired: false,
|
||
message: this.consolidationService.describePending(booking, slots),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Finalize a contract booking once it is cleared to proceed (needed no
|
||
* consolidation, or has just paired): seed clearance milestones / link the
|
||
* contract cycle, then generate the invoice. Idempotent — safe to call again
|
||
* for a booking that pairs after having waited. Skips a booking that is still
|
||
* PENDING_CONSOLIDATION (guards the pairing event against a stray partner).
|
||
*/
|
||
private async finalizeContractBooking(
|
||
bookingId: string,
|
||
contract: Contract,
|
||
generalCustoms: boolean,
|
||
): Promise<void> {
|
||
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
|
||
if (!booking || booking.status === 'PENDING_CONSOLIDATION') return;
|
||
|
||
// 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, bookingId);
|
||
}
|
||
await this.milestoneService.seedPostBookingMilestones(
|
||
bookingId,
|
||
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 full milestone timeline on the booking.
|
||
await this.milestoneService.seedPreBookingMilestonesOnBooking(
|
||
bookingId,
|
||
contract.tradeDirection,
|
||
);
|
||
await this.milestoneService.seedPostBookingMilestones(
|
||
bookingId,
|
||
contract.tradeDirection,
|
||
);
|
||
}
|
||
|
||
// 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(booking)
|
||
.catch((err) =>
|
||
this.logger.error(
|
||
`Failed to generate invoice for contract booking ${booking.reference}: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A parked drawdown just paired — finalize whichever partner is a contract
|
||
* booking that was waiting (invoice + milestones deferred at creation). The
|
||
* pairing already resumed the booking's status from consolidationResumeStatus;
|
||
* this runs the create-time tail that was skipped. Non-contract partners have
|
||
* their own finalize path (staff accept) and are ignored here.
|
||
*/
|
||
@OnEvent('booking.consolidation.paired')
|
||
async onConsolidationPaired(payload: {
|
||
bookingIds: string[];
|
||
}): Promise<void> {
|
||
for (const id of payload.bookingIds ?? []) {
|
||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||
if (!booking?.contractId || booking.status === 'PENDING_CONSOLIDATION') {
|
||
continue;
|
||
}
|
||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||
booking.contractId,
|
||
);
|
||
if (!contract) continue;
|
||
const generalCustoms =
|
||
contract.contractKind === 'GENERAL' &&
|
||
Boolean(contract.customsClearingEnabled);
|
||
await this.finalizeContractBooking(id, contract, generalCustoms).catch(
|
||
(err) =>
|
||
this.logger.error(
|
||
`Failed to finalize paired contract booking ${booking.reference}: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 — pre-booking boundary milestone must be complete.
|
||
const boundaryOk = await this.workflowService.isBoundaryComplete(
|
||
contract.id,
|
||
contract.tradeDirection,
|
||
);
|
||
if (!boundaryOk) {
|
||
throw new BadRequestException(
|
||
'Pre-booking clearance is not complete — booking cannot be created 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),
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Complete the contract once its quantity cap is fully consumed. Runs after
|
||
* every booking created under a GENERAL contract (including a split remainder
|
||
* being rebooked): when no capped scope line has capacity left, the contract
|
||
* moves to CONTRACT_CLOSED even though its validity window is still open —
|
||
* blocking further bookings and shipment requests, including inside an open
|
||
* booking window. Never throws: a status hiccup must not undo the booking
|
||
* that was just created.
|
||
*/
|
||
private async maybeCompleteContract(contract: Contract): Promise<void> {
|
||
try {
|
||
// ONE_TIME contracts are governed by the single-active-booking slot (and
|
||
// are promoted to GENERAL on split), so only GENERAL completes by cap.
|
||
if (contract.contractKind !== 'GENERAL') return;
|
||
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
|
||
const capacity = await this.computeCapacity(contract);
|
||
if (capacity.length === 0) return; // uncapped — completes only by expiry
|
||
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
|
||
// 3 decimals); container caps are integers and unaffected.
|
||
const exhausted = capacity.every(
|
||
(c) => c.remaining != null && c.remaining <= 0.001,
|
||
);
|
||
if (!exhausted) return;
|
||
await this.contractsRepository.update(contract.id, {
|
||
status: 'CONTRACT_CLOSED',
|
||
} as never);
|
||
this.logger.log(
|
||
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
|
||
);
|
||
} catch (err) {
|
||
this.logger.error(
|
||
`Could not evaluate completion for contract ${contract.id}: ${
|
||
err instanceof Error ? err.message : String(err)
|
||
}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pre-create validation + authoritative price preview for the shipment form:
|
||
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
|
||
* would persist it and run the same BookingPricingService compute over it —
|
||
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
|
||
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
|
||
* backoffice form call this from the price-confirm modal, so the breakdown the
|
||
* user confirms is line-for-line what the booking will be charged. Also runs
|
||
* the 20ft weight-pairing rule, which hard-blocks creation.
|
||
*/
|
||
async validateShipment(
|
||
contractId: string,
|
||
dto: CreateBookingUnderContractDto,
|
||
): Promise<{
|
||
overweightLines: Array<{
|
||
containerTypeCode: string;
|
||
totalVgmTons: number;
|
||
maxAllowedTons: number;
|
||
excessTons: number;
|
||
}>;
|
||
overweightSurchargeAmount: number;
|
||
currency: string | null;
|
||
pairingErrors: string[];
|
||
capacityErrors: string[];
|
||
lineItems: PriceLineItemDto[];
|
||
totalAmount: number;
|
||
}> {
|
||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||
|
||
const lines = dto.containers ?? [];
|
||
if (contract.freightType === 'CONTAINER' && !lines.length) {
|
||
return {
|
||
overweightLines: [],
|
||
overweightSurchargeAmount: 0,
|
||
currency: null,
|
||
pairingErrors: [],
|
||
capacityErrors: [],
|
||
lineItems: [],
|
||
totalAmount: 0,
|
||
};
|
||
}
|
||
|
||
// Resolve each container line's type + total VGM (sum of unit weights) —
|
||
// mirrors persistContainers so the preview lines match the persisted ones.
|
||
const resolved = await Promise.all(
|
||
lines.map(async (line) => {
|
||
const ct = await this.resolveContainerTypeForSize(
|
||
line.containerSize,
|
||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||
);
|
||
const totalVgmTons = (line.units ?? []).reduce(
|
||
(s, u) => s + Number(u.vgmTons ?? 0),
|
||
0,
|
||
);
|
||
return { line, ct, totalVgmTons };
|
||
}),
|
||
);
|
||
|
||
// The unsaved twin of the booking createUnderContract would write: same
|
||
// denormalized contract fields, same container-line math. No id → the
|
||
// pricing service derives wagon counts from the in-memory lines.
|
||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||
const previewBooking = Object.assign(new Booking(), {
|
||
freightType: contract.freightType,
|
||
tradeDirection: contract.tradeDirection,
|
||
paymentCurrency: contract.paymentCurrency,
|
||
serviceTypeId: contract.serviceTypeId,
|
||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||
isHazardous: contract.isHazardous,
|
||
isReefer: contract.isReefer,
|
||
isGovernment: contract.isGovernment,
|
||
shippingLineId: null,
|
||
contractRouteId: route?.id ?? null,
|
||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||
Object.assign(new BookingContainer(), {
|
||
containerTypeId: ct.id,
|
||
containerSize: line.containerSize,
|
||
quantity: line.quantity,
|
||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||
reeferQuantity: line.reeferQuantity ?? 0,
|
||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||
totalVgmTons,
|
||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||
}),
|
||
),
|
||
}) as Booking;
|
||
|
||
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
|
||
|
||
// The overweight surcharge line is already currency-converted; surface its
|
||
// amount separately so the warning alert can reference the exact charge.
|
||
const overweightSurchargeAmount =
|
||
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
|
||
|
||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||
const twentyFtUnits = resolved
|
||
.filter((r) => (r.line.containerSize ?? '').includes('20'))
|
||
.flatMap((r) =>
|
||
(r.line.units ?? []).map((u, idx) => ({
|
||
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
|
||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||
})),
|
||
);
|
||
const maxDiff = await this.max20ftPairDiffTons();
|
||
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
|
||
(v) => v.message,
|
||
);
|
||
|
||
// Hard capacity ceiling — a non-empty result means the create call will be
|
||
// rejected, so the form can block submit up front.
|
||
const capacityErrors = await this.ruleEngineService.capacityViolations(
|
||
resolved.map(({ line, ct, totalVgmTons }) => ({
|
||
containerTypeId: ct.id,
|
||
quantity: line.quantity,
|
||
totalVgmTons,
|
||
})),
|
||
contract.tradeDirection,
|
||
);
|
||
|
||
return {
|
||
overweightLines: computed.overweightLines,
|
||
overweightSurchargeAmount,
|
||
currency: computed.currency,
|
||
pairingErrors,
|
||
capacityErrors,
|
||
lineItems: computed.lineItems,
|
||
totalAmount: computed.totalAmount,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Throws when any container line's total weight exceeds the hard capacity
|
||
* ceiling of its weight limit rule. Mirrors validateShipment's line
|
||
* resolution so the gate matches what the form preview reported.
|
||
*/
|
||
private async assertWithinMaxCapacity(
|
||
contract: Contract,
|
||
dto: CreateBookingUnderContractDto,
|
||
): Promise<void> {
|
||
const lines = dto.containers ?? [];
|
||
if (!lines.length) return;
|
||
|
||
const containers = await Promise.all(
|
||
lines.map(async (line) => {
|
||
const ct = await this.resolveContainerTypeForSize(
|
||
line.containerSize,
|
||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||
);
|
||
const totalVgmTons = (line.units ?? []).reduce(
|
||
(s, u) => s + Number(u.vgmTons ?? 0),
|
||
0,
|
||
);
|
||
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
|
||
}),
|
||
);
|
||
|
||
const violations = await this.ruleEngineService.capacityViolations(
|
||
containers,
|
||
contract.tradeDirection,
|
||
);
|
||
if (violations.length) {
|
||
throw new BadRequestException(violations.join('; '));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Hard-block booking creation when the 20ft container weights cannot be
|
||
* balanced onto wagons (pair diff over the global cap). Same rule the
|
||
* shipment-form preview reports as `pairingErrors`, enforced server-side.
|
||
*/
|
||
private async assert20ftPairableAtCreate(
|
||
dto: CreateBookingUnderContractDto,
|
||
): Promise<void> {
|
||
const twentyFtUnits = (dto.containers ?? [])
|
||
.filter((line) => (line.containerSize ?? '').includes('20'))
|
||
.flatMap((line, lineIdx) =>
|
||
(line.units ?? []).map((u, idx) => ({
|
||
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
|
||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||
})),
|
||
);
|
||
if (twentyFtUnits.length < 2) return;
|
||
|
||
const maxDiff = await this.max20ftPairDiffTons();
|
||
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
|
||
if (violations.length) {
|
||
throw new BadRequestException(
|
||
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
|
||
.map((v) => v.message)
|
||
.join(' ')}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
private async max20ftPairDiffTons(): Promise<number> {
|
||
const row = await this.dataSource
|
||
.getRepository(TrainSchedulingGlobalRules)
|
||
.find({ order: { createdAt: 'ASC' }, take: 1 })
|
||
.then((rows) => rows[0] ?? null)
|
||
.catch(() => null);
|
||
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
|
||
return Number.isFinite(n) ? n : 10;
|
||
}
|
||
|
||
/** 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 seq = await this.bookingsRepository.maxReferenceSequence(year);
|
||
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
|
||
}
|
||
}
|