Files
edr-platform/apps/edr-freight-api/src/modules/train-scheduling/booking-split.service.ts
Marshal 4b7f6d2548 enhance contract and booking services with server-side search and validation improvements
- Added  parameter to  and  for server-side free-text search on contract reference, company name, and booking details.
- Introduced new validation errors in  for container clashes and space issues when creating bookings.
- Implemented paginated dropdown settings retrieval in .
- Updated  to fetch active yards using a new method that handles pagination.
- Enhanced  with a  method to fetch all records by walking through pages.
- Refactored  to support filtering and pagination in schedule listings.
- Improved  to return a paginated list of facilities.
- Updated UI components in  and  to utilize debounced search inputs for better performance.
- Added alerts in  to inform users about booking constraints related to splits and capacity.
- Enhanced  to display notifications for split bookings and capacity usage.
2026-07-12 10:51:31 +00:00

312 lines
13 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.
*
* GENERAL and ONE_TIME 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. The reduced booking is flagged
* is_split (see applySplit); the contract kind never changes. On a ONE_TIME
* contract a split booking releases the single-active-booking slot, but the
* next booking must take the WHOLE remainder — the split chain is the only way
* a ONE_TIME contract produces multiple bookings. Once the remainder is
* rebooked and the cap hits zero, ContractBookingService completes the
* contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
* while validity and a booking window are still open.
*/
@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).
*
* `maxOfferedWeightTons` caps the offered CARGO tonnage (bulk only) — on a
* weight-limited train the wagons' own tare eats into the locomotive's
* remaining pull weight, so the caller passes the room left after tare.
*/
async sizeOffer(
booking: Booking,
freeWagons: number,
totalWagons: number,
bulkWagonCapacityTons: number,
maxOfferedWeightTons?: 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,
// further capped by the caller's weight room when the pull limit binds.
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
offeredWeightTons = Math.min(
totalWeight,
freeWagons * bulkWagonCapacityTons,
maxOfferedWeightTons ?? Number.POSITIVE_INFINITY,
);
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) => {
// Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
// contract this is the ledger the outstanding remainder is derived from
// (there is no contract quantity cap to fall back on).
const preSplit = await manager.getRepository(Booking).findOne({
where: { id: bookingId },
select: { id: true, cargoTotalWeightVgm: true },
});
const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};
if (offer.offeredLines?.length) {
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId },
});
const bySize: Record<string, number> = {};
for (const line of lines) {
const size = line.containerSize ?? '';
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
}
preSplitQuantities.bySize = bySize;
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));
}
}
}
} else {
preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
}
// is_split releases the ONE_TIME single-active-booking slot for the
// remainder (whole-remainder-only, enforced at booking creation) and
// switches the contract into remainder-based completion. The contract
// kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
// split chain.
await manager.getRepository(Booking).update(bookingId, {
wagonsRequired: offer.offeredWagons,
cargoTotalWeightVgm: offer.offeredWeightTons,
totalAmount: offer.offeredAmount,
pricingBreakdown: offer.offeredPricingBreakdown,
isSplit: true,
preSplitQuantities,
} 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' },
});
}
}