Merge pull request #535 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-08 12:09:40 +03:00
committed by GitHub
8 changed files with 895 additions and 30 deletions

View File

@@ -177,6 +177,8 @@ export interface BatchBoardSchedule {
/** Weight committed on the train (allocated + selected-for-batch). */
usedWeightTons: number;
maxWeightTons: number | null;
/** Wagon-slot cap for the train (locomotive/wagon-type derived). */
maxWagons: number | null;
};
counts: {
allocated: number;
@@ -290,6 +292,9 @@ export class BookingBatchService implements OnModuleInit {
* schedule-scoped — only the fill is day-level).
*/
async processRouteDay(group: RouteDayGroup): Promise<void> {
this.logger.log(
`[BATCH] processRouteDay START ${group.originYardId}->${group.destinationYardId} ${group.day}`,
);
const scheduleIds = await this.fillRouteDay(
group.originYardId,
group.destinationYardId,
@@ -863,7 +868,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -902,6 +907,7 @@ export class BookingBatchService implements OnModuleInit {
lengthMeters: number;
}>,
loco: Locomotive | null,
maxWagons: number | null,
): BatchBoardSchedule["capacity"] {
const allocated = items.filter((i) => i.state === "ALLOCATED");
const committed = items.filter(
@@ -918,6 +924,7 @@ export class BookingBatchService implements OnModuleInit {
Math.round(committed.reduce((sum, i) => sum + i.weightTons, 0) * 100) /
100,
maxWeightTons: loco ? Number(loco.maxPullWeightTons) : null,
maxWagons: maxWagons ?? null,
};
}
@@ -956,7 +963,7 @@ export class BookingBatchService implements OnModuleInit {
maxTrainLengthMeters: Number(loco.maxTrainLengthMeters),
}
: null,
capacity: this.computeBoardCapacity(items, loco),
capacity: this.computeBoardCapacity(items, loco, s.maxWagons ?? null),
counts: {
allocated: items.filter((i) => i.state === "ALLOCATED").length,
selectedForBatch: items.filter((i) => i.state === "SELECTED_FOR_BATCH")
@@ -1033,6 +1040,7 @@ export class BookingBatchService implements OnModuleInit {
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
const units = this.groupConsolidatedPool(pool);
let armed = false;
let reservedThisPass = 0;
// Batch fill trace: caps + pool at entry. Kept on debug level — invaluable when
// reservations trickle instead of landing in one pass (a reserve() throwing
@@ -1087,18 +1095,33 @@ export class BookingBatchService implements OnModuleInit {
}
}
if (isGov) {
await this.allocate(scheduleId, booking, "gov");
if (partner) await this.allocate(scheduleId, partner, "gov");
} else {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
// Isolate each unit so a throw in reserve/allocate (e.g. billing hiccup)
// can't abort the whole top-up pass and leave the rest to trickle in one
// per tick. Log + skip the failing unit, keep going.
try {
if (isGov) {
await this.allocate(scheduleId, booking, "gov");
if (partner) await this.allocate(scheduleId, partner, "gov");
} else {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
}
budget.subtract(need, leg);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
`[fillSchedule ${scheduleId}] reserve/allocate FAILED for ${booking.reference} ` +
`— skipping this unit, continuing: ${(err as Error).message}`,
);
continue;
}
budget.subtract(need, leg);
if (budget.maxRemaining().wagons <= 0) break; // every leg exhausted — nothing more can board
}
this.logger.log(
`[fillSchedule ${scheduleId}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
if (budget.maxRemaining().wagons <= 0) await this.setWindow(scheduleId, "FULL");
if (armed) this.armSettle(scheduleId);
void this.triggerWagonAllocation(scheduleId);
@@ -1190,6 +1213,7 @@ export class BookingBatchService implements OnModuleInit {
`trains=${trains.map((t) => `${t.id}:${JSON.stringify(t.budget.maxRemaining())}`).join(",")} ` +
`poolSize=${pool.length} units=${units.length}`,
);
let reservedThisPass = 0;
for (const unit of units) {
const { primary: booking, partner } = unit;
@@ -1256,17 +1280,35 @@ export class BookingBatchService implements OnModuleInit {
continue;
}
if (isGov) {
await this.allocate(target.id, booking, "gov");
if (partner) await this.allocate(target.id, partner, "gov");
} else {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
// A throw here (e.g. a billing/invoice hiccup inside reserve) must NOT abort
// the whole pass — otherwise only the bookings before the failure get a pay
// window and the rest trickle in one-per-tick on later retries (the
// "selected one at a time / staggered" symptom). Isolate each unit: log +
// skip a failing one, keep reserving the others. The skipped unit stays in
// the pool and is retried next cycle.
try {
if (isGov) {
await this.allocate(target.id, booking, "gov");
if (partner) await this.allocate(target.id, partner, "gov");
} else {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
}
target.budget.subtract(need, legOn(target)!);
reservedThisPass += 1;
} catch (err) {
this.logger.error(
`[fillRouteDay] reserve/allocate FAILED for ${booking.reference} on ${target.id} ` +
`— skipping this unit, continuing the batch: ${(err as Error).message}`,
);
}
target.budget.subtract(need, legOn(target)!);
}
this.logger.log(
`[fillRouteDay ${originYardId}->${destinationYardId} ${day}] reserved ${reservedThisPass}/${units.length} unit(s) this pass`,
);
for (const t of trains) {
if (t.budget.maxRemaining().wagons <= 0) await this.setWindow(t.id, "FULL");
if (t.armed) this.armSettle(t.id);
@@ -1402,6 +1444,9 @@ export class BookingBatchService implements OnModuleInit {
const byId = new Map(reserved.map((b) => [b.id, b]));
const done = new Set<string>();
let anySettled = false;
this.logger.debug(
`[settleReserved ${scheduleId}] ${reserved.length} reserved booking(s) to settle`,
);
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
@@ -1448,7 +1493,14 @@ export class BookingBatchService implements OnModuleInit {
/** 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);
// A settle that allocated/expired anything frees or fills capacity → re-run the
// fill so the next waiting-list bookings get a fresh pay window (top-up).
if (anySettled) {
this.logger.log(
`[BATCH] settle changed state on ${scheduleId} — running top-up fill for the waiting list`,
);
await this.fillSchedule(scheduleId);
}
}
// ---- settle (1h after a batch) -------------------------------------------
@@ -1637,6 +1689,11 @@ export class BookingBatchService implements OnModuleInit {
"PREPAID",
);
await this.notifier.payNow(booking, deadline);
this.logger.log(
`[BATCH] RESERVED ${booking.reference} (${this.wagonsFor(booking)}w, ` +
`priority ${booking.priorityScore ?? 0}) on schedule ${scheduleId}` +
`pay by ${deadline.toISOString()}`,
);
// Customer tracking: a wagon slot is reserved and the freight pay window is
// open. Doc-trigger path — silent no-op for bookings without milestone rows.
void this.completeTrackingMilestones(booking.id, [
@@ -1671,6 +1728,9 @@ export class BookingBatchService implements OnModuleInit {
selectedForBatchAt: null,
} as never);
});
this.logger.log(
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
@@ -1738,6 +1798,10 @@ export class BookingBatchService implements OnModuleInit {
// source-agnostic.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID");
this.notifier.expired(booking);
this.logger.log(
`[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` +
`wagons back to the pool for top-up`,
);
}
/**
@@ -1793,6 +1857,12 @@ export class BookingBatchService implements OnModuleInit {
corridorYards,
group.day,
);
if (unaccepted.length > 0) {
this.logger.log(
`[BATCH] doc-review end: expiring ${unaccepted.length} un-accepted booking(s) ` +
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
);
}
for (const booking of unaccepted) {
await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
@@ -1807,8 +1877,7 @@ export class BookingBatchService implements OnModuleInit {
.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})`,
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
);
}
}

View File

@@ -92,8 +92,17 @@ export class BookingWindowService implements OnModuleInit {
now,
);
} catch (err) {
// This is THE line to watch when a window freezes mid-phase: the tick
// catches a throw here per-schedule and moves on, so a schedule whose
// transition keeps throwing stays stuck in its phase forever. Log the
// phase + stack so the failing step is obvious.
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
`[WINDOW] transition FAILED for schedule ${schedule.id} ` +
`(phase=${schedule.windowPhase}, cycle=${schedule.bookingCycleNo}): ` +
`${(err as Error).message}`,
);
this.logger.error(
`[WINDOW] stack: ${((err as Error).stack ?? "").split("\n").slice(0, 5).join(" | ")}`,
);
}
}
@@ -236,7 +245,8 @@ export class BookingWindowService implements OnModuleInit {
// Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
`[WINDOW] ${schedule.id} PRE_WINDOW→OPEN — booking window opened ` +
`(cycle ${schedule.bookingCycleNo})`,
);
return true;
}
@@ -251,7 +261,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'CLOSED';
}
this.logger.log(
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
`[WINDOW] ${schedule.id} OPEN→DOC_REVIEW — booking closed; staff document ` +
`review until ${docReviewEndsAt.toISOString()}`,
);
return true;
}
@@ -277,7 +288,8 @@ export class BookingWindowService implements OnModuleInit {
// 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()}`,
`[WINDOW] ${schedule.id} DOC_REVIEW→PAYMENT — batch ran; payment phase ` +
`until ${paymentPhaseEndsAt.toISOString()}`,
);
return true;
}
@@ -287,6 +299,10 @@ export class BookingWindowService implements OnModuleInit {
schedule.paymentPhaseEndsAt != null &&
now >= schedule.paymentPhaseEndsAt
) {
this.logger.log(
`[WINDOW] ${schedule.id} PAYMENT window ended — settling reservations ` +
`(allocate paid / expire unpaid) then concluding the cycle`,
);
await this.bookingBatchService.settleDueReservations(schedule.id);
await this.concludeCycle(schedule, cfg, now);
return true;
@@ -306,6 +322,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
await this.setPhase(schedule, { windowPhase: 'DONE' });
await this.tryAutoFinalize(schedule.id);
this.logger.log(
`[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`,
);
return;
}
@@ -324,7 +343,8 @@ export class BookingWindowService implements OnModuleInit {
if (nextOpensAt == null) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
this.logger.log(
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
`[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` +
`departure — window DONE`,
);
return;
}
@@ -347,7 +367,8 @@ export class BookingWindowService implements OnModuleInit {
});
const sameDay = eatDay(nextOpensAt) === eatDay(now);
this.logger.log(
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
`[WINDOW] ${schedule.id} conclude → NOT full, waiting list may remain — ` +
`REOPENS ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
);
}