mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
changes
This commit is contained in:
@@ -1057,6 +1057,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.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. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findBatchPoolByCorridorDay: jest.Mock;
|
||||
findUnacceptedForRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -55,6 +56,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByCorridorDay: jest.fn().mockResolvedValue([]),
|
||||
findUnacceptedForRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -332,4 +334,177 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
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);
|
||||
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) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1037,6 +1045,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// stands for the pair.
|
||||
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 (isGov) {
|
||||
const freed = await this.preemptForGovernment(
|
||||
@@ -1048,6 +1062,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -1149,6 +1175,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// consolidated booking whose partner isn't ready this cycle is skipped.
|
||||
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) {
|
||||
const { primary: booking, partner } = unit;
|
||||
const isPair = partner != null;
|
||||
@@ -1167,6 +1201,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
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) {
|
||||
// Government fits nowhere on its own — try to preempt commercial
|
||||
// on each corridor-matching train (earliest first) until one frees room.
|
||||
@@ -1188,38 +1234,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// A consolidated pair is placed whole or not at all — never split.
|
||||
if (!isPair) {
|
||||
// Fits no train whole. Import GENERAL-contract commercial bookings get a
|
||||
// partial-capacity offer on the train with the most free wagons on the
|
||||
// booking's own leg.
|
||||
const partialTarget = trains
|
||||
.map((t) => {
|
||||
const leg = legOn(t);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fits no train whole. A split-eligible booking is offered the largest
|
||||
// part that fits on the train with the most free wagons on its leg (this
|
||||
// covers both "fits nowhere" and the boundary case where earlier bookings
|
||||
// already consumed most of the room). Consolidated pairs / government /
|
||||
// non-import never split — isSplitEligible guards that. Passing the live
|
||||
// `trains` entries lets maybeOfferPartial mutate the chosen budget/armed.
|
||||
const offered = await this.maybeOfferPartial(booking, isPair, trains, need);
|
||||
if (offered) continue;
|
||||
// Stays in the pool, retried next batch/window cycle.
|
||||
this.notifier.unplaced(booking, day);
|
||||
if (partner) this.notifier.unplaced(partner, day);
|
||||
@@ -1246,6 +1268,57 @@ export class BookingBatchService implements OnModuleInit {
|
||||
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
|
||||
* (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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 { 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,
|
||||
OfferedLine,
|
||||
@@ -30,10 +31,11 @@ export interface SizedOffer {
|
||||
* pays, which is the act of accepting the split (applySplit). No payment →
|
||||
* 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
|
||||
* 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()
|
||||
export class BookingSplitService {
|
||||
@@ -245,6 +247,25 @@ export class BookingSplitService {
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
} 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
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
|
||||
@@ -263,14 +263,19 @@ export class BookingWindowService implements OnModuleInit {
|
||||
) {
|
||||
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
|
||||
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
|
||||
// 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({
|
||||
const routeDay = {
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
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(
|
||||
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
|
||||
);
|
||||
|
||||
@@ -18,14 +18,11 @@ import {
|
||||
HardDrive,
|
||||
Layers,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -33,9 +30,7 @@ import { api } from "@/services/api";
|
||||
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
|
||||
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
|
||||
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
|
||||
|
||||
export default function FileUploadSettingsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -43,7 +38,6 @@ export default function FileUploadSettingsPage() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery(
|
||||
api.fileUploadSettings.list.queryOptions(),
|
||||
);
|
||||
const deleteMutation = useMutation(api.fileUploadSettings.remove.mutationOptions());
|
||||
|
||||
const fileUploadSettings = useMemo(
|
||||
() => (Array.isArray(data) ? data : []),
|
||||
@@ -178,6 +172,8 @@ export default function FileUploadSettingsPage() {
|
||||
headerClassName,
|
||||
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 }) => {
|
||||
const setting = row.original;
|
||||
return (
|
||||
@@ -191,33 +187,12 @@ export default function FileUploadSettingsPage() {
|
||||
Fields
|
||||
</Button>
|
||||
</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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [deleteMutation]);
|
||||
}, []);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -226,11 +201,6 @@ export default function FileUploadSettingsPage() {
|
||||
<PageHeader
|
||||
title="File upload settings"
|
||||
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
|
||||
@@ -286,7 +256,7 @@ export default function FileUploadSettingsPage() {
|
||||
emptyMessage={
|
||||
query.trim()
|
||||
? "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]"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user