mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
- 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.
312 lines
13 KiB
TypeScript
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' },
|
|
});
|
|
}
|
|
}
|