This commit is contained in:
Nathnael
2026-06-29 11:14:42 +00:00
parent c37e8acfdc
commit 662d96571e
2 changed files with 3 additions and 472 deletions

View File

@@ -1,470 +0,0 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { CompaniesService } from '../companies/companies.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { RatesService } from '../rule-engine/services/rates.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { BookingOrdersRepository } from './booking-orders.repository';
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
import { BookingOrder } from './entities/booking-order.entity';
import { BookingOrderLine } from './entities/booking-order-line.entity';
import { GeneralContractService } from './general-contract.service';
import { isRoadService, roadKmPrice } from './road.util';
@Injectable()
export class BookingOrdersService {
private readonly logger = new Logger(BookingOrdersService.name);
constructor(
private readonly dataSource: DataSource,
private readonly ordersRepository: BookingOrdersRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly companiesService: CompaniesService,
private readonly generalContractService: GeneralContractService,
private readonly pricingService: BookingPricingService,
private readonly ratesService: RatesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
const orders = await this.ordersRepository.findByContract(contractBookingId);
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
return orders;
}
async findById(id: string): Promise<BookingOrder | null> {
const order = await this.ordersRepository.findById(id);
if (order) await this.syncOrderFromChild(order);
return order;
}
/**
* The order is a ledger row; the spawned child ONE_TIME booking is what
* actually moves through the workflow (clearance → marketing/ops accept →
* pay → allocate), exactly like a one-time booking. Nothing writes the order
* row after creation, so its stored status would stay 'PENDING' forever.
*
* Mirror the child onto the order whenever it is read: copy the child's
* status, schedulingStatus and trainScheduleId onto the order (mutating the
* in-memory instance the caller gets back), and persist that snapshot when it
* has drifted so list/detail views and any stored reporting stay in sync.
*/
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
const child = order.booking;
if (!child) return;
const nextStatus = child.status;
const nextScheduling = child.schedulingStatus;
const nextTrainScheduleId = child.trainScheduleId ?? null;
const drifted =
order.status !== nextStatus ||
order.schedulingStatus !== nextScheduling ||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
// Reflect the child onto the instance returned to the caller.
order.status = nextStatus;
order.schedulingStatus = nextScheduling;
order.trainScheduleId = nextTrainScheduleId;
if (drifted) {
await this.ordersRepository.update(order.id, {
status: nextStatus,
schedulingStatus: nextScheduling,
trainScheduleId: nextTrainScheduleId,
});
}
}
/**
* Place a drawdown order against an ACTIVE general contract.
*
* Validates the requested quantities against the remaining pool, then spawns a
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
* route/cargo/service) so it flows through the existing train-scheduling
* pipeline. The order row is the ledger entry linking contract → child booking.
*/
async create(
dto: CreateBookingOrderDto,
userId?: string,
): Promise<BookingOrder> {
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
if (!contract) {
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
}
if (!this.generalContractService.isGeneralContract(contract)) {
throw new BadRequestException('Booking is not a general contract');
}
if (contract.status !== 'CONTRACT_ACTIVE') {
throw new BadRequestException(
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
);
}
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Contract ordering window has expired');
}
// The customer placing the order must own the contract.
if (userId && !(await this.userOwnsContract(userId, contract))) {
throw new BadRequestException('You do not have access to this contract');
}
// Resolve the route the order ships on: a chosen contract route line for a
// multi-route contract, else the contract's own origin/destination.
const routeLines = await this.generalContractService.getRouteLines(
contract.id,
);
let originYardId = contract.originYardId;
let destinationYardId = contract.destinationYardId;
let routeLineId: string | null = null;
let routeKm: number | null = null;
if (routeLines.length > 0) {
if (!dto.routeLineId) {
throw new BadRequestException(
'This contract has multiple routes — select a route to draw from',
);
}
const chosen = routeLines.find((r) => r.routeLineId === dto.routeLineId);
if (!chosen) {
throw new BadRequestException(
'Selected route is not part of this contract',
);
}
originYardId = chosen.originYardId;
destinationYardId = chosen.destinationYardId;
routeLineId = chosen.routeLineId;
routeKm = chosen.km ?? null;
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
originYardId,
destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
const isContainer = contract.freightType === 'CONTAINER';
// Hazardous/reefer counts the customer entered cannot exceed the line they
// belong to. Validated for every order regardless of routing.
for (const line of dto.lines) {
const haz = line.hazardousQuantity ?? 0;
const reefer = line.reeferQuantity ?? 0;
if (haz < 0 || reefer < 0) {
throw new BadRequestException('Hazardous/reefer quantities cannot be negative');
}
if (haz > line.quantity || reefer > line.quantity) {
throw new BadRequestException(
'Hazardous/reefer quantity cannot exceed the line quantity',
);
}
}
// The contract has a single shared drawdown pool (per container type for
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
// only fixed origin/destination/km above — so every order, routed or not,
// validates each line against the same shared pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
for (const line of dto.lines) {
if (line.quantity <= 0) {
throw new BadRequestException('Order quantities must be greater than zero');
}
const key = isContainer ? (line.containerTypeId ?? '') : '';
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
if (!poolLine) {
throw new BadRequestException(
isContainer
? `Container type ${line.containerTypeId} is not part of this contract`
: 'This contract has no matching quantity pool',
);
}
if (line.quantity > poolLine.remainingQuantity) {
throw new BadRequestException(
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
);
}
}
// Persist the order + its child shipment booking atomically.
const order = await this.dataSource.transaction(async (manager) => {
const childBooking = await this.spawnChildBooking(
contract,
dto,
{ originYardId, destinationYardId, km: routeKm },
manager,
);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
routeLineId,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
// The order is a ledger row; the child booking drives the workflow
// (review → pay → allocate), so the order tracks PENDING until done.
status: 'PENDING',
schedulingStatus: 'NOT_SCHEDULED',
});
const savedOrder = await manager.save(orderRow);
const lines = dto.lines.map((l) =>
manager.create(BookingOrderLine, {
orderId: savedOrder.id,
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
quantity: l.quantity,
hazardousQuantity: l.hazardousQuantity ?? 0,
reeferQuantity: l.reeferQuantity ?? 0,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// The child does NOT enter the train batch pool here. It is priced and
// unpaid, awaiting Marketing review (OPERATION_REQUEST_PENDING) or customs
// clearance first; the batch enqueue happens only on accept.
// Close the contract once its pool is exhausted (pending orders count, so
// the pool reserves quantity as soon as an order is placed).
if (await this.generalContractService.isExhausted(contract.id)) {
await this.dataSource
.getRepository(Booking)
.update(contract.id, { status: 'CONTRACT_CLOSED' });
this.logger.log(
`Contract ${contract.reference} CLOSED — quantity exhausted`,
);
}
return (await this.ordersRepository.findById(order.id)) ?? order;
}
/**
* Create the ONE_TIME child booking for an order, inheriting the contract's
* shipment context. Unlike the contract (which is no longer paid up front),
* the child is PRICED and UNPAID and waits for Marketing review — going
* through the customs clearance gate first when the service includes customs,
* mirroring a one-time booking. It only enters the train pool on accept.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
route: { originYardId: string; destinationYardId: string; km: number | null },
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const isContainer = contract.freightType === 'CONTAINER';
// Sum line quantities × the contract's per-unit weight for the child total.
const containerByType = new Map(
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
);
let totalWeight = 0;
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
totalWeight += vgmPerUnit * line.quantity;
}
} else {
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
}
// Per-order hazardous/reefer: set the child flags from the order's line
// counts so the HAZARD_SURCHARGE / REEFER_SURCHARGE rates apply.
const hasHazardous = dto.lines.some((l) => (l.hazardousQuantity ?? 0) > 0);
const hasReefer = dto.lines.some((l) => (l.reeferQuantity ?? 0) > 0);
// Customs orders flow through the one-time clearance gate first; others go
// straight to operations review with the chosen shipment day.
const { includesCustoms } = clearanceCodesForBooking(contract);
const spawnStatus = includesCustoms
? 'AWAITING_DOCUMENTS'
: 'OPERATION_REQUEST_PENDING';
const child = manager.create(Booking, {
reference,
// Drawdown orders inherit the contract's company + profile (both required).
companyId: contract.companyId,
companyProfileId: contract.companyProfileId,
isGovernment: contract.isGovernment,
governmentInstitution: contract.governmentInstitution ?? null,
contractType: contract.contractType,
previousContractId: contract.id,
serviceTypeId: contract.serviceTypeId,
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
equipmentReturn: contract.equipmentReturn,
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: hasHazardous,
isReefer: hasReefer,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Priced + unpaid: the customer pays this order on its own.
status: spawnStatus,
paymentStatus: 'PENDING',
priorityScore: contract.priorityScore,
totalAmount: 0,
schedulingStatus: 'NOT_SCHEDULED',
});
const savedChild = await manager.save(child);
if (isContainer) {
for (const line of dto.lines) {
const src = containerByType.get(line.containerTypeId ?? '');
const ct = line.containerTypeId
? await manager.getRepository(ContainerType).findOne({
where: { id: line.containerTypeId },
})
: null;
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
const row = manager.create(BookingContainer, {
bookingId: savedChild.id,
containerTypeId: line.containerTypeId ?? null,
quantity: line.quantity,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
isOverweight: false,
});
await manager.save(row);
}
}
// Price the order: base freight for the drawn quantity + haz/reefer
// surcharges, plus a road KM charge when the service ships by road.
const roadKm = isRoadService(contract.serviceType) ? route.km : null;
await this.priceChildBooking(savedChild.id, roadKm, manager);
return savedChild;
}
/**
* Compute and persist the child order's price (base + surcharges) inside the
* order transaction. The contract is no longer paid up front, so each order
* carries its own total that the customer pays.
*/
private async priceChildBooking(
childId: string,
roadKm: number | null,
manager: import('typeorm').EntityManager,
): Promise<void> {
const child = await manager.getRepository(Booking).findOne({
where: { id: childId },
relations: { bookingContainers: true },
});
if (!child) return;
try {
const computed = await this.pricingService.computePriceForBooking(child);
const lineItems = [...computed.lineItems];
let total = computed.totalAmount;
// Road KM charge: distance × the live PER_KM rate, added as its own line.
if (roadKm && roadKm > 0) {
const perKmRate = await this.findPerKmRate(child.paymentCurrency);
const kmAmount = roadKmPrice(roadKm, perKmRate);
if (kmAmount > 0) {
lineItems.push({
code: 'ROAD_KM',
description: `Road transport (${roadKm} km)`,
amount: kmAmount,
unitAmount: perKmRate!,
unit: 'PER_KM',
quantity: roadKm,
currency: child.paymentCurrency,
});
total += kmAmount;
}
}
await manager.getRepository(Booking).update(childId, {
totalAmount: total,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
} catch (err) {
this.logger.error(
`Pricing child order ${childId} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The live PER_KM rate value for road billing, in the given currency. */
private async findPerKmRate(currency: string): Promise<number | null> {
const rates = await this.ratesService.findLiveRates();
const rate = rates.find(
(r) => r.rateUnit === 'PER_KM' && r.currency === currency,
);
return rate ? Number(rate.rateValue) : null;
}
private async userOwnsContract(
userId: string,
contract: Booking,
): Promise<boolean> {
if (!contract.companyId) return true; // government / staff-created
try {
const { company } = await this.companiesService.getCompanyInfoByUserId(
userId,
);
return company.id === contract.companyId;
} catch {
return false;
}
}
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.ordersRepository.countByYear(year);
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
}
private async generateChildBookingReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
}

View File

@@ -52,7 +52,7 @@ export class BookingInvoiceService {
private readonly firstMile: FirstMileService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatch: BookingBatchService,
) {}
) { }
/**
* Ensure the booking has its invoice, generating one from the snapshotted
@@ -122,7 +122,8 @@ export class BookingInvoiceService {
if (booking.paymentStatus === 'PAID') return;
const paidAt = new Date();
const isGeneralContract = booking.bookingType === 'GENERAL_CONTRACT';
const isGeneralContract = false
// booking.bookingType === 'GENERAL_CONTRACT';
let contractExpiresAt: Date | null = null;
if (isGeneralContract) {