mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 08:20:58 +00:00
Three export rules that the flow left open. An unpaid export booking could be received at the warehouse. Receiving is what starts storage and mints a GRN, so it must not happen against cargo the customer has not settled. receive() now rejects an unpaid EXPORT booking. Import is untouched — it arrives OFF a train and its receive is the unload, so gating that on payment would strand cargo already at the yard. An allocated export booking could be marked loaded onto its train without ever reaching the warehouse. An allocation is a plan; the GRN is the proof the goods are in hand. Two loading paths skipped that check — the per-yard loadBooking and the workspace confirmScheduleLoading — and both now require every export booking to be received with a GRN first, however it arrived (first-mile or the customer's own truck) and whatever it is allocated to. The rule lives in one shared guard (assertExportReceivedWithGrn) so the two paths cannot drift. Export self-haul without a first-mile leg already worked and is unchanged: assertSelfHaulPaid allows a customer truck when there is no EDR mile leg and the booking is paid, and addTruck applies the same one-40ft-or-two-20ft rule to containers and the tonnage drawdown to bulk, exactly as import does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
Optional,
|
|
} from '@nestjs/common';
|
|
import { InjectDataSource } from '@nestjs/typeorm';
|
|
import { DataSource, EntityManager, In } from 'typeorm';
|
|
import { Freight } from '@edr/types';
|
|
|
|
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
|
import { FacilityHandlingService } from './facility-handling.service';
|
|
import { Booking } from '../bookings/entities/booking.entity';
|
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
|
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
|
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
|
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
|
|
|
/**
|
|
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
|
*
|
|
* A booking rides only its own origin→destination leg, so "dispatched" and
|
|
* "arrived" are per-booking facts confirmed by the yard operator, not train
|
|
* facts: load at the booking's origin yard (PAID → IN_TRANSIT, loadedAt) and
|
|
* unload at its destination yard (IN_TRANSIT → ARRIVED for import/export,
|
|
* → COMPLETED for intercity), possibly long before the train's final arrival.
|
|
* Both are gated on the train's latest recorded checkpoint being at that yard.
|
|
* Unload also fires automatically: recording a checkpoint at a yard auto-
|
|
* unloads every booking destined there (autoUnloadAtYard), so the manual
|
|
* unload endpoint remains only a fallback.
|
|
*
|
|
* Unloading also settles the physical wagons: each wagon that alights with the
|
|
* booking is released at that yard and the move is written to the
|
|
* wagon_movements ledger.
|
|
*/
|
|
@Injectable()
|
|
export class BookingJourneyService {
|
|
private readonly logger = new Logger(BookingJourneyService.name);
|
|
|
|
constructor(
|
|
@InjectDataSource() private readonly dataSource: DataSource,
|
|
private readonly yardFacilities: YardFacilitiesService,
|
|
private readonly facilityHandling: FacilityHandlingService,
|
|
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
|
) {}
|
|
|
|
/** Statuses from which a booking may be loaded (gov bookings don't prepay). */
|
|
private canLoad(booking: Booking): boolean {
|
|
if (booking.status === 'PAID') return true;
|
|
return booking.isGovernment && booking.status === 'APPROVED';
|
|
}
|
|
|
|
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
|
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
|
throw new BadRequestException('Booking is already loaded');
|
|
}
|
|
if (!this.canLoad(booking)) {
|
|
throw new BadRequestException(
|
|
`Booking must be paid before loading (currently ${booking.status})`,
|
|
);
|
|
}
|
|
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
|
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
|
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
|
// however it arrived and whatever it is allocated to.
|
|
await assertExportReceivedWithGrn(this.dataSource, booking);
|
|
|
|
const now = new Date();
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(Booking).update(bookingId, {
|
|
status: 'IN_TRANSIT',
|
|
loadedAt: now,
|
|
loadedByUserId: userId ?? null,
|
|
} as never);
|
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
|
// The facility handed the cargo over — raise its GRN. No-ops for yards
|
|
// without a facility (import/export terminals), which keep their own flow.
|
|
await this.facilityHandling.recordHandling(manager, {
|
|
booking,
|
|
yardId: booking.originYardId,
|
|
trainScheduleId: scheduleId,
|
|
eventType: 'LOAD',
|
|
performedBy: userId ?? null,
|
|
occurredAt: now,
|
|
});
|
|
});
|
|
|
|
// Customer tracking: cargo is on the train — loading milestones plus the
|
|
// direction's "departed" handoff. Doc-trigger path no-ops non-customs
|
|
// bookings (intercity) and already-completed codes.
|
|
void this.completeMilestones(booking, [
|
|
'CARGO_ARRIVED',
|
|
'READY_FOR_LOADING',
|
|
'LOADED',
|
|
...(booking.tradeDirection === 'IMPORT'
|
|
? ['DEPARTED_FROM_DJIBOUTI']
|
|
: booking.tradeDirection === 'EXPORT'
|
|
? ['DEPARTED_TO_DJIBOUTI']
|
|
: []),
|
|
]);
|
|
|
|
return { bookingId, status: 'IN_TRANSIT' as const, loadedAt: now.toISOString() };
|
|
}
|
|
|
|
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
|
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
|
if (booking.status !== 'IN_TRANSIT') {
|
|
throw new BadRequestException(
|
|
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
|
);
|
|
}
|
|
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
|
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
|
|
|
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
|
// export continue into clearance, keyed on the booking's own arrival.
|
|
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
|
const now = new Date();
|
|
await this.dataSource.transaction(async (manager) => {
|
|
await manager.getRepository(Booking).update(bookingId, {
|
|
status: nextStatus,
|
|
arrivedAt: now,
|
|
arrivedByUserId: userId ?? null,
|
|
} as never);
|
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'DEPARTED');
|
|
await this.settleWagonsOnUnload(manager, schedule, booking, now, userId ?? null);
|
|
// The facility took the cargo off the train — raise its GRN. Where the
|
|
// facility also stores cargo (Indode), the event links the storage record
|
|
// that storage/demurrage accrue against.
|
|
await this.facilityHandling.recordHandling(manager, {
|
|
booking,
|
|
yardId: booking.destinationYardId,
|
|
trainScheduleId: scheduleId,
|
|
eventType: 'UNLOAD',
|
|
performedBy: userId ?? null,
|
|
occurredAt: now,
|
|
});
|
|
});
|
|
|
|
// Customer tracking: THIS booking arrived (train may still be rolling).
|
|
void this.completeMilestones(booking, [
|
|
...(booking.tradeDirection === 'IMPORT'
|
|
? ['ARRIVED_ETHIOPIA']
|
|
: booking.tradeDirection === 'EXPORT'
|
|
? ['ARRIVED_AT_DJIBOUTI']
|
|
: []),
|
|
]);
|
|
|
|
return { bookingId, status: nextStatus, arrivedAt: now.toISOString() };
|
|
}
|
|
|
|
/**
|
|
* Per-yard operator worklist for a schedule: which bookings board / alight at
|
|
* each stop, with their journey state, so the yard operator at Dire sees
|
|
* exactly what to load and unload when the train is there.
|
|
*/
|
|
async listYardWork(scheduleId: string) {
|
|
const schedule = await this.getSchedule(scheduleId);
|
|
const bookings = await this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('booking')
|
|
.leftJoinAndSelect('booking.company', 'company')
|
|
.leftJoinAndSelect('booking.originYard', 'originYard')
|
|
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
|
.innerJoin(
|
|
TrainScheduleBooking,
|
|
'tsb',
|
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
|
{ scheduleId },
|
|
)
|
|
.getMany();
|
|
|
|
const latest = await this.latestCheckpoint(scheduleId);
|
|
const yardIds = [
|
|
...new Set(
|
|
bookings.flatMap((b) => [b.originYardId, b.destinationYardId]).filter(Boolean),
|
|
),
|
|
];
|
|
const yards = yardIds.length
|
|
? await this.dataSource.getRepository(Yard).find({ where: { id: In(yardIds) } })
|
|
: [];
|
|
const yardById = new Map(yards.map((y) => [y.id, y]));
|
|
const yardLabel = (id: string) =>
|
|
yardById.get(id)?.label ?? yardById.get(id)?.code ?? id;
|
|
|
|
const mapBooking = (b: Booking) => ({
|
|
id: b.id,
|
|
reference: b.reference,
|
|
status: b.status,
|
|
tradeDirection: b.tradeDirection,
|
|
isGovernment: b.isGovernment,
|
|
customer: b.company?.name ?? 'Unknown customer',
|
|
originYardId: b.originYardId,
|
|
destinationYardId: b.destinationYardId,
|
|
origin: yardLabel(b.originYardId),
|
|
destination: yardLabel(b.destinationYardId),
|
|
loadedAt: b.loadedAt?.toISOString() ?? null,
|
|
arrivedAt: b.arrivedAt?.toISOString() ?? null,
|
|
canLoad: !b.loadedAt && this.canLoad(b),
|
|
canUnload: b.status === 'IN_TRANSIT',
|
|
});
|
|
|
|
const byYard = new Map<
|
|
string,
|
|
{ yardId: string; yard: string; toLoad: ReturnType<typeof mapBooking>[]; toUnload: ReturnType<typeof mapBooking>[] }
|
|
>();
|
|
const bucket = (yardId: string) => {
|
|
let entry = byYard.get(yardId);
|
|
if (!entry) {
|
|
entry = { yardId, yard: yardLabel(yardId), toLoad: [], toUnload: [] };
|
|
byYard.set(yardId, entry);
|
|
}
|
|
return entry;
|
|
};
|
|
for (const b of bookings) {
|
|
bucket(b.originYardId).toLoad.push(mapBooking(b));
|
|
bucket(b.destinationYardId).toUnload.push(mapBooking(b));
|
|
}
|
|
|
|
return {
|
|
scheduleId,
|
|
scheduleStatus: schedule.status,
|
|
trainAtYardId: latest?.yardId ?? (schedule.status === 'DISPATCHED' ? null : schedule.originStationId),
|
|
yards: [...byYard.values()],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Auto-unload on checkpoint: every IN_TRANSIT booking on this schedule whose
|
|
* destination is the yard the train just reached alights automatically, so
|
|
* the customer's booking flips to ARRIVED (COMPLETED for intercity) the
|
|
* moment the train is recorded at their yard — no separate operator unload.
|
|
* Runs through the same per-booking unload path (wagon settle + ledger +
|
|
* milestones); one booking's failure is logged and never blocks the
|
|
* checkpoint or the other bookings. Returns the unloaded booking ids.
|
|
*/
|
|
async autoUnloadAtYard(
|
|
scheduleId: string,
|
|
yardId: string,
|
|
userId?: string | null,
|
|
): Promise<string[]> {
|
|
const bookings = await this.dataSource
|
|
.getRepository(Booking)
|
|
.createQueryBuilder('booking')
|
|
// Entity-class join: a raw 'freight.table' string is parsed by TypeORM as
|
|
// an alias.property path ("freight" alias was not found) — runtime 500.
|
|
.innerJoin(
|
|
TrainScheduleBooking,
|
|
'tsb',
|
|
'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL',
|
|
{ scheduleId },
|
|
)
|
|
.where('booking.destination_yard_id = :yardId', { yardId })
|
|
.andWhere(`booking.status = 'IN_TRANSIT'`)
|
|
.getMany();
|
|
|
|
const unloaded: string[] = [];
|
|
for (const booking of bookings) {
|
|
try {
|
|
await this.unloadBooking(scheduleId, booking.id, userId);
|
|
unloaded.push(booking.id);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Auto-unload failed for booking ${booking.id} at yard ${yardId}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
return unloaded;
|
|
}
|
|
|
|
/**
|
|
* Bulk fallback at the train's FINAL arrival: any booking destined for the
|
|
* final yard that operators didn't unload individually gets its per-booking
|
|
* arrival stamped now, so nothing stays stuck. Mid-corridor bookings are NOT
|
|
* touched — their arrival is their own unload. Returns the affected ids.
|
|
*/
|
|
async autoArriveAtFinalYard(
|
|
manager: EntityManager,
|
|
schedule: TrainSchedule,
|
|
now: Date,
|
|
): Promise<string[]> {
|
|
const rows: Array<{ id: string; trade_direction: string }> = await manager.query(
|
|
`UPDATE freight.bookings b
|
|
SET status = CASE WHEN b.trade_direction = 'DOMESTIC' THEN 'COMPLETED' ELSE 'ARRIVED' END,
|
|
scheduling_status = 'DISPATCHED',
|
|
arrived_at = COALESCE(b.arrived_at, $3),
|
|
loaded_at = COALESCE(b.loaded_at, b.created_at)
|
|
FROM freight.train_schedule_bookings tsb
|
|
WHERE tsb.booking_id = b.id
|
|
AND tsb.train_schedule_id = $1
|
|
AND tsb.deleted_at IS NULL
|
|
AND b.deleted_at IS NULL
|
|
AND b.destination_yard_id = $2
|
|
AND b.status NOT IN ('DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED', 'ARRIVED', 'DELIVERED')
|
|
RETURNING b.id, b.trade_direction`,
|
|
[schedule.id, schedule.destinationStationId, now],
|
|
);
|
|
return rows.map((r) => r.id);
|
|
}
|
|
|
|
// ---- helpers ---------------------------------------------------------------
|
|
|
|
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
|
|
const schedule = await this.dataSource
|
|
.getRepository(TrainSchedule)
|
|
.findOne({ where: { id: scheduleId } });
|
|
if (!schedule) {
|
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
|
}
|
|
return schedule;
|
|
}
|
|
|
|
private async getScheduleBooking(scheduleId: string, bookingId: string) {
|
|
const schedule = await this.getSchedule(scheduleId);
|
|
const booking = await this.dataSource
|
|
.getRepository(Booking)
|
|
.findOne({ where: { id: bookingId } });
|
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
|
if (booking.trainScheduleId !== scheduleId) {
|
|
throw new BadRequestException('Booking is not assigned to this schedule');
|
|
}
|
|
return { schedule, booking };
|
|
}
|
|
|
|
private async latestCheckpoint(scheduleId: string): Promise<TrainCheckpointEvent | null> {
|
|
return this.dataSource.getRepository(TrainCheckpointEvent).findOne({
|
|
where: { trainScheduleId: scheduleId },
|
|
order: { occurredAt: 'DESC', createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* INTERCITY ONLY. Intercity cargo rides a passing train and is handled at the
|
|
* booking's own yards, so those yards need the equipment to do it — a train
|
|
* stopping somewhere is not the same as somewhere being able to load it.
|
|
*
|
|
* Import/export are untouched: their cargo is handled at the route's terminal
|
|
* ports, not at an arbitrary mid-corridor yard, and gating them here would
|
|
* block existing traffic.
|
|
*
|
|
* Lives here rather than in the controller so the checkpoint-driven
|
|
* autoUnloadAtYard path cannot route around it.
|
|
*/
|
|
private async assertYardCanHandleCargo(
|
|
booking: Booking,
|
|
yardId: string,
|
|
side: 'origin' | 'destination',
|
|
): Promise<void> {
|
|
if (booking.tradeDirection !== 'DOMESTIC') return;
|
|
const facility = await this.yardFacilities.facilityForYard(yardId);
|
|
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
|
|
|
if (!facility?.hasFacility) {
|
|
throw new BadRequestException(
|
|
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
|
);
|
|
}
|
|
// A facility only handles what its equipment can lift: containers need a
|
|
// reach stacker/gantry, bulk does not.
|
|
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
|
throw new BadRequestException(
|
|
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
|
`an intercity booking cannot be ${where} here.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The train is "at" a yard when the latest recorded checkpoint is that yard,
|
|
* or — for a booking boarding at the train's own origin — when the train has
|
|
* not recorded any checkpoint yet (still sitting at its origin).
|
|
*/
|
|
private async assertTrainAtYard(
|
|
schedule: TrainSchedule,
|
|
yardId: string,
|
|
side: 'origin' | 'destination',
|
|
): Promise<void> {
|
|
const latest = await this.latestCheckpoint(schedule.id);
|
|
if (!latest) {
|
|
if (side === 'origin' && schedule.originStationId === yardId) return;
|
|
throw new BadRequestException(
|
|
'Train has not reached this yard yet — record its checkpoint first',
|
|
);
|
|
}
|
|
if (latest.yardId !== yardId) {
|
|
throw new BadRequestException(
|
|
`Train's last recorded position is not at the booking's ${side} yard`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private async setAllocationStatuses(
|
|
manager: EntityManager,
|
|
scheduleId: string,
|
|
bookingId: string,
|
|
status: 'LOADED' | 'DEPARTED',
|
|
): Promise<void> {
|
|
const allocations = await this.allocationsForBooking(manager, scheduleId, bookingId);
|
|
if (!allocations.length) return;
|
|
await manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.update({ id: In(allocations.map((a) => a.id)) }, { status });
|
|
}
|
|
|
|
private async allocationsForBooking(
|
|
manager: EntityManager,
|
|
scheduleId: string,
|
|
bookingId: string,
|
|
): Promise<Array<WagonBookingAllocation & { trainSetWagon?: TrainSetWagon }>> {
|
|
return manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.createQueryBuilder('alloc')
|
|
.innerJoinAndSelect('alloc.trainSetWagon', 'slot')
|
|
.innerJoin(
|
|
TrainSchedule,
|
|
'schedule',
|
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
|
{ scheduleId },
|
|
)
|
|
.where('alloc.booking_id = :bookingId', { bookingId })
|
|
.getMany();
|
|
}
|
|
|
|
/**
|
|
* On unload: write the wagon_movements ledger rows (board yard → unload yard,
|
|
* kind LOADED) for the booking's pinned wagons, and release each wagon whose
|
|
* slot alights here — it detaches, stays at this yard, and becomes Available
|
|
* (dynamic consist). Wagons shared with a still-loaded consolidated partner
|
|
* stay pinned until the last booking on the slot unloads.
|
|
*/
|
|
private async settleWagonsOnUnload(
|
|
manager: EntityManager,
|
|
schedule: TrainSchedule,
|
|
booking: Booking,
|
|
now: Date,
|
|
userId: string | null,
|
|
): Promise<void> {
|
|
const allocations = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
|
for (const alloc of allocations) {
|
|
const slot = alloc.trainSetWagon;
|
|
if (!slot?.physicalWagonId) continue;
|
|
|
|
const boardYardId = slot.boardYardId ?? schedule.originStationId;
|
|
await manager.getRepository(WagonMovement).save(
|
|
manager.getRepository(WagonMovement).create({
|
|
wagonId: slot.physicalWagonId,
|
|
fromYardId: boardYardId,
|
|
toYardId: booking.destinationYardId,
|
|
trainScheduleId: schedule.id,
|
|
bookingId: booking.id,
|
|
kind: Freight.WagonMovementKind.Loaded,
|
|
movedByUserId: userId,
|
|
occurredAt: now,
|
|
}),
|
|
);
|
|
|
|
// Detach only when this yard is where the slot's leg ends and no other
|
|
// booking on the wagon is still in transit.
|
|
const slotAlightYardId = slot.alightYardId ?? schedule.destinationStationId;
|
|
if (slotAlightYardId !== booking.destinationYardId) continue;
|
|
const siblings = await manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.createQueryBuilder('alloc')
|
|
.innerJoin('alloc.booking', 'b')
|
|
.where('alloc.train_set_wagon_id = :slotId', { slotId: slot.id })
|
|
.andWhere('alloc.booking_id != :bookingId', { bookingId: booking.id })
|
|
.andWhere(`b.status = 'IN_TRANSIT'`)
|
|
.getCount();
|
|
if (siblings > 0) continue;
|
|
|
|
await manager.getRepository(TrainSetWagon).update(slot.id, { status: 'DEPARTED' });
|
|
const wagon = await manager
|
|
.getRepository(Wagon)
|
|
.findOne({ where: { id: slot.physicalWagonId } });
|
|
// Only settle a wagon still bound to this schedule (it may have been
|
|
// re-pinned elsewhere already).
|
|
if (wagon && wagon.currentTrainScheduleId === schedule.id) {
|
|
await manager.getRepository(Wagon).update(wagon.id, {
|
|
currentYardId: booking.destinationYardId,
|
|
currentTrainScheduleId: null,
|
|
trainSetWagonId: null,
|
|
status: Freight.WagonStatus.Available,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private async completeMilestones(booking: Booking, codes: string[]): Promise<void> {
|
|
if (!this.milestoneService || !codes.length) return;
|
|
for (const code of codes) {
|
|
try {
|
|
await this.milestoneService.completeByDocTrigger({ bookingId: booking.id }, code);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Milestone ${code} completion failed for booking ${booking.id}: ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|