mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- Updated ContractRouteLineView to clarify that routes carry no quantity and are pure origin-destination lanes. - Modified GeneralContractService to reflect changes in route handling, removing quantity-related logic. - Adjusted BookingsService to persist contracted routes without quantities, aligning with the new contract structure. - Revised CreateBookingDto and CreateContractRouteDto to remove quantity fields, emphasizing shared pool usage. - Added ContractOrdersPanel component to display drawdown orders and their associated pool. - Implemented useContractOrders and useContractPool hooks for fetching order and pool data. - Created booking-orders.service.ts to manage API interactions for drawdown orders and pool data. - Updated BookingRequestDetailPage and PlaceOrderDialog to accommodate new order handling logic.
470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
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,
|
||
companyId: contract.companyId ?? null,
|
||
companyProfileId: contract.companyProfileId ?? null,
|
||
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')}`;
|
||
}
|
||
}
|