mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
changes
This commit is contained in:
@@ -1057,6 +1057,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commercial bookings on the day's corridor whose operation request was NOT
|
||||||
|
* accepted by staff (still pending / changes / price-confirm) and are not yet
|
||||||
|
* linked to a train. These never reached FULLY_EXECUTED, so they never enter the
|
||||||
|
* batch pool; the window's doc-review end sweeps them to EXPIRED. Government
|
||||||
|
* bookings are excluded (they don't go through the customer window).
|
||||||
|
*/
|
||||||
|
findUnacceptedForRouteDay(
|
||||||
|
corridorYardIds: string[],
|
||||||
|
day: string,
|
||||||
|
): Promise<Booking[]> {
|
||||||
|
if (corridorYardIds.length === 0) return Promise.resolve([]);
|
||||||
|
return this.repository
|
||||||
|
.createQueryBuilder('booking')
|
||||||
|
.leftJoinAndSelect('booking.company', 'company')
|
||||||
|
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||||
|
.where('booking.origin_yard_id IN (:...corridorYardIds)', { corridorYardIds })
|
||||||
|
.andWhere('booking.destination_yard_id IN (:...corridorYardIds)', {
|
||||||
|
corridorYardIds,
|
||||||
|
})
|
||||||
|
.andWhere(
|
||||||
|
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||||
|
{ day },
|
||||||
|
)
|
||||||
|
.andWhere('sb.id IS NULL')
|
||||||
|
.andWhere('booking.is_government = false')
|
||||||
|
.andWhere(
|
||||||
|
`booking.status IN (
|
||||||
|
'OPERATION_REQUESTED',
|
||||||
|
'OPERATION_REQUEST_PENDING',
|
||||||
|
'OPERATION_CHANGES_REQUESTED',
|
||||||
|
'OPERATION_PRICE_PENDING_CONFIRM'
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||||
return this.repository
|
return this.repository
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
findBatchPool: jest.Mock;
|
findBatchPool: jest.Mock;
|
||||||
findBatchPoolByRouteDay: jest.Mock;
|
findBatchPoolByRouteDay: jest.Mock;
|
||||||
findBatchPoolByCorridorDay: jest.Mock;
|
findBatchPoolByCorridorDay: jest.Mock;
|
||||||
|
findUnacceptedForRouteDay: jest.Mock;
|
||||||
findReservedForSchedule: jest.Mock;
|
findReservedForSchedule: jest.Mock;
|
||||||
update: jest.Mock;
|
update: jest.Mock;
|
||||||
};
|
};
|
||||||
@@ -55,6 +56,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||||
|
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
|
||||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||||
update: jest.fn().mockResolvedValue(undefined),
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
@@ -332,4 +334,177 @@ describe('BookingBatchService — PAID reconcile', () => {
|
|||||||
expect(notifier.payNow).not.toHaveBeenCalled();
|
expect(notifier.payNow).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('expireUnacceptedForRouteDay — doc-review sweep', () => {
|
||||||
|
const originYardId = 'yard-origin';
|
||||||
|
const destinationYardId = 'yard-dest';
|
||||||
|
const day = '2026-06-20';
|
||||||
|
|
||||||
|
const pendingBooking = {
|
||||||
|
id: 'pending-1',
|
||||||
|
reference: 'BK-PENDING-1',
|
||||||
|
status: 'OPERATION_REQUEST_PENDING',
|
||||||
|
isGovernment: false,
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// One fillable schedule on this corridor/day so corridorYardsForRouteDay
|
||||||
|
// resolves a non-empty yard set (legacy two-stop route → [origin, dest]).
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'sched-1',
|
||||||
|
originStationId: originYardId,
|
||||||
|
destinationStationId: destinationYardId,
|
||||||
|
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expires each un-accepted booking and clears its scheduled day', async () => {
|
||||||
|
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([pendingBooking]);
|
||||||
|
|
||||||
|
await service.expireUnacceptedForRouteDay({
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bookingsRepository.findUnacceptedForRouteDay).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([originYardId, destinationYardId]),
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||||
|
'pending-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'EXPIRED',
|
||||||
|
schedulingStatus: 'ELIGIBLE',
|
||||||
|
scheduledDate: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifier.expired).toHaveBeenCalledWith(pendingBooking);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when nothing is un-accepted', async () => {
|
||||||
|
bookingsRepository.findUnacceptedForRouteDay.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.expireUnacceptedForRouteDay({
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bookingsRepository.update).not.toHaveBeenCalled();
|
||||||
|
expect(notifier.expired).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the route-day has no fillable schedule', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.expireUnacceptedForRouteDay({
|
||||||
|
originYardId,
|
||||||
|
destinationYardId,
|
||||||
|
day,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bookingsRepository.findUnacceptedForRouteDay).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maybeOfferPartial — split-eligibility gate', () => {
|
||||||
|
const importGeneral = {
|
||||||
|
id: 'b1',
|
||||||
|
reference: 'b1',
|
||||||
|
isGovernment: false,
|
||||||
|
tradeDirection: 'IMPORT',
|
||||||
|
contractKind: 'GENERAL',
|
||||||
|
consolidationPartnerId: null,
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
const call = (booking: Booking, isPair: boolean): boolean =>
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||||
|
}
|
||||||
|
).isSplitEligible(booking, isPair);
|
||||||
|
|
||||||
|
it('allows IMPORT + GENERAL when splitService is present', () => {
|
||||||
|
const withSplit = new BookingBatchService(
|
||||||
|
dataSource as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
trainScheduleBookingsRepository as never,
|
||||||
|
notifier as never,
|
||||||
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
|
trainSchedulingService as never,
|
||||||
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
undefined,
|
||||||
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const eligible = (
|
||||||
|
withSplit as unknown as {
|
||||||
|
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||||
|
}
|
||||||
|
).isSplitEligible(importGeneral, false);
|
||||||
|
expect(eligible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows IMPORT + ONE_TIME (promoted to GENERAL on split)', () => {
|
||||||
|
const withSplit = new BookingBatchService(
|
||||||
|
dataSource as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
trainScheduleBookingsRepository as never,
|
||||||
|
notifier as never,
|
||||||
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
|
trainSchedulingService as never,
|
||||||
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
undefined,
|
||||||
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const eligible = (
|
||||||
|
withSplit as unknown as {
|
||||||
|
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||||
|
}
|
||||||
|
).isSplitEligible(
|
||||||
|
{ ...importGeneral, contractKind: 'ONE_TIME' } as Booking,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(eligible).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when splitService is absent (default test service)', () => {
|
||||||
|
// `service` from the outer beforeEach was built without a splitService.
|
||||||
|
expect(call(importGeneral, false)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects EXPORT, government, consolidated pairs, and other directions', () => {
|
||||||
|
const withSplit = new BookingBatchService(
|
||||||
|
dataSource as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
trainScheduleBookingsRepository as never,
|
||||||
|
notifier as never,
|
||||||
|
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||||
|
trainSchedulingService as never,
|
||||||
|
{ syncPayableDueDate: jest.fn(), expirePayable: jest.fn() } as never,
|
||||||
|
{ emitPhase: jest.fn() } as never,
|
||||||
|
undefined,
|
||||||
|
{ findOpenOffer: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
const check = (
|
||||||
|
withSplit as unknown as {
|
||||||
|
isSplitEligible: (b: Booking, p: boolean) => boolean;
|
||||||
|
}
|
||||||
|
).isSplitEligible.bind(withSplit);
|
||||||
|
|
||||||
|
expect(check({ ...importGeneral, tradeDirection: 'EXPORT' } as Booking, false)).toBe(false);
|
||||||
|
expect(check({ ...importGeneral, isGovernment: true } as Booking, false)).toBe(false);
|
||||||
|
expect(check(importGeneral, true)).toBe(false); // consolidated pair
|
||||||
|
expect(check({ ...importGeneral, contractKind: null } as Booking, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1026,6 +1026,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
|
||||||
|
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||||
|
// is confirmed. Logs the caps + pool so we can see which axis rejects unit #2.
|
||||||
|
this.logger.debug(
|
||||||
|
`[fillSchedule ${scheduleId}] limits=${JSON.stringify(limits)} ` +
|
||||||
|
`maxWagons=${schedule.maxWagons} remaining=${JSON.stringify(budget.maxRemaining())} ` +
|
||||||
|
`poolSize=${pool.length} units=${units.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const { primary: booking, partner } = unit;
|
const { primary: booking, partner } = unit;
|
||||||
const isPair = partner != null;
|
const isPair = partner != null;
|
||||||
@@ -1037,6 +1045,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// stands for the pair.
|
// stands for the pair.
|
||||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||||
|
|
||||||
|
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||||
|
this.logger.debug(
|
||||||
|
`[fillSchedule ${scheduleId}] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||||
|
`roomOnLeg=${JSON.stringify(budget.remainingFor(leg))} fits=${budget.fits(need, leg)}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!budget.fits(need, leg)) {
|
if (!budget.fits(need, leg)) {
|
||||||
if (isGov) {
|
if (isGov) {
|
||||||
const freed = await this.preemptForGovernment(
|
const freed = await this.preemptForGovernment(
|
||||||
@@ -1048,6 +1062,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (!freed) continue; // still doesn't fit even after preempt
|
if (!freed) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
|
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||||
|
// that fits in the remaining room (top-up path splits the boundary
|
||||||
|
// booking, mirroring fillRouteDay); otherwise skip and try the next.
|
||||||
|
const cand: { id: string; budget: CorridorBudget; armed: boolean } = {
|
||||||
|
id: scheduleId,
|
||||||
|
budget,
|
||||||
|
armed,
|
||||||
|
};
|
||||||
|
if (await this.maybeOfferPartial(booking, isPair, [cand], need)) {
|
||||||
|
armed = cand.armed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
continue; // skip a unit that exceeds weight/length/wagons, try the next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1149,6 +1175,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
|
|
||||||
|
// TODO(change-c-diagnostics): remove once the "only 1 reserved" capacity cause
|
||||||
|
// is confirmed. Shows each train's caps + the day pool size.
|
||||||
|
this.logger.debug(
|
||||||
|
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] ` +
|
||||||
|
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
|
||||||
|
`poolSize=${pool.length} units=${units.length}`,
|
||||||
|
);
|
||||||
|
|
||||||
for (const unit of units) {
|
for (const unit of units) {
|
||||||
const { primary: booking, partner } = unit;
|
const { primary: booking, partner } = unit;
|
||||||
const isPair = partner != null;
|
const isPair = partner != null;
|
||||||
@@ -1167,6 +1201,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return leg != null && t.budget.fits(need, leg);
|
return leg != null && t.budget.fits(need, leg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// TODO(change-c-diagnostics): remove once the capacity cause is confirmed.
|
||||||
|
this.logger.debug(
|
||||||
|
`[fillRouteDay] unit ${booking.reference}: need=${JSON.stringify(need)} ` +
|
||||||
|
`targetTrain=${target?.id ?? "none"} ` +
|
||||||
|
`rooms=${trains
|
||||||
|
.map((t) => {
|
||||||
|
const leg = legOn(t);
|
||||||
|
return leg ? `${t.id}:${JSON.stringify(t.budget.remainingFor(leg))}` : `${t.id}:offleg`;
|
||||||
|
})
|
||||||
|
.join(",")}`,
|
||||||
|
);
|
||||||
|
|
||||||
if (!target && isGov) {
|
if (!target && isGov) {
|
||||||
// Government fits nowhere on its own — try to preempt commercial
|
// Government fits nowhere on its own — try to preempt commercial
|
||||||
// on each corridor-matching train (earliest first) until one frees room.
|
// on each corridor-matching train (earliest first) until one frees room.
|
||||||
@@ -1188,38 +1234,14 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
// A consolidated pair is placed whole or not at all — never split.
|
// Fits no train whole. A split-eligible booking is offered the largest
|
||||||
if (!isPair) {
|
// part that fits on the train with the most free wagons on its leg (this
|
||||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
// covers both "fits nowhere" and the boundary case where earlier bookings
|
||||||
// partial-capacity offer on the train with the most free wagons on the
|
// already consumed most of the room). Consolidated pairs / government /
|
||||||
// booking's own leg.
|
// non-import never split — isSplitEligible guards that. Passing the live
|
||||||
const partialTarget = trains
|
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||||
.map((t) => {
|
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||||
const leg = legOn(t);
|
if (offered) continue;
|
||||||
return leg ? { t, leg, room: t.budget.remainingFor(leg) } : null;
|
|
||||||
})
|
|
||||||
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
|
||||||
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
|
||||||
if (
|
|
||||||
partialTarget &&
|
|
||||||
!booking.isGovernment &&
|
|
||||||
booking.tradeDirection === "IMPORT" &&
|
|
||||||
booking.contractKind === "GENERAL" &&
|
|
||||||
this.splitService
|
|
||||||
) {
|
|
||||||
const offered = await this.tryPartialOffer(
|
|
||||||
booking,
|
|
||||||
partialTarget.t.id,
|
|
||||||
partialTarget.room,
|
|
||||||
need,
|
|
||||||
);
|
|
||||||
if (offered) {
|
|
||||||
partialTarget.t.budget.subtract(offered, partialTarget.leg);
|
|
||||||
partialTarget.t.armed = true;
|
|
||||||
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);
|
if (partner) this.notifier.unplaced(partner, day);
|
||||||
@@ -1246,6 +1268,57 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
return trains.map((t) => t.id);
|
return trains.map((t) => t.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||||
|
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||||
|
* neither shared wagon) and government bookings never split (they preempt).
|
||||||
|
*/
|
||||||
|
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||||
|
return (
|
||||||
|
!isPair &&
|
||||||
|
!booking.isGovernment &&
|
||||||
|
booking.tradeDirection === "IMPORT" &&
|
||||||
|
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||||
|
this.splitService != null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer the largest fitting part of a booking that does not fit any candidate
|
||||||
|
* train whole, on the train with the most free wagons on the booking's leg.
|
||||||
|
* Mutates the chosen candidate's budget + armed flag in place. Returns true when
|
||||||
|
* an offer was opened (caller should `continue` past this unit), false otherwise.
|
||||||
|
* Shared by fillRouteDay (multi-train) and fillSchedule (single train). The leg
|
||||||
|
* is computed per candidate from the booking's yards, so callers pass their live
|
||||||
|
* train entries and only leg-carrying trains are considered.
|
||||||
|
*/
|
||||||
|
private async maybeOfferPartial(
|
||||||
|
booking: Booking,
|
||||||
|
isPair: boolean,
|
||||||
|
candidates: Array<{ id: string; budget: CorridorBudget; armed: boolean }>,
|
||||||
|
need: Capacity,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.isSplitEligible(booking, isPair)) return false;
|
||||||
|
const target = candidates
|
||||||
|
.map((c) => {
|
||||||
|
const leg = c.budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
|
return leg ? { c, leg, room: c.budget.remainingFor(leg) } : null;
|
||||||
|
})
|
||||||
|
.filter((x): x is NonNullable<typeof x> => x != null && x.room.wagons >= 1)
|
||||||
|
.sort((a, b) => b.room.wagons - a.room.wagons)[0];
|
||||||
|
if (!target) return false;
|
||||||
|
const offered = await this.tryPartialOffer(
|
||||||
|
booking,
|
||||||
|
target.c.id,
|
||||||
|
target.room,
|
||||||
|
need,
|
||||||
|
);
|
||||||
|
if (!offered) return false;
|
||||||
|
target.c.budget.subtract(offered, target.leg);
|
||||||
|
target.c.armed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer the largest fitting part of an over-capacity booking as a partial
|
* Offer the largest fitting part of an over-capacity booking as a partial
|
||||||
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
* (split-on-payment). Returns the capacity the offer consumes, or null when no
|
||||||
@@ -1659,6 +1732,79 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.notifier.expired(booking);
|
this.notifier.expired(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of stop yards across the day's fillable schedules on this corridor —
|
||||||
|
* the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings
|
||||||
|
* are covered. Empty when no fillable schedule exists for the group.
|
||||||
|
*/
|
||||||
|
private async corridorYardsForRouteDay(
|
||||||
|
group: RouteDayGroup,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const corridor = await this.trainSchedulesRepository.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originStationId: group.originYardId,
|
||||||
|
destinationStationId: group.destinationYardId,
|
||||||
|
status: TrainScheduleStatusEnum.Scheduled,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const yards = new Set<string>();
|
||||||
|
for (const schedule of corridor) {
|
||||||
|
if (
|
||||||
|
schedule.scheduledDepartureDate == null ||
|
||||||
|
eatDay(schedule.scheduledDepartureDate) !== group.day
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const yardId of await this.stopsForSchedule(schedule)) {
|
||||||
|
yards.add(yardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...yards];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep bookings on a route-day whose operation request staff did NOT accept by
|
||||||
|
* the time the window's document-review phase ends. They never reached
|
||||||
|
* FULLY_EXECUTED, so they never enter the batch — expire them (customer must
|
||||||
|
* rebook a new window). No reservation and no invoice exists yet at this stage,
|
||||||
|
* so this is a lighter expiry than `expire()`: just flip status + notify, and
|
||||||
|
* best-effort close any payable if one was issued early. Government/export are
|
||||||
|
* excluded by the query.
|
||||||
|
*/
|
||||||
|
async expireUnacceptedForRouteDay(group: RouteDayGroup): Promise<void> {
|
||||||
|
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||||
|
if (corridorYards.length === 0) return;
|
||||||
|
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||||
|
corridorYards,
|
||||||
|
group.day,
|
||||||
|
);
|
||||||
|
for (const booking of unaccepted) {
|
||||||
|
await this.bookingsRepository.update(booking.id, {
|
||||||
|
status: "EXPIRED",
|
||||||
|
schedulingStatus: "ELIGIBLE",
|
||||||
|
// Free the shipment day so the customer can rebook a fresh window.
|
||||||
|
scheduledDate: null,
|
||||||
|
} as never);
|
||||||
|
// Close any payable issued before doc-review end (normally none — the invoice
|
||||||
|
// is created at ops-accept, which by definition has not happened here).
|
||||||
|
await this.billing
|
||||||
|
.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID")
|
||||||
|
.catch(() => undefined);
|
||||||
|
this.notifier.expired(booking);
|
||||||
|
this.logger.log(
|
||||||
|
`Expired unaccepted booking ${booking.reference}:${booking.id} at doc-review end ` +
|
||||||
|
`(${group.originYardId}->${group.destinationYardId} ${group.day})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { BookingSplitService } from './booking-split.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 { Contract } from '../contracts/entities/contract.entity';
|
||||||
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
||||||
|
* (both the parent contract row and the booking's denormalized copy) so the split
|
||||||
|
* remainder can be rebooked. A GENERAL booking is left untouched.
|
||||||
|
*/
|
||||||
|
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||||
|
const bookingId = 'bk-1';
|
||||||
|
const contractId = 'ct-1';
|
||||||
|
const offerId = 'of-1';
|
||||||
|
|
||||||
|
const buildService = (bookingContractKind: 'ONE_TIME' | 'GENERAL') => {
|
||||||
|
const offer = {
|
||||||
|
id: offerId,
|
||||||
|
bookingId,
|
||||||
|
status: 'OFFERED',
|
||||||
|
offeredWagons: 3,
|
||||||
|
totalWagons: 5,
|
||||||
|
offeredWeightTons: 30,
|
||||||
|
offeredAmount: 300,
|
||||||
|
offeredPricingBreakdown: {},
|
||||||
|
offeredLines: null,
|
||||||
|
} as unknown as BookingBatchOffer;
|
||||||
|
|
||||||
|
const bookingRepo = {
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: bookingId,
|
||||||
|
contractId,
|
||||||
|
contractKind: bookingContractKind,
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const contractRepo = { update: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const offerRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(offer),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const containerRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
update: jest.fn(),
|
||||||
|
softDelete: jest.fn(),
|
||||||
|
};
|
||||||
|
const unitRepo = { find: jest.fn().mockResolvedValue([]), softDelete: jest.fn() };
|
||||||
|
|
||||||
|
const repoFor = (entity: unknown) => {
|
||||||
|
if (entity === Booking) return bookingRepo;
|
||||||
|
if (entity === Contract) return contractRepo;
|
||||||
|
if (entity === BookingBatchOffer) return offerRepo;
|
||||||
|
if (entity === BookingContainer) return containerRepo;
|
||||||
|
if (entity === BookingContainerUnit) return unitRepo;
|
||||||
|
return { find: jest.fn().mockResolvedValue([]), update: jest.fn() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSource = {
|
||||||
|
getRepository: jest.fn(repoFor),
|
||||||
|
transaction: jest.fn(async (fn: (m: unknown) => Promise<void>) => {
|
||||||
|
await fn({ getRepository: repoFor });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new BookingSplitService(
|
||||||
|
dataSource as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ expirePayable: jest.fn() } as never,
|
||||||
|
{ payNowPartial: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
return { service, bookingRepo, contractRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
||||||
|
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||||
|
|
||||||
|
await service.applySplit(bookingId);
|
||||||
|
|
||||||
|
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||||
|
bookingId,
|
||||||
|
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||||
|
);
|
||||||
|
expect(contractRepo.update).toHaveBeenCalledWith(
|
||||||
|
contractId,
|
||||||
|
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
|
||||||
|
const { service, contractRepo } = buildService('GENERAL');
|
||||||
|
|
||||||
|
await service.applySplit(bookingId);
|
||||||
|
|
||||||
|
expect(contractRepo.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import { BillingService } from '../billing/billing.service';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import {
|
import {
|
||||||
BookingBatchOffer,
|
BookingBatchOffer,
|
||||||
OfferedLine,
|
OfferedLine,
|
||||||
@@ -30,10 +31,11 @@ export interface SizedOffer {
|
|||||||
* pays, which is the act of accepting the split (applySplit). No payment →
|
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||||
* offer expires and the booking stays whole.
|
* offer expires and the booking stays whole.
|
||||||
*
|
*
|
||||||
* Only GENERAL-contract commercial bookings are offered partials: the remainder
|
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||||
* returns to the contract's quantity cap (derived live from booking_container
|
* 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
|
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||||
* any later window within contract validity.
|
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||||
|
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingSplitService {
|
export class BookingSplitService {
|
||||||
@@ -245,6 +247,25 @@ export class BookingSplitService {
|
|||||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
// A ONE_TIME contract permits a single active booking, which would block the
|
||||||
|
// split remainder from ever being rebooked. Promote the parent contract (and
|
||||||
|
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
|
||||||
|
// down against the cap like any general contract, within the same validity.
|
||||||
|
const booking = await manager.getRepository(Booking).findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
select: { id: true, contractId: true, contractKind: true },
|
||||||
|
});
|
||||||
|
if (booking?.contractKind === 'ONE_TIME') {
|
||||||
|
await manager
|
||||||
|
.getRepository(Booking)
|
||||||
|
.update(bookingId, { contractKind: 'GENERAL' } as never);
|
||||||
|
if (booking.contractId) {
|
||||||
|
await manager
|
||||||
|
.getRepository(Contract)
|
||||||
|
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await manager
|
await manager
|
||||||
.getRepository(BookingBatchOffer)
|
.getRepository(BookingBatchOffer)
|
||||||
.update(offer.id, { status: 'APPLIED' });
|
.update(offer.id, { status: 'APPLIED' });
|
||||||
|
|||||||
@@ -263,14 +263,19 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
) {
|
) {
|
||||||
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
||||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||||
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
const routeDay = {
|
||||||
// (or allocating government) — skipped automatically for everyone who fits
|
|
||||||
// is handled inside the fill (all fit → all reserved → all notified).
|
|
||||||
await this.bookingBatchService.processRouteDay({
|
|
||||||
originYardId: schedule.originStationId,
|
originYardId: schedule.originStationId,
|
||||||
destinationYardId: schedule.destinationStationId,
|
destinationYardId: schedule.destinationStationId,
|
||||||
day: eatDay(schedule.scheduledDepartureDate),
|
day: eatDay(schedule.scheduledDepartureDate),
|
||||||
});
|
};
|
||||||
|
// Doc review is over: bookings staff never accepted (still pending) can no
|
||||||
|
// longer make this train — expire them BEFORE the batch so they never
|
||||||
|
// compete for capacity and never reach the pool.
|
||||||
|
await this.bookingBatchService.expireUnacceptedForRouteDay(routeDay);
|
||||||
|
// Run the batch: priority fill over the route-day pool, reserving pay windows
|
||||||
|
// (or allocating government) — skipped automatically for everyone who fits
|
||||||
|
// is handled inside the fill (all fit → all reserved → all notified).
|
||||||
|
await this.bookingBatchService.processRouteDay(routeDay);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,14 +18,11 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Layers,
|
Layers,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Trash2,
|
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||||
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
|
|||||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||||
|
|
||||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
|
||||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
|
||||||
|
|
||||||
export default function FileUploadSettingsPage() {
|
export default function FileUploadSettingsPage() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
|
|||||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||||
api.fileUploadSettings.list.queryOptions(),
|
api.fileUploadSettings.list.queryOptions(),
|
||||||
);
|
);
|
||||||
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
|
|
||||||
|
|
||||||
const fileUploadSettings = useMemo(
|
const fileUploadSettings = useMemo(
|
||||||
() => (Array.isArray(data) ? data : []),
|
() => (Array.isArray(data) ? data : []),
|
||||||
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
|
|||||||
headerClassName,
|
headerClassName,
|
||||||
cellClassName: `${cellClassName} whitespace-nowrap`,
|
cellClassName: `${cellClassName} whitespace-nowrap`,
|
||||||
},
|
},
|
||||||
|
// Settings are seeded/fixed — staff may only update a setting's fields,
|
||||||
|
// not create, edit, or delete the settings themselves.
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const setting = row.original;
|
const setting = row.original;
|
||||||
return (
|
return (
|
||||||
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
|
|||||||
Fields
|
Fields
|
||||||
</Button>
|
</Button>
|
||||||
</ManageFileUploadFieldsDialog>
|
</ManageFileUploadFieldsDialog>
|
||||||
|
|
||||||
<EditFileUploadSettingDialog mode="edit" setting={setting}>
|
|
||||||
<ActionIcon variant="default" aria-label="Edit setting">
|
|
||||||
<Pencil size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</EditFileUploadSettingDialog>
|
|
||||||
|
|
||||||
<DeleteFileUploadSettingDialog
|
|
||||||
settingLabel={setting.label}
|
|
||||||
settingCode={setting.code}
|
|
||||||
onConfirm={() => deleteMutation.mutate({ id: setting.id })}
|
|
||||||
>
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
color="red"
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
aria-label="Delete setting"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
</DeleteFileUploadSettingDialog>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [deleteMutation]);
|
}, []);
|
||||||
|
|
||||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||||
|
|
||||||
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="File upload settings"
|
title="File upload settings"
|
||||||
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
subtitle="Define the file inputs every form in the platform should render — required/optional, single/multiple, allowed types and size."
|
||||||
action={
|
|
||||||
<EditFileUploadSettingDialog mode="create">
|
|
||||||
<Button leftSection={<Plus size={18} />}>New setting</Button>
|
|
||||||
</EditFileUploadSettingDialog>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
|
|||||||
emptyMessage={
|
emptyMessage={
|
||||||
query.trim()
|
query.trim()
|
||||||
? "No file upload settings match your search."
|
? "No file upload settings match your search."
|
||||||
: 'No file upload settings yet. Click "New setting" to add one.'
|
: "No file upload settings configured."
|
||||||
}
|
}
|
||||||
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user