Files
edr-platform/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts
Marshal b6d5047d27 feat: Implement general contract booking orders functionality
- Add DTOs for creating booking orders and viewing contract quantities.
- Create entities for booking orders and booking order lines.
- Implement service for managing general contract operations, including activation after payment and retrieving quantity lines.
- Develop UI components for contract detail and list pages, including order placement dialog.
- Integrate API service for booking orders, enabling listing and creating orders against contracts.
- Enhance contract status display and quantity pool visualization in the UI.
2026-06-20 19:31:51 +00:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
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 { BookingBatchService } from '../train-scheduling/booking-batch.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';
@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,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
/** Orders placed against a contract, with their lines and child booking. */
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
return this.ordersRepository.findByContract(contractBookingId);
}
findById(id: string): Promise<BookingOrder | null> {
return this.ordersRepository.findById(id);
}
/**
* 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');
}
// Validate the route has a departure on the chosen day.
const day = eatDay(new Date(dto.scheduledDate));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
contract.originYardId,
contract.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
// Validate each line against the remaining pool.
const poolLines = await this.generalContractService.getQuantityLines(
contract.id,
);
const isContainer = contract.freightType === 'CONTAINER';
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, manager);
const reference = await this.generateReference();
const orderRow = manager.create(BookingOrder, {
reference,
contractBookingId: contract.id,
bookingId: childBooking.id,
companyId: contract.companyId ?? null,
scheduledDate: new Date(dto.scheduledDate),
status: 'PAID',
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,
}),
);
await manager.save(lines);
savedOrder.lines = lines;
return savedOrder;
});
// Feed the child booking into the day-pool batch so it allocates to a train.
try {
await this.bookingBatchService.processRouteDay({
originYardId: contract.originYardId,
destinationYardId: contract.destinationYardId,
day,
});
} catch (err) {
this.logger.error(
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
// Close the contract once its pool is exhausted.
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 and entering the queue already PAID + FULLY_EXECUTED.
*/
private async spawnChildBooking(
contract: Booking,
dto: CreateBookingOrderDto,
manager: import('typeorm').EntityManager,
): Promise<Booking> {
const reference = await this.generateChildBookingReference();
const now = new Date();
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);
}
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: contract.originYardId,
destinationYardId: contract.destinationYardId,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
cargoTypeId: contract.cargoTypeId ?? null,
cargoFreeText: contract.cargoFreeText ?? null,
shippingLineId: contract.shippingLineId ?? null,
cargoTotalWeightVgm: totalWeight,
isHazardous: contract.isHazardous,
paymentCurrency: contract.paymentCurrency,
bookingType: 'ONE_TIME',
scheduledDate: new Date(dto.scheduledDate),
// Already covered by the contract's one-time payment: enter the pool ready
// and paid so the batch engine reserves → allocates it immediately.
status: 'FULLY_EXECUTED',
paymentStatus: 'PAID',
fullyExecutedAt: now,
customerSignedAt: now,
priorityScore: contract.priorityScore,
totalAmount: 0,
allowConsolidation: false,
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);
}
}
return savedChild;
}
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')}`;
}
}