Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts
Marshal 56de90892d Add booking window management and locomotive scheduling features
- Implemented migration to release stuck assigned locomotives.
- Added schedule window phases to train schedules.
- Created booking batch offers table for partial capacity bookings.
- Developed BookingSplitService to handle partial booking offers and splits.
- Introduced BookingWindowService to manage booking window lifecycle and transitions.
- Added BookingBatchOffer entity to represent offers made during booking splits.
- Enhanced locomotive options with warnings for scheduling.
- Created UpcomingWindowsSection component to display upcoming booking windows.
2026-07-03 03:36:06 +00:00

271 lines
10 KiB
TypeScript

import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { BillingService } from '../billing/billing.service';
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 {
BookingBatchOffer,
OfferedLine,
} from './entities/booking-batch-offer.entity';
import { BookingNotifierService } from './booking-notifier.service';
export interface SizedOffer {
offeredWagons: number;
totalWagons: number;
offeredLines: OfferedLine[] | null;
offeredWeightTons: number;
offeredAmount: number;
offeredPricingBreakdown: Record<string, unknown>;
}
/**
* Partial-capacity booking splits (import batch). The offer is sized and priced
* against an in-memory clone — the booking row is untouched until the customer
* pays, which is the act of accepting the split (applySplit). No payment →
* offer expires and the booking stays whole.
*
* Only GENERAL-contract commercial bookings are offered partials: the remainder
* returns to the contract's quantity cap (derived live from booking_container
* rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity.
*/
@Injectable()
export class BookingSplitService {
private readonly logger = new Logger(BookingSplitService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
@Inject(forwardRef(() => BookingPricingService))
private readonly pricing: BookingPricingService,
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly billing: BillingService,
private readonly notifier: BookingNotifierService,
) {}
/**
* Size the largest part of the booking that fits `freeWagons`, priced via an
* in-memory clone. Returns null when nothing meaningful fits (no whole
* container unit / no bulk tonnage, or pricing failed).
*/
async sizeOffer(
booking: Booking,
freeWagons: number,
totalWagons: number,
bulkWagonCapacityTons: number,
): Promise<SizedOffer | null> {
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
const containers = booking.bookingContainers ?? [];
let offeredLines: OfferedLine[] | null = null;
let offeredWeightTons = 0;
let offeredWagons = 0;
const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking);
clone.adjustedTotalAmount = null;
if (containers.length) {
offeredLines = [];
let remaining = freeWagons;
const clonedContainers: BookingContainer[] = [];
for (const line of containers) {
const quantity = Number(line.quantity ?? 0);
const lineWagons = Number(line.wagonsRequired ?? 0);
if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue;
const perUnit = lineWagons / quantity;
// Largest unit count whose wagon need still fits the remaining budget.
let take = Math.min(quantity, Math.floor(remaining / perUnit));
while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1;
if (take <= 0) continue;
const takeWagons = Math.ceil(take * perUnit);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
offeredLines.push({
bookingContainerId: line.id,
quantity: take,
wagonsRequired: takeWagons,
totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000,
});
offeredWeightTons += take * vgmPerUnit;
offeredWagons += takeWagons;
remaining -= takeWagons;
const clonedLine: BookingContainer = Object.assign(
Object.create(Object.getPrototypeOf(line)),
line,
{
quantity: take,
wagonsRequired: takeWagons,
totalVgmTons: take * vgmPerUnit,
},
);
clonedContainers.push(clonedLine);
}
if (!offeredLines.length || offeredWagons <= 0) return null;
clone.bookingContainers = clonedContainers;
} else {
// Bulk: split by weight — the offered part is what freeWagons can carry.
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
if (offeredWeightTons <= 0) return null;
offeredWagons = Math.min(
freeWagons,
Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)),
);
}
offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000;
clone.cargoTotalWeightVgm = offeredWeightTons;
clone.wagonsRequired = offeredWagons;
try {
const priced = await this.pricing.computePriceForBooking(clone);
return {
offeredWagons,
totalWagons,
offeredLines,
offeredWeightTons,
offeredAmount: priced.totalAmount,
offeredPricingBreakdown: {
lineItems: priced.lineItems,
totalAmount: priced.totalAmount,
currency: priced.currency,
generatedAt: new Date().toISOString(),
partialOfWagons: totalWagons,
},
};
} catch (err) {
this.logger.warn(
`Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`,
);
return null;
}
}
/**
* Persist the offer and swap the booking's payable to a partial invoice for the
* offered amount. Any previous open offer for the booking is superseded.
*/
async createOffer(
booking: Booking,
scheduleId: string,
sized: SizedOffer,
deadline: Date,
): Promise<BookingBatchOffer> {
const repo = this.dataSource.getRepository(BookingBatchOffer);
await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' });
// The full-amount invoice must not stay payable next to the partial one.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID');
const invoice = await this.invoiceService.ensureInvoiceForBooking(
{ ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking,
{ dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending },
);
const offer = await repo.save(
repo.create({
bookingId: booking.id,
trainScheduleId: scheduleId,
offeredWagons: sized.offeredWagons,
totalWagons: sized.totalWagons,
offeredLines: sized.offeredLines,
offeredWeightTons: sized.offeredWeightTons,
offeredAmount: sized.offeredAmount,
offeredPricingBreakdown: sized.offeredPricingBreakdown,
invoiceId: invoice.id,
paymentDeadline: deadline,
status: 'OFFERED',
}),
);
await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons);
return offer;
}
/**
* Payment received inside the window — the customer accepted the split.
* Reduce the booking to the offered lines/weight; the remainder returns to the
* contract cap automatically (bookedQuantities derives from live lines).
* Idempotent: no OFFERED offer → no-op.
*/
async applySplit(bookingId: string): Promise<void> {
const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },
order: { createdAt: 'DESC' },
});
if (!offer) return;
await this.dataSource.transaction(async (manager) => {
if (offer.offeredLines?.length) {
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId },
});
for (const line of lines) {
const kept = keptByLine.get(line.id);
if (!kept) {
await manager.getRepository(BookingContainer).softDelete(line.id);
await manager
.getRepository(BookingContainerUnit)
.softDelete({ bookingContainerId: line.id });
continue;
}
const dropCount = Number(line.quantity) - kept.quantity;
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.quantity,
wagonsRequired: kept.wagonsRequired,
totalVgmTons: kept.totalVgmTons,
hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity),
reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity),
});
if (dropCount > 0) {
// Trim surplus physical units, last-entered first.
const units = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: dropCount,
});
if (units.length) {
await manager
.getRepository(BookingContainerUnit)
.softDelete(units.map((u) => u.id));
}
}
}
}
await manager.getRepository(Booking).update(bookingId, {
wagonsRequired: offer.offeredWagons,
cargoTotalWeightVgm: offer.offeredWeightTons,
totalAmount: offer.offeredAmount,
pricingBreakdown: offer.offeredPricingBreakdown,
} as never);
await manager
.getRepository(BookingBatchOffer)
.update(offer.id, { status: 'APPLIED' });
});
this.logger.log(
`Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`,
);
}
/** Pay window closed without payment — offer dies, booking stays whole. */
async expireOpenOffer(bookingId: string): Promise<void> {
await this.dataSource
.getRepository(BookingBatchOffer)
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
}
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
return this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },
order: { createdAt: 'DESC' },
});
}
}