mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 03:10:54 +00:00
- 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.
294 lines
11 KiB
TypeScript
294 lines
11 KiB
TypeScript
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')}`;
|
||
}
|
||
}
|