feat(clearance): preview charge documents before and after upload

This commit is contained in:
Marshal
2026-08-21 07:04:22 +00:00
parent ce3fde676e
commit 4c549029fe
32 changed files with 1195 additions and 660 deletions

View File

@@ -1218,12 +1218,7 @@ export class BookingBatchService implements OnModuleInit {
schedule.originStationId,
budget.stops,
);
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
const ledger = await this.stockLedgerFor(schedule, budget, [booking.id]);
// On a multi-yard consist the pool that matters is the one standing at
// the booking's own boarding yard — a type carried only in Mojo must not
// be advertised to a customer boarding at Dire.
@@ -4782,18 +4777,36 @@ export class BookingBatchService implements OnModuleInit {
private async stockLedgerFor(
schedule: TrainSchedule,
budget: CorridorBudget,
excludeBookingIds?: string[],
): Promise<WagonStockLedger> {
const stock = await this.trainSchedulingService.wagonStockForSchedule(
schedule.id,
schedule.originStationId,
budget.stops,
);
return new WagonStockLedger(
const ledger = new WagonStockLedger(
stock.remainingByTypeId,
Math.max(1, budget.stops.length - 1),
stock.byYardId,
budget.stops,
);
// Debit what is already committed, per boarding yard and wagon type — the
// same bookings the corridor budget subtracted. A booking with no resolvable
// wagon type still occupies steel, so it drains any type at its yard.
const [wagonDims, allowed] = await Promise.all([
this.loadWagonDims(),
this.loadAllowedWagonTypeIds(),
]);
const anyType = [...stock.remainingByTypeId.keys()];
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
const typeIds = this.allowedWagonTypeIdsFor(b, allowed);
ledger.consume(
typeIds.length ? typeIds : anyType,
this.wagonsFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return ledger;
}
/**
@@ -4956,6 +4969,29 @@ export class BookingBatchService implements OnModuleInit {
// wagon serves disjoint legs — capacity freed past an alight yard is real.
const stops = await this.stopsForSchedule(schedule);
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
for (const b of await this.committedBookings(schedule, excludeBookingIds)) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
}
/**
* Every booking already holding capacity on the schedule: allocated (linked),
* live-reserved (unexpired pay window or paid), and pending export requests
* that named this train. The ONE list both the abstract corridor budget and
* the per-yard wagon-type ledger must debit — when only the budget saw them,
* a train with 15 wagons planned at Mojo and 15 already booked from Mojo
* still advertised "15 free" there, because the whole-train budget had room
* left on that edge (from the other yard's wagons) and the ledger was born
* full.
*/
private async committedBookings(
schedule: TrainSchedule,
excludeBookingIds?: string[],
): Promise<Booking[]> {
const allocated = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b));
@@ -4988,13 +5024,14 @@ export class BookingBatchService implements OnModuleInit {
relations: ['bookingContainers'],
})
).filter((b) => !excludeBookingIds?.includes(b.id));
for (const b of [...allocated, ...reserved, ...pendingHolds]) {
budget.subtract(
this.needFor(b, wagonDims),
budget.legForYards(b.originYardId, b.destinationYardId),
);
}
return budget;
// A booking can sit in more than one set (allocated AND still reserved);
// it holds its wagons once.
const seen = new Set<string>();
return [...allocated, ...reserved, ...pendingHolds].filter((b) => {
if (seen.has(b.id) || excludeBookingIds?.includes(b.id)) return false;
seen.add(b.id);
return true;
});
}
/**