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

@@ -199,6 +199,7 @@ import {
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
occupiedTeuBySlot,
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -1996,7 +1997,11 @@ export class TrainSchedulingService {
);
if (unplacedUnits.length) {
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(unplacedUnits, slots);
const generated = autoFillPlacements(
unplacedUnits,
slots,
occupiedTeuBySlot(containerPlacements ?? [], units),
);
const missing = findMissingContainerNumberIssues(unplacedUnits, generated);
if (missing.length) {
throw new BadRequestException({
@@ -2053,16 +2058,23 @@ export class TrainSchedulingService {
// Linked ride-alongs count as requested too: silently dropping one here is
// exactly the delete-and-recreate orphan this method must never produce.
const plannedIds = new Set(validation.bookings.map((b) => b.id));
const droppedRequested = allBookingIds.filter((id) => !plannedIds.has(id));
const dropped = allBookingIds.filter((id) => !plannedIds.has(id));
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const detailOf = (id: string) =>
reasonById.get(id) ?? `${id}: does not fit the train's wagon stock or capacity`;
// Auto-allocation (keepDeferredLinked): an ALREADY-LINKED booking the plan
// cannot seat — e.g. it boards at a yard whose planned wagons are all taken
// — stays linked and is parked WAITING_FOR_WAGON for staff, instead of
// aborting the whole rebuild and leaving every OTHER paid booking without a
// wagon too. Explicitly requested ids still fail loudly.
const keptDeferredIds = dto.keepDeferredLinked
? dropped.filter((id) => !dto.bookingIds.includes(id))
: [];
const droppedRequested = dropped.filter((id) => !keptDeferredIds.includes(id));
if (droppedRequested.length) {
const reasonById = new Map(
validation.deferredBookings.map((d) => [d.id, `${d.reference}: ${d.reason}`]),
);
const details = droppedRequested.map(
(id) =>
reasonById.get(id) ??
`${id}: does not fit the train's wagon stock or capacity`,
);
const details = droppedRequested.map(detailOf);
throw new BadRequestException({
message: `Cannot allocate — ${details.join('; ')}`,
violations: details,
@@ -2072,6 +2084,9 @@ export class TrainSchedulingService {
}
const { bookings, wagonPlan, warnings, deferredBookings } = validation;
for (const id of keptDeferredIds) {
warnings.push(`${detailOf(id)} — kept on the schedule, waiting for a wagon`);
}
const totalWeightTons = validation.summary.totalWeightTons;
const totalLengthMeters = validation.summary.totalLengthMeters;
@@ -2179,10 +2194,10 @@ export class TrainSchedulingService {
wagonPlan,
);
const scheduleBookingRecords = bookings.map((booking) => ({
trainScheduleId: scheduleId,
bookingId: booking.id,
}));
const scheduleBookingRecords = [
...bookings.map((booking) => booking.id),
...keptDeferredIds,
].map((bookingId) => ({ trainScheduleId: scheduleId, bookingId }));
await this.trainScheduleBookingsRepository.createMany(scheduleBookingRecords, manager);
await this.persistAllocationsAndLoads(
@@ -2208,6 +2223,13 @@ export class TrainSchedulingService {
manager,
);
}
for (const bookingId of keptDeferredIds) {
await this.bookingsRepository.updateSchedulingFields(
bookingId,
{ schedulingStatus: SchedulingStatus.WaitingForWagon },
manager,
);
}
if (schedule.status === TrainScheduleStatusEnum.Draft && bookings.length > 0) {
await this.trainSchedulesRepository.updateStatus(
@@ -4928,6 +4950,7 @@ export class TrainSchedulingService {
stock,
legs: legByBookingId,
edgeCount: Math.max(1, stops.length - 1),
stops,
});
violations.push(...planned.configIssues);
const fittingBookings = planned.fitting;
@@ -9362,8 +9385,7 @@ export class TrainSchedulingService {
if (!performAssign || !assignableIds.length) return result;
const needsPlacements = containerBookings.some((b) => assignableSet.has(b.id));
if (needsPlacements && !assignPlacements.length) {
if (containerBookings.some((b) => assignableSet.has(b.id)) && !assignPlacements.length) {
return {
...result,
violations: [...result.violations, 'Container placements could not be generated'],
@@ -9371,12 +9393,14 @@ export class TrainSchedulingService {
}
try {
// No placements handed over on purpose: the assign re-plans the WHOLE
// linked set (incl. non-eligible linked bookings such as one already in
// transit), so slot numbers from THIS preview would not line up with the
// plan it builds — it auto-fills every unit against its own plan instead.
// The preview placements above only serve the missing-number check.
await this.assignBookingsToSchedule(
schedule.id,
{
bookingIds: assignableIds,
containerPlacements: needsPlacements ? assignPlacements : undefined,
},
{ bookingIds: assignableIds, keepDeferredLinked: true },
undefined,
);
result.assignedBookingIds = assignableIds;
@@ -10044,8 +10068,11 @@ export class TrainSchedulingService {
if (!booking) return null;
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
// Count the fleet at the yard the BOOKING boards from, not the train's
// origin: on a split consist a Mojo booking can only ride Mojo wagons, and
// Gelan's 31 spare wagons told a paying customer there was no shortage.
const fleetCounts = await this.countFleetAvailability(
schedule.originStationId,
booking.originYardId ?? schedule.originStationId,
scheduleId,
);
const fleetByTypeId = new Map(