mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
Implement consolidated booking handling and UI updates for shared wagons
This commit is contained in:
@@ -993,12 +993,14 @@ export class BookingTransitionService {
|
|||||||
|
|
||||||
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
// Export is FCFS: fail the accept up-front (409) when no export train on the
|
||||||
// booking's day still has capacity — nothing below runs and the request stays
|
// booking's day still has capacity — nothing below runs and the request stays
|
||||||
// pending for staff to move/decline.
|
// pending for staff to move/decline. (For a consolidated pair this is a rough
|
||||||
|
// solo pre-check; the real combined-capacity reservation happens after the
|
||||||
|
// booking is FULLY_EXECUTED, once both partners are ready.)
|
||||||
const isExportTrain =
|
const isExportTrain =
|
||||||
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
|
||||||
const exportScheduleId = isExportTrain
|
if (isExportTrain) {
|
||||||
? await this.bookingBatchService.pickExportSchedule(booking)
|
await this.bookingBatchService.pickExportSchedule(booking);
|
||||||
: null;
|
}
|
||||||
|
|
||||||
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -1023,11 +1025,12 @@ export class BookingTransitionService {
|
|||||||
lockedAt: booking.lockedAt ?? now,
|
lockedAt: booking.lockedAt ?? now,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
if (exportScheduleId) {
|
if (isExportTrain) {
|
||||||
// FCFS: reserve the slot and send the payment notification immediately;
|
// FCFS: reserve the slot and send the payment notification immediately;
|
||||||
// paid → auto-allocated by the settle/paid pipeline.
|
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||||
|
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||||
const fresh = await this.bookingsService.findById(booking.id);
|
const fresh = await this.bookingsService.findById(booking.id);
|
||||||
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
|
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||||
} else if (booking.tradeDirection === "IMPORT") {
|
} else if (booking.tradeDirection === "IMPORT") {
|
||||||
// Import bookings wait for their booking-day window cycle — the batch runs
|
// Import bookings wait for their booking-day window cycle — the batch runs
|
||||||
// after staff document review, never at accept time.
|
// after staff document review, never at accept time.
|
||||||
|
|||||||
@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||||
* (same route, same container type, partial wagon on both sides).
|
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||||
|
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
|
||||||
|
*
|
||||||
|
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
|
||||||
|
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
|
||||||
|
* the same train is useless. The day filter is applied only when THIS booking already has
|
||||||
|
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
|
||||||
*/
|
*/
|
||||||
async findComplementaryConsolidationPartner(
|
async findComplementaryConsolidationPartner(
|
||||||
booking: Booking,
|
booking: Booking,
|
||||||
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
): Promise<Booking | null> {
|
): Promise<Booking | null> {
|
||||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||||
|
|
||||||
return this.repository
|
const qb = this.repository
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||||
.innerJoin('bc.containerType', 'ct')
|
.innerJoin('bc.containerType', 'ct')
|
||||||
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||||
quantity,
|
quantity,
|
||||||
perWagon,
|
perWagon,
|
||||||
})
|
});
|
||||||
.orderBy('b.createdAt', 'ASC')
|
|
||||||
.getOne();
|
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
|
||||||
|
// when this booking has no date yet (matched again once it picks its day).
|
||||||
|
if (booking.scheduledDate) {
|
||||||
|
qb.andWhere(
|
||||||
|
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||||
|
{ bookingDate: booking.scheduledDate },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb.orderBy('b.createdAt', 'ASC').getOne();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||||
|
|||||||
@@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reserves both partners of a consolidated pair together on one train', async () => {
|
||||||
|
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
|
||||||
|
const consol = (id: string, partnerId: string, priority: number): Booking =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
reference: id,
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: priority,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: partnerId,
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||||
|
consol('a', 'b', 30),
|
||||||
|
consol('b', 'a', 20),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Both reserved on the same (first) train; neither reported unplaced.
|
||||||
|
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||||
|
expect(reservedIds.sort()).toEqual(['a', 'b']);
|
||||||
|
expect(notifier.unplaced).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
|
||||||
|
const lonely = {
|
||||||
|
id: 'a',
|
||||||
|
reference: 'a',
|
||||||
|
isGovernment: false,
|
||||||
|
priorityScore: 30,
|
||||||
|
status: 'FULLY_EXECUTED',
|
||||||
|
wagonsRequired: 1,
|
||||||
|
cargoTotalWeightVgm: 10,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
consolidationPartnerId: 'missing-partner',
|
||||||
|
bookingContainers: [{ quantity: 1 }],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
|
||||||
|
|
||||||
|
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||||
|
|
||||||
|
// Never reserved — waits for its partner in a later cycle.
|
||||||
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { BookingSplitService } from './booking-split.service';
|
import { BookingSplitService } from './booking-split.service';
|
||||||
|
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
|
||||||
|
|
||||||
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
/** A train's remaining capacity along the three physical limits the batch enforces. */
|
||||||
interface Capacity {
|
interface Capacity {
|
||||||
@@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
/** Set when this booking shares a wagon with a consolidation partner. */
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
@@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
* fits the booking. Throws ConflictException when every train is full — the
|
* fits the booking. Throws ConflictException when every train is full — the
|
||||||
* staff accept fails and no more export bookings are taken.
|
* staff accept fails and no more export bookings are taken.
|
||||||
*/
|
*/
|
||||||
async pickExportSchedule(booking: Booking): Promise<string> {
|
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||||
if (!booking.scheduledDate) {
|
if (!booking.scheduledDate) {
|
||||||
throw new BadRequestException('Booking has no scheduled date');
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
}
|
}
|
||||||
@@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const rules = await this.loadGlobalRules();
|
const rules = await this.loadGlobalRules();
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const required = need ?? this.needFor(booking, wagonLengths);
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
@@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (!schedule || !locomotive) continue;
|
if (!schedule || !locomotive) continue;
|
||||||
const limits = await this.capacityLimits(locomotive, rules);
|
const limits = await this.capacityLimits(locomotive, rules);
|
||||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||||
if (this.fits(need, budget)) return schedule.id;
|
if (this.fits(required, budget)) return schedule.id;
|
||||||
}
|
}
|
||||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
throw new ConflictException('Train is full — no export capacity left for this day');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserve an accepted export booking on its picked train and open the pay
|
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||||
* window immediately (payment notification goes out on reserve). Marks the
|
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||||
* train FULL when this reservation exhausts the wagon budget.
|
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
|
||||||
|
* against the combined shared-wagon need; the first partner's accept just waits.
|
||||||
|
* Throws ConflictException (before this booking is persisted-ready) when there is
|
||||||
|
* no export capacity for the day, so staff accept fails.
|
||||||
*/
|
*/
|
||||||
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
|
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||||
await this.reserve(booking, scheduleId);
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking);
|
||||||
|
await this.reserveOnExport([booking], scheduleId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partner = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
|
||||||
|
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
|
||||||
|
// waits; the partner's later accept will reserve the pair.
|
||||||
|
if (!partner || partner.status !== 'FULLY_EXECUTED') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
|
const need = this.combinedNeed(booking, partner, wagonLengths);
|
||||||
|
const scheduleId = await this.pickExportSchedule(booking, need);
|
||||||
|
await this.reserveOnExport([booking, partner], scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
|
||||||
|
private async reserveOnExport(
|
||||||
|
bookings: Booking[],
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const b of bookings) await this.reserve(b, scheduleId);
|
||||||
this.armSettle(scheduleId);
|
this.armSettle(scheduleId);
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
@@ -622,6 +655,27 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
allocationPreview.issues.map((i) => [i.bookingId, i]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Resolve consolidation-partner references for the shared-wagon badge. Most
|
||||||
|
// partners are on this same schedule; look up any that aren't in one query.
|
||||||
|
const refById = new Map(
|
||||||
|
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
|
||||||
|
);
|
||||||
|
const missingPartnerIds = [
|
||||||
|
...new Set(
|
||||||
|
bookings
|
||||||
|
.map((b) => b.consolidationPartnerId)
|
||||||
|
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (missingPartnerIds.length) {
|
||||||
|
const partners = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.find({ where: { id: In(missingPartnerIds) } });
|
||||||
|
for (const p of partners) {
|
||||||
|
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
|
||||||
const need = this.needFor(b, wagonLengths);
|
const need = this.needFor(b, wagonLengths);
|
||||||
const alloc = allocationByBooking.get(b.id);
|
const alloc = allocationByBooking.get(b.id);
|
||||||
@@ -647,6 +701,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
: null,
|
: null,
|
||||||
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
|
||||||
allocationIssue: alloc?.issue ?? null,
|
allocationIssue: alloc?.issue ?? null,
|
||||||
|
consolidationPartnerId: b.consolidationPartnerId ?? null,
|
||||||
|
consolidationPartnerRef: b.consolidationPartnerId
|
||||||
|
? (refById.get(b.consolidationPartnerId) ?? null)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -903,13 +961,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
if (!this.fits(need, budget)) {
|
if (!this.fits(need, budget)) {
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
budget = await this.preemptForGovernment(
|
budget = await this.preemptForGovernment(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
need,
|
need,
|
||||||
@@ -918,14 +982,16 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
continue; // skip a booking that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(scheduleId, booking, "gov");
|
await this.allocate(scheduleId, booking, "gov");
|
||||||
|
if (partner) await this.allocate(scheduleId, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, scheduleId);
|
await this.reserve(booking, scheduleId);
|
||||||
|
if (partner) await this.reserve(partner, scheduleId);
|
||||||
armed = true;
|
armed = true;
|
||||||
}
|
}
|
||||||
budget = this.subtract(budget, need);
|
budget = this.subtract(budget, need);
|
||||||
@@ -1013,15 +1079,23 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
destinationYardId,
|
destinationYardId,
|
||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
|
// Consolidated partners collapse into one atomic unit (both-or-neither); a
|
||||||
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
|
const units = this.groupConsolidatedPool(pool);
|
||||||
|
|
||||||
for (const booking of pool) {
|
for (const unit of units) {
|
||||||
const need = this.needFor(booking, wagonLengths);
|
const { primary: booking, partner } = unit;
|
||||||
|
const isPair = partner != null;
|
||||||
|
const need = isPair
|
||||||
|
? this.combinedNeed(booking, partner, wagonLengths)
|
||||||
|
: this.needFor(booking, wagonLengths);
|
||||||
|
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
|
||||||
|
|
||||||
// First train (earliest departure) that fits this booking as-is.
|
// First train (earliest departure) that fits this unit as-is.
|
||||||
let target = trains.find((t) => this.fits(need, t.budget));
|
let target = trains.find((t) => this.fits(need, t.budget));
|
||||||
|
|
||||||
if (!target && booking.isGovernment) {
|
if (!target && isGov) {
|
||||||
// Government booking fits nowhere on its own — try to preempt commercial
|
// Government fits nowhere on its own — try to preempt commercial
|
||||||
// on each train (earliest first) until one frees enough room.
|
// on each train (earliest first) until one frees enough room.
|
||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
t.budget = await this.preemptForGovernment(
|
t.budget = await this.preemptForGovernment(
|
||||||
@@ -1038,41 +1112,45 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
// A consolidated pair is placed whole or not at all — never split.
|
||||||
// partial-capacity offer on the train with the most free wagons: pay =
|
if (!isPair) {
|
||||||
// accept the split (remainder returns to the contract cap), no pay =
|
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||||
// booking stays whole and expires for this train.
|
// partial-capacity offer on the train with the most free wagons.
|
||||||
const partialTarget = [...trains]
|
const partialTarget = [...trains]
|
||||||
.filter((t) => t.budget.wagons >= 1)
|
.filter((t) => t.budget.wagons >= 1)
|
||||||
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
|
||||||
if (
|
if (
|
||||||
partialTarget &&
|
partialTarget &&
|
||||||
!booking.isGovernment &&
|
!booking.isGovernment &&
|
||||||
booking.tradeDirection === "IMPORT" &&
|
booking.tradeDirection === "IMPORT" &&
|
||||||
booking.contractKind === "GENERAL" &&
|
booking.contractKind === "GENERAL" &&
|
||||||
this.splitService
|
this.splitService
|
||||||
) {
|
) {
|
||||||
const offered = await this.tryPartialOffer(
|
const offered = await this.tryPartialOffer(
|
||||||
booking,
|
booking,
|
||||||
partialTarget.id,
|
partialTarget.id,
|
||||||
partialTarget.budget,
|
partialTarget.budget,
|
||||||
need,
|
need,
|
||||||
);
|
);
|
||||||
if (offered) {
|
if (offered) {
|
||||||
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
partialTarget.budget = this.subtract(partialTarget.budget, offered);
|
||||||
partialTarget.armed = true;
|
partialTarget.armed = true;
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stays in the pool, retried next batch/window cycle.
|
// Stays in the pool, retried next batch/window cycle.
|
||||||
this.notifier.unplaced(booking, day);
|
this.notifier.unplaced(booking, day);
|
||||||
|
if (partner) this.notifier.unplaced(partner, day);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (booking.isGovernment) {
|
if (isGov) {
|
||||||
await this.allocate(target.id, booking, "gov");
|
await this.allocate(target.id, booking, "gov");
|
||||||
|
if (partner) await this.allocate(target.id, partner, "gov");
|
||||||
} else {
|
} else {
|
||||||
await this.reserve(booking, target.id);
|
await this.reserve(booking, target.id);
|
||||||
|
if (partner) await this.reserve(partner, target.id);
|
||||||
target.armed = true;
|
target.armed = true;
|
||||||
}
|
}
|
||||||
target.budget = this.subtract(target.budget, need);
|
target.budget = this.subtract(target.budget, need);
|
||||||
@@ -1099,6 +1177,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
need: Capacity,
|
need: Capacity,
|
||||||
): Promise<Capacity | null> {
|
): Promise<Capacity | null> {
|
||||||
if (!this.splitService) return null;
|
if (!this.splitService) return null;
|
||||||
|
// A consolidated booking is already half of a shared wagon — never split it.
|
||||||
|
if (booking.consolidationPartnerId) return null;
|
||||||
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
if (await this.splitService.findOpenOffer(booking.id)) return null;
|
||||||
|
|
||||||
const wagonLengths = await this.loadWagonLengths();
|
const wagonLengths = await this.loadWagonLengths();
|
||||||
@@ -1143,29 +1223,69 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return capacity > 0 ? capacity : 60;
|
return capacity > 0 ? capacity : 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
/**
|
||||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
|
||||||
|
* how to treat a reservation with no deadline (durable path: leave it; timeout
|
||||||
|
* path: expire it). Consolidated pairs settle atomically: both allocate only
|
||||||
|
* when both paid; if either partner expires, both expire (a half-paid shared
|
||||||
|
* wagon must not ship). Returns whether anything changed.
|
||||||
|
*/
|
||||||
|
private async settleReserved(
|
||||||
|
scheduleId: string,
|
||||||
|
expireUnpaidUnknownDeadline: boolean,
|
||||||
|
): Promise<boolean> {
|
||||||
const reserved =
|
const reserved =
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const byId = new Map(reserved.map((b) => [b.id, b]));
|
||||||
|
const done = new Set<string>();
|
||||||
let anySettled = false;
|
let anySettled = false;
|
||||||
|
|
||||||
for (const booking of reserved) {
|
const isPaid = (b: Booking) =>
|
||||||
const paid =
|
b.paymentStatus === "PAID" || b.status === "PAID";
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
const isExpired = (b: Booking) =>
|
||||||
const expired = booking.paymentDeadline
|
b.paymentDeadline
|
||||||
? booking.paymentDeadline.getTime() <= now
|
? b.paymentDeadline.getTime() <= now
|
||||||
: false;
|
: expireUnpaidUnknownDeadline;
|
||||||
|
|
||||||
if (paid) {
|
for (const booking of reserved) {
|
||||||
|
if (done.has(booking.id)) continue;
|
||||||
|
const partner = booking.consolidationPartnerId
|
||||||
|
? (byId.get(booking.consolidationPartnerId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (partner) {
|
||||||
|
done.add(booking.id);
|
||||||
|
done.add(partner.id);
|
||||||
|
// Both-or-neither: allocate the shared wagon only when both partners paid;
|
||||||
|
// if either lapsed, expire both so no half-paid wagon rides.
|
||||||
|
if (isPaid(booking) && isPaid(partner)) {
|
||||||
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
|
await this.allocate(scheduleId, partner, "paid");
|
||||||
|
anySettled = true;
|
||||||
|
} else if (isExpired(booking) || isExpired(partner)) {
|
||||||
|
await this.expire(booking);
|
||||||
|
await this.expire(partner);
|
||||||
|
anySettled = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
done.add(booking.id);
|
||||||
|
if (isPaid(booking)) {
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
await this.allocate(scheduleId, booking, "paid");
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
} else if (expired) {
|
} else if (isExpired(booking)) {
|
||||||
await this.expire(booking);
|
await this.expire(booking);
|
||||||
anySettled = true;
|
anySettled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return anySettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||||
|
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||||
|
const anySettled = await this.settleReserved(scheduleId, false);
|
||||||
if (anySettled) await this.fillSchedule(scheduleId);
|
if (anySettled) await this.fillSchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,25 +1294,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
/** Allocate paid reservations, expire the rest, then top up. */
|
/** Allocate paid reservations, expire the rest, then top up. */
|
||||||
async settleBatch(scheduleId: string): Promise<void> {
|
async settleBatch(scheduleId: string): Promise<void> {
|
||||||
this.removeTimeout(scheduleId);
|
this.removeTimeout(scheduleId);
|
||||||
const reserved =
|
await this.settleReserved(scheduleId, true);
|
||||||
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const booking of reserved) {
|
|
||||||
const paid =
|
|
||||||
booking.paymentStatus === "PAID" || booking.status === "PAID";
|
|
||||||
const expired = booking.paymentDeadline
|
|
||||||
? booking.paymentDeadline.getTime() <= now
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (paid) {
|
|
||||||
await this.allocate(scheduleId, booking, "paid");
|
|
||||||
} else if (expired) {
|
|
||||||
await this.expire(booking);
|
|
||||||
}
|
|
||||||
// else: still within window (rare at settle) → leave for the re-armed timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.fillSchedule(scheduleId);
|
await this.fillSchedule(scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
}
|
}
|
||||||
@@ -1452,6 +1554,74 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
// ---- capacity helpers -----------------------------------------------------
|
// ---- capacity helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse consolidated partners into single pool entries so the fill treats a
|
||||||
|
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
|
||||||
|
* - no `consolidationPartnerId` → passes through as a lone booking.
|
||||||
|
* - consolidated + partner also in this pool → emitted ONCE (at the position of
|
||||||
|
* whichever partner ranks first) as a pair; the partner is not emitted again.
|
||||||
|
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
|
||||||
|
* it waits for the partner to become ready in a later cycle).
|
||||||
|
* The pool is already priority-ordered, so emitting the pair at the first-seen
|
||||||
|
* partner's slot ranks it by the stronger (max-priority) partner automatically.
|
||||||
|
*/
|
||||||
|
private groupConsolidatedPool(
|
||||||
|
pool: Booking[],
|
||||||
|
): Array<{ primary: Booking; partner: Booking | null }> {
|
||||||
|
const byId = new Map(pool.map((b) => [b.id, b]));
|
||||||
|
const emitted = new Set<string>();
|
||||||
|
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
|
||||||
|
for (const booking of pool) {
|
||||||
|
if (emitted.has(booking.id)) continue;
|
||||||
|
const partnerId = booking.consolidationPartnerId ?? null;
|
||||||
|
if (!partnerId) {
|
||||||
|
emitted.add(booking.id);
|
||||||
|
units.push({ primary: booking, partner: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const partner = byId.get(partnerId) ?? null;
|
||||||
|
if (!partner) {
|
||||||
|
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
|
||||||
|
emitted.add(booking.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emitted.add(booking.id);
|
||||||
|
emitted.add(partner.id);
|
||||||
|
units.push({ primary: booking, partner });
|
||||||
|
}
|
||||||
|
return units;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
|
||||||
|
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
|
||||||
|
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
|
||||||
|
* independently-rounded-up needs (that is the capacity consolidation saves).
|
||||||
|
*/
|
||||||
|
private combinedNeed(
|
||||||
|
primary: Booking,
|
||||||
|
partner: Booking,
|
||||||
|
wagonLengths: WagonLengths,
|
||||||
|
): Capacity {
|
||||||
|
const containers = (b: Booking): number =>
|
||||||
|
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
|
||||||
|
const totalContainers = containers(primary) + containers(partner);
|
||||||
|
const sharedWagons =
|
||||||
|
totalContainers > 0
|
||||||
|
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
|
||||||
|
: this.wagonsFor(primary) + this.wagonsFor(partner);
|
||||||
|
const weightTons =
|
||||||
|
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
|
||||||
|
return {
|
||||||
|
wagons: sharedWagons,
|
||||||
|
weightTons,
|
||||||
|
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
|
||||||
|
container: wagonLengths.container,
|
||||||
|
bulk: wagonLengths.bulk,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private wagonsFor(booking: Booking): number {
|
private wagonsFor(booking: Booking): number {
|
||||||
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
|
||||||
return Math.ceil(booking.wagonsRequired);
|
return Math.ceil(booking.wagonsRequired);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowLeftRight,
|
||||||
Boxes,
|
Boxes,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -242,6 +243,25 @@ const BOOKING_COLUMNS: ColumnDef<BatchBoardBookingDetail>[] = [
|
|||||||
Gov
|
Gov
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
{b.consolidationPartnerRef ? (
|
||||||
|
<Tooltip
|
||||||
|
label={`Consolidated — shares one wagon with ${b.consolidationPartnerRef}`}
|
||||||
|
withArrow
|
||||||
|
multiline
|
||||||
|
maw={260}
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<ArrowLeftRight size={10} />}
|
||||||
|
style={{ textTransform: "none" }}
|
||||||
|
>
|
||||||
|
shared wagon · {b.consolidationPartnerRef}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
|||||||
selectedForBatchAt: string | null;
|
selectedForBatchAt: string | null;
|
||||||
allocationStatus: BookingAllocationStatus;
|
allocationStatus: BookingAllocationStatus;
|
||||||
allocationIssue: string | null;
|
allocationIssue: string | null;
|
||||||
|
consolidationPartnerId: string | null;
|
||||||
|
consolidationPartnerRef: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BatchWindowGroup {
|
export interface BatchWindowGroup {
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ export function BookingPaymentPanel({
|
|||||||
: priceTotal(pricing);
|
: priceTotal(pricing);
|
||||||
const items = priceLineItems(pricing);
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
|
// Consolidation: this shipment shares a wagon with a partner booking, and the
|
||||||
|
// wagon is only scheduled once both partners have paid. Surface a note while
|
||||||
|
// payment is still pending (pay-window open, or a deadline set and not paid).
|
||||||
|
const showConsolidationNote =
|
||||||
|
!paid &&
|
||||||
|
Boolean(booking.consolidationPartnerId) &&
|
||||||
|
(booking.status === "SELECTED_FOR_BATCH" ||
|
||||||
|
Boolean(booking.paymentDeadline));
|
||||||
|
|
||||||
const { data: invoices = [] } = useQuery({
|
const { data: invoices = [] } = useQuery({
|
||||||
queryKey: ["booking-invoices", booking.id],
|
queryKey: ["booking-invoices", booking.id],
|
||||||
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
queryFn: () => invoicesService.listForSource("booking", booking.id),
|
||||||
@@ -268,6 +277,12 @@ export function BookingPaymentPanel({
|
|||||||
onPay={onPay}
|
onPay={onPay}
|
||||||
paying={paying}
|
paying={paying}
|
||||||
/>
|
/>
|
||||||
|
{showConsolidationNote && (
|
||||||
|
<Text mt={12} fz="12px" c="#9AA8B5" lh={1.5}>
|
||||||
|
This shipment shares a wagon with a consolidation partner — both
|
||||||
|
shipments must be paid for the wagon to be scheduled.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Divider />
|
<Divider />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user