import { BadRequestException, Injectable, Logger, NotFoundException, Optional, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; 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 { BookingContainer } from '../bookings/entities/booking-container.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.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, private readonly events: EventEmitter2, @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); // Intercity cargo rides the wagons freed by earlier unloads along the // corridor — place it before the status flip so it boards with a wagon. if (booking.tradeDirection === 'DOMESTIC') { await this.autoPlaceOnFreedWagons(manager, schedule, booking); } await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED'); // Keep the schedule↔booking link's tracking flag in sync — the dispatch // readiness warnings and workspace badges read loading_status, not loadedAt. await manager .getRepository(TrainScheduleBooking) .update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'LOADED' }); // Warehouse cargo may be loaded either from the warehouse Load-to-Train // queue or from the schedule itself. Loading here must move its inventory // too, otherwise the goods read as still sitting in the shed while the // train leaves with them. No-ops for direct truck-to-train (no inventory). // ponytail: no WarehouseLoading record on this path — those are only read // back as per-inventory loading history, never billed. Create them here if // that history ever has to be complete. await manager.query( `UPDATE freight.warehouse_inventory SET status = 'LOADED', loaded_at = COALESCE(loaded_at, $2), updated_at = NOW() WHERE booking_id = $1 AND deleted_at IS NULL AND status NOT IN ('LOADED', 'DISPATCHED')`, [bookingId, now], ); // 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, }); }); // Intercity ends here — a ONE_TIME contract closes on its shipment being // delivered (import/export emit this from booking-transition.complete). if (nextStatus === 'COMPLETED') { this.events.emit('booking.completed', { bookingId }); } // The cargo is physically off the train at its own yard — mid-corridor or // final. WarehouseInventoryService picks this up to create the warehouse // record (import/intercity only; export already has one from receive). this.events.emit('booking.unloadedAtYard', { bookingId, tradeDirection: booking.tradeDirection, }); // 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[]; toUnload: ReturnType[] } >(); 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, // No checkpoint yet ⇒ the train is still at its origin, even just after // dispatch — assertTrainAtYard allows origin loading in that state, so // the UI position must agree or origin Load buttons grey out wrongly. trainAtYardId: latest?.yardId ?? 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 { 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 { 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], ); if (rows.length === 0) return []; // The facility took the cargo off the train at the final yard — raise its // GRN, same as the per-booking unloadBooking() path does. Only when that // yard also has a warehouse (or has no facility at all, e.g. Kality) does // WarehouseInventoryService additionally get to allocate a warehouse/yard/ // zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is // fully represented by the facility event alone — there is nothing there // for warehouse_inventory's NOT NULL warehouse/yard/zone to point at. const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId); const bookings = await manager .getRepository(Booking) .find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] }); const bookingById = new Map(bookings.map((b) => [b.id, b])); for (const row of rows) { // Intercity rows just completed — let a ONE_TIME contract close on delivery. if (row.trade_direction === 'DOMESTIC') { this.events.emit('booking.completed', { bookingId: row.id }); } const booking = bookingById.get(row.id); if (booking) { await this.facilityHandling.recordHandling(manager, { booking, yardId: schedule.destinationStationId, trainScheduleId: schedule.id, eventType: 'UNLOAD', occurredAt: now, }); } // Same event the per-booking unloadBooking() path emits — WarehouseInventoryService // listens for this to auto-create the warehouse_inventory row (import/intercity only, // it filters EXPORT itself). The bulk SQL update above skipped this entirely, so // bookings caught by this fallback never left "awaiting unload". if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) { this.events.emit('booking.unloadedAtYard', { bookingId: row.id, tradeDirection: row.trade_direction, }); } } return rows.map((r) => r.id); } // ---- helpers --------------------------------------------------------------- private async getSchedule(scheduleId: string): Promise { 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 { 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 { 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 { 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`, ); } } /** * INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the * slots freed by cargo already unloaded along the corridor (e.g. import * containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool, * so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over * on-train slots (not DEPARTED) with no active cargo (every allocation * DEPARTED, or none), in consist order, by capacity. Container numbers are * copied onto the first allocation so the marshalling document and its * 40ft/20ft tally stay truthful. When nothing is free the load proceeds * unallocated — the marshalling document then lists the booking as on board * without a recorded wagon. * ponytail: remainder over free capacity is dumped on the last used slot * (paper overload beats missing cargo); upgrade path is a capacity guard in * the intercity accept step. */ private async autoPlaceOnFreedWagons( manager: EntityManager, schedule: TrainSchedule, booking: Booking, ): Promise { const existing = await this.allocationsForBooking(manager, schedule.id, booking.id); if (existing.length) return; const slots = await manager .getRepository(TrainSetWagon) .createQueryBuilder('slot') .leftJoinAndSelect('slot.allocations', 'alloc') .innerJoin( TrainSchedule, 'schedule', 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', { scheduleId: schedule.id }, ) .where(`slot.status != 'DEPARTED'`) .orderBy('slot.sequence_no', 'ASC') .getMany(); const freed = slots.filter((slot) => (slot.allocations ?? []).every((a) => a.status === 'DEPARTED'), ); if (!freed.length) { this.logger.warn( `No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`, ); return; } let remaining = Number(booking.cargoTotalWeightVgm) || 0; const allocRepo = manager.getRepository(WagonBookingAllocation); const created: WagonBookingAllocation[] = []; for (const slot of freed) { const capacity = Number(slot.capacityTons) || remaining || 1; const take = Math.min(remaining || capacity, capacity); created.push( await allocRepo.save( allocRepo.create({ trainSetWagonId: slot.id, bookingId: booking.id, allocatedWeightTons: take, loadType: booking.freightType ?? null, status: 'LOADED', }), ), ); remaining = Math.max(0, remaining - take); if (remaining <= 0) break; } if (remaining > 0 && created.length) { await allocRepo.update(created[created.length - 1].id, { allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`, } as never); } // Container numbers onto the first allocation, from the booking's container // lines (per physical unit when recorded, else per line). const lines = await manager .getRepository(BookingContainer) .find({ where: { bookingId: booking.id }, relations: { units: true } }); const itemRepo = manager.getRepository(WagonAllocationContainerItem); const first = created[0]; for (const line of lines) { const units = line.units?.length ? line.units : [null]; for (const unit of units) { await itemRepo.save( itemRepo.create({ wagonBookingAllocationId: first.id, bookingContainerId: line.id, containerNumber: unit?.containerNumber ?? line.containerNumber ?? null, containerTypeId: line.containerTypeId ?? null, }), ); } } } private async setAllocationStatuses( manager: EntityManager, scheduleId: string, bookingId: string, status: 'LOADED' | 'DEPARTED', ): Promise { 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> { 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 { 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, // A wagon that belongs to a built train stays coupled to it (ASSIGNED); // only loose wagons return to the open AVAILABLE pool. Marking a // coupled wagon AVAILABLE made it show up in the train-builder's // "available wagons" picker, where attaching it always 409'd. status: wagon.trainId ? Freight.WagonStatus.Assigned : Freight.WagonStatus.Available, }); } } } private async completeMilestones(booking: Booking, codes: string[]): Promise { 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}`, ); } } } }