|
|
|
|
@@ -27,6 +27,8 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
|
|
|
|
|
import { formatRouteLabel } from '../routes/entities/route.entity';
|
|
|
|
|
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
|
|
|
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
|
|
|
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
|
|
|
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
|
|
|
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
|
|
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
|
|
|
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
|
|
|
|
@@ -459,6 +461,9 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
group.destinationYardId,
|
|
|
|
|
group.day,
|
|
|
|
|
);
|
|
|
|
|
// Backstop: PAID bookings stranded without a schedule (hold expired before
|
|
|
|
|
// the payment landed) get re-placed onto whatever fits today.
|
|
|
|
|
await this.rescueStrandedPaidForDay(group.day);
|
|
|
|
|
for (const scheduleId of scheduleIds) {
|
|
|
|
|
await this.settleDueReservations(scheduleId);
|
|
|
|
|
await this.reconcilePaidUnlinked(scheduleId);
|
|
|
|
|
@@ -519,17 +524,26 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
});
|
|
|
|
|
if (!booking) return;
|
|
|
|
|
if (!booking.trainScheduleId) {
|
|
|
|
|
// A paid booking with no train is money taken and nothing boarding —
|
|
|
|
|
// scream so staff pin it to a schedule manually (batch board / assign).
|
|
|
|
|
// A paid booking with no train is money taken and nothing boarding. The
|
|
|
|
|
// hold was expired before the payment landed (webhook lag beat the
|
|
|
|
|
// reconcile, or the stranding predates it) — try to re-place it on a
|
|
|
|
|
// fitting same-day train before falling back to a manual-assign scream.
|
|
|
|
|
if (booking.paymentStatus === "PAID" || booking.status === "PAID") {
|
|
|
|
|
this.logger.error(
|
|
|
|
|
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
|
|
|
|
`its reservation was likely expired before the payment landed. ` +
|
|
|
|
|
`Assign it to a schedule manually from the batch board.`,
|
|
|
|
|
);
|
|
|
|
|
const rescuedScheduleId = await this.replaceStrandedPaidBooking(booking);
|
|
|
|
|
if (!rescuedScheduleId) {
|
|
|
|
|
this.logger.error(
|
|
|
|
|
`PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` +
|
|
|
|
|
`its reservation was likely expired before the payment landed and no ` +
|
|
|
|
|
`same-day train fits it. Assign it to a schedule manually from the batch board.`,
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
booking.trainScheduleId = rescuedScheduleId;
|
|
|
|
|
} else {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!booking.trainScheduleId) return; // unreachable — narrows the rescue path for TS
|
|
|
|
|
|
|
|
|
|
const isBatchPaid =
|
|
|
|
|
booking.status === "SELECTED_FOR_BATCH" ||
|
|
|
|
|
@@ -644,6 +658,69 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
await this.ensurePaidBookingAllocated(bookingId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Day-level backstop for stranded PAID bookings: reconcilePaidUnlinked is
|
|
|
|
|
* keyed on train_schedule_id, so a booking whose hold was expired (schedule
|
|
|
|
|
* cleared) before its payment landed never re-enters it. Sweep the day's
|
|
|
|
|
* PAID-but-unscheduled bookings through ensurePaidBookingAllocated, which
|
|
|
|
|
* re-places them on a fitting train.
|
|
|
|
|
*/
|
|
|
|
|
private async rescueStrandedPaidForDay(day: string): Promise<void> {
|
|
|
|
|
const stranded: Array<{ id: string }> = await this.dataSource.query(
|
|
|
|
|
`SELECT id FROM freight.bookings
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
AND train_schedule_id IS NULL
|
|
|
|
|
AND (payment_status = 'PAID' OR status = 'PAID')
|
|
|
|
|
AND scheduled_date IS NOT NULL
|
|
|
|
|
AND DATE(scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = $1`,
|
|
|
|
|
[day],
|
|
|
|
|
);
|
|
|
|
|
for (const { id } of stranded) {
|
|
|
|
|
await this.ensurePaidBookingAllocated(id).catch((err) =>
|
|
|
|
|
this.logger.error(
|
|
|
|
|
`Stranded-PAID rescue failed for booking ${id}: ${(err as Error).message}`,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Re-place a PAID booking whose hold was expired before the payment landed
|
|
|
|
|
* (trainScheduleId already cleared). Picks the earliest same-day train that
|
|
|
|
|
* still fits the booking's whole need on ITS OWN leg and pins the booking to
|
|
|
|
|
* it. Returns the schedule id, or null when no train fits (manual assign).
|
|
|
|
|
*/
|
|
|
|
|
private async replaceStrandedPaidBooking(
|
|
|
|
|
booking: Booking,
|
|
|
|
|
): Promise<string | null> {
|
|
|
|
|
if (!booking.scheduledDate) return null;
|
|
|
|
|
// The booking loaded by ensurePaidBookingAllocated carries no cargo
|
|
|
|
|
// relations; needFor/fittingTrainsForDay derive the wagon need from them.
|
|
|
|
|
const full = await this.dataSource.getRepository(Booking).findOne({
|
|
|
|
|
where: { id: booking.id },
|
|
|
|
|
relations: {
|
|
|
|
|
bookingContainers: { containerType: true },
|
|
|
|
|
cargoType: true,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
if (!full) return null;
|
|
|
|
|
const day = eatDay(new Date(booking.scheduledDate));
|
|
|
|
|
const direction = booking.tradeDirection === "EXPORT" ? "EXPORT" : "IMPORT";
|
|
|
|
|
const wagonDims = await this.loadWagonDims();
|
|
|
|
|
const need = this.needFor(full, wagonDims);
|
|
|
|
|
const fitting = await this.fittingTrainsForDay(full, day, direction);
|
|
|
|
|
const target = fitting.find((t) => t.freeWagons >= need.wagons);
|
|
|
|
|
if (!target) return null;
|
|
|
|
|
await this.dataSource
|
|
|
|
|
.getRepository(Booking)
|
|
|
|
|
.update(booking.id, { trainScheduleId: target.scheduleId });
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`[BATCH] re-placed stranded PAID booking ${booking.reference ?? booking.id} ` +
|
|
|
|
|
`onto schedule ${target.scheduleId} — its hold expired before the payment landed`,
|
|
|
|
|
);
|
|
|
|
|
return target.scheduleId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
|
|
|
|
|
async getOpenOfferSummary(bookingId: string): Promise<{
|
|
|
|
|
offeredWagons: number;
|
|
|
|
|
@@ -911,7 +988,50 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
async exportTrainOptionsForDay(
|
|
|
|
|
booking: Booking,
|
|
|
|
|
day: string,
|
|
|
|
|
overrides?: {
|
|
|
|
|
/** Cargo the customer is entering on a form (bare contract instance —
|
|
|
|
|
* nothing persisted yet): container types drive the per-type space. */
|
|
|
|
|
containerTypeIds?: string[];
|
|
|
|
|
/** Size labels ("20ft"/"40ft") when the form has no type ids. */
|
|
|
|
|
containerSizes?: string[];
|
|
|
|
|
/** Bulk counterparts of the container inputs. */
|
|
|
|
|
cargoTypeId?: string;
|
|
|
|
|
cargoTypeCode?: string;
|
|
|
|
|
/** Needed wagons estimate from the form (drives the `fits` flag). */
|
|
|
|
|
wagons?: number;
|
|
|
|
|
},
|
|
|
|
|
): Promise<ExportTrainOption[]> {
|
|
|
|
|
const sizeFts = (overrides?.containerSizes ?? [])
|
|
|
|
|
.map((s) => parseInt(s, 10))
|
|
|
|
|
.filter((n) => Number.isFinite(n) && n > 0);
|
|
|
|
|
if (overrides?.containerTypeIds?.length || sizeFts.length) {
|
|
|
|
|
const types = await this.dataSource.getRepository(ContainerType).find({
|
|
|
|
|
where: overrides?.containerTypeIds?.length
|
|
|
|
|
? { id: In(overrides.containerTypeIds) }
|
|
|
|
|
: { sizeFt: In(sizeFts) },
|
|
|
|
|
relations: { wagonTypes: true },
|
|
|
|
|
});
|
|
|
|
|
booking = {
|
|
|
|
|
...booking,
|
|
|
|
|
freightType: "CONTAINER",
|
|
|
|
|
bookingContainers: types.map((ct) => ({ containerType: ct })),
|
|
|
|
|
} as Booking;
|
|
|
|
|
} else if (overrides?.cargoTypeId || overrides?.cargoTypeCode) {
|
|
|
|
|
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
|
|
|
|
where: overrides.cargoTypeId
|
|
|
|
|
? { id: overrides.cargoTypeId }
|
|
|
|
|
: { code: overrides.cargoTypeCode },
|
|
|
|
|
relations: { wagonTypes: true },
|
|
|
|
|
});
|
|
|
|
|
booking = {
|
|
|
|
|
...booking,
|
|
|
|
|
freightType: "BULK",
|
|
|
|
|
cargoType: cargoType ?? undefined,
|
|
|
|
|
} as Booking;
|
|
|
|
|
}
|
|
|
|
|
if (overrides?.wagons && overrides.wagons > 0) {
|
|
|
|
|
booking = { ...booking, wagonsRequired: overrides.wagons } as Booking;
|
|
|
|
|
}
|
|
|
|
|
const corridor = await this.trainSchedulesRepository.findAll({
|
|
|
|
|
where: [
|
|
|
|
|
{ status: TrainScheduleStatusEnum.Draft },
|
|
|
|
|
@@ -2200,14 +2320,16 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
|
|
|
|
* shared wagon) and government bookings never split (they preempt).
|
|
|
|
|
*
|
|
|
|
|
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
|
|
|
|
* enabled: export historically rides one train whole, so splitting it changes
|
|
|
|
|
* the FCFS money path — each split part still rides ONE train whole, and the
|
|
|
|
|
* leftover becomes its own booking on the next train.
|
|
|
|
|
* IMPORT and DOMESTIC (intercity ride-along) are always eligible. EXPORT is
|
|
|
|
|
* eligible only when export split is enabled: export historically rides one
|
|
|
|
|
* train whole, so splitting it changes the FCFS money path — each split part
|
|
|
|
|
* still rides ONE train whole, and the leftover becomes its own booking on
|
|
|
|
|
* the next train.
|
|
|
|
|
*/
|
|
|
|
|
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
|
|
|
|
const directionOk =
|
|
|
|
|
booking.tradeDirection === "IMPORT" ||
|
|
|
|
|
booking.tradeDirection === "DOMESTIC" ||
|
|
|
|
|
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
|
|
|
|
return (
|
|
|
|
|
!isPair &&
|
|
|
|
|
@@ -2818,6 +2940,41 @@ export class BookingBatchService implements OnModuleInit {
|
|
|
|
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Intercity booking that does not fit its leg whole: offer the largest part
|
|
|
|
|
* that does (split-on-payment, customer notified with a pay window), sized
|
|
|
|
|
* against the leg's remaining room AND the train's physical wagon stock.
|
|
|
|
|
* Returns true when an offer was opened. The caller's budget is mutated so
|
|
|
|
|
* later bookings in the same accept pass see the offer's consumption.
|
|
|
|
|
*/
|
|
|
|
|
async offerIntercityPartial(
|
|
|
|
|
booking: Booking,
|
|
|
|
|
scheduleId: string,
|
|
|
|
|
budget: CorridorBudget,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
const wagonDims = await this.loadWagonDims();
|
|
|
|
|
const need = this.needFor(booking, wagonDims);
|
|
|
|
|
const allowed = await this.loadAllowedWagonTypeIds();
|
|
|
|
|
const wagonTypeIds = this.allowedWagonTypeIdsFor(booking, allowed);
|
|
|
|
|
const schedule =
|
|
|
|
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
|
|
|
|
if (!schedule) return false;
|
|
|
|
|
const stock = await this.stockLedgerFor(schedule, budget);
|
|
|
|
|
const cand = { id: scheduleId, budget, armed: false, stock };
|
|
|
|
|
const offered = await this.maybeOfferPartial(
|
|
|
|
|
booking,
|
|
|
|
|
false,
|
|
|
|
|
[cand],
|
|
|
|
|
need,
|
|
|
|
|
wagonTypeIds,
|
|
|
|
|
);
|
|
|
|
|
if (offered && cand.armed) {
|
|
|
|
|
this.armSettle(scheduleId);
|
|
|
|
|
this.notifyBoardChanged(scheduleId, 'intercity_partial_offered');
|
|
|
|
|
}
|
|
|
|
|
return offered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---- mutations ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|