import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { ClearanceMilestone, CustomsRiskLevel, MilestoneMetadata, } from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { Contract } from './entities/contract.entity'; import { HANDOFF_MILESTONES, MilestoneDef, milestonesForDirection, splitMilestones, } from './clearance-milestone.catalog'; /** * Seeds and advances the GL clearance milestones (18–23 per direction). Pre-booking * milestones attach to the contract clearance cycle; post-booking milestones attach * to the booking. See docs/new-doc.md §5.12, §5.16, §11.3, §12.2. */ @Injectable() export class ClearanceMilestoneService { constructor(private readonly dataSource: DataSource) {} private get repo() { return this.dataSource.getRepository(ClearanceMilestone); } /** Seed the pre-booking milestones onto a contract's current clearance cycle. */ async seedPreBookingMilestones( contract: Contract, clearanceCycleId: string, ): Promise { const { preBooking } = splitMilestones(contract.tradeDirection); await this.seed(preBooking, { contractId: contract.id, clearanceCycleId, }); } /** Seed pre-booking milestones on a booking (GENERAL + customs per-shipment clearance). */ async seedPreBookingMilestonesOnBooking( bookingId: string, tradeDirection: string, ): Promise { const { preBooking } = splitMilestones(tradeDirection); await this.seed(preBooking, { bookingId }); } /** Seed the post-booking milestones onto a freshly created booking. */ async seedPostBookingMilestones( bookingId: string, tradeDirection: string, ): Promise { const { postBooking } = splitMilestones(tradeDirection); await this.seed(postBooking, { bookingId }); } /** * Seed whichever pre/post-booking milestones the booking is still missing, * keyed by milestoneCode. Plain seeding is a blind insert, so paths that can * run more than once (completing an initiated instance whose pre-booking * milestones were seeded at initiation, or a consolidation pairing replay) * must go through this instead — a duplicate timeline breaks the phase * derivation. */ async ensureBookingMilestones( bookingId: string, tradeDirection: string, ): Promise { const existing = await this.repo.find({ where: { bookingId } }); const have = new Set(existing.map((m) => m.milestoneCode)); const { preBooking, postBooking } = splitMilestones(tradeDirection); await this.seed( preBooking.filter((d) => !have.has(d.code)), { bookingId }, ); await this.seed( postBooking.filter((d) => !have.has(d.code)), { bookingId }, ); } private async seed( defs: MilestoneDef[], scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string }, ): Promise { if (!defs.length) return; const rows = defs.map((def, i) => this.repo.create({ ...scope, milestoneCode: def.code, milestoneLabel: def.label, ownerRegion: def.ownerRegion, triggeredByDoc: def.triggeredByDoc, status: 'PENDING', sortOrder: i, }), ); await this.repo.save(rows); } /** List milestones for a contract cycle or a booking. */ async listForContract(contractId: string): Promise { return this.repo.find({ where: { contractId }, order: { sortOrder: 'ASC' }, }); } async listForBooking(bookingId: string): Promise { const rows = await this.repo.find({ where: { bookingId }, order: { sortOrder: 'ASC' }, }); // Self-heal: a booking that has settled its freight payment must have // FREIGHT_PAYMENT_SETTLED completed. The batch settle path writes it, but an // export FCFS booking (linked to its train at booking time) paid via the // prepaid invoice can leave the milestone PENDING — the clearance "Payment & // wagon allocation" step then never ticks. getClearanceView backfills it, but // the stepper reads its gating milestones straight from here, so heal here too. // Idempotent (no-op once COMPLETED); recovers already-stuck rows with no migration. const paymentSettled = rows.find( (m) => m.milestoneCode === 'FREIGHT_PAYMENT_SETTLED', ); if (paymentSettled && paymentSettled.status === 'PENDING') { const booking = await this.dataSource.getRepository(Booking).findOne({ where: { id: bookingId }, select: { id: true, status: true, paymentStatus: true }, }); if (booking?.paymentStatus === 'PAID' || booking?.status === 'PAID') { await this.completeForBooking(bookingId, 'FREIGHT_PAYMENT_SETTLED'); return this.repo.find({ where: { bookingId }, order: { sortOrder: 'ASC' }, }); } } return rows; } /** * Find-or-create a post-booking milestone row from the catalog. Needed for codes * added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED). */ async ensureForBooking( bookingId: string, code: string, tradeDirection: string, ): Promise { const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); if (existing) return existing; const { postBooking } = splitMilestones(tradeDirection); const idx = postBooking.findIndex((d) => d.code === code); if (idx < 0) { throw new NotFoundException( `Milestone ${code} is not a ${tradeDirection} post-booking milestone`, ); } const def = postBooking[idx]!; return this.repo.save( this.repo.create({ bookingId, milestoneCode: def.code, milestoneLabel: def.label, ownerRegion: def.ownerRegion, triggeredByDoc: def.triggeredByDoc, status: 'PENDING', sortOrder: idx, }), ); } /** Mark a milestone complete (by code) on a booking. */ async completeForBooking( bookingId: string, code: string, userId?: string, note?: string, ): Promise { const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } if (milestone.status === 'COMPLETED') { return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); milestone.triggeredByUserId = userId ?? null; if (note) milestone.note = note; const saved = await this.repo.save(milestone); if (HANDOFF_MILESTONES.includes(code)) { await this.onHandoff(bookingId, code); } return saved; } /** * Assign a customs risk level (GREEN/YELLOW/RED) and complete the RISK_ASSIGNED * milestone on a booking (GL Import US-04 / §11.3 #19). Stores the level in the * milestone metadata so the timeline shows it. * * Customs cannot risk-rate cargo still moving under transit: the T1 must be * closed (accepted by GL Ethiopia after the train arrives) first, which is the * catalog order T1_CLOSED → RISK_ASSIGNED. * * The level stays correctable until duty is advised off it, so each assignment * is appended to `riskHistory` instead of silently replacing the last one — a * customer-visible level that changes needs a trail of who changed it and when. */ async assignRisk( bookingId: string, riskLevel: CustomsRiskLevel, userId?: string, note?: string, actor?: string, ): Promise { await this.assertT1Closed(bookingId); const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: 'RISK_ASSIGNED' }, }); const previousLevel = existing?.metadata?.riskLevel; const history = existing?.metadata?.riskHistory ?? []; // A repeat of the level already assigned is not a decision — recording it // would pad the trail with entries that changed nothing. const entries = previousLevel === riskLevel ? history : [ ...history, { level: riskLevel, ...(previousLevel ? { previousLevel } : {}), assignedAt: new Date().toISOString(), assignedByUserId: userId ?? null, assignedBy: actor ?? null, note: note ?? null, }, ]; return this.completeWithMetadata( bookingId, 'RISK_ASSIGNED', { riskLevel, riskHistory: entries }, userId, note, ); } /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ private async assertT1Closed(bookingId: string): Promise { const t1 = await this.repo.findOne({ where: { bookingId, milestoneCode: 'T1_CLOSED' }, }); if (t1?.status !== 'COMPLETED' && t1?.status !== 'SKIPPED') { throw new BadRequestException( 'The T1 must be closed before a customs risk level can be assigned.', ); } } /** * Advise duty & tax (amount + declaration serial) and complete the * DUTY_TAXES_ADVISED milestone (§11.3 #6). The customer then uploads the * payment slip, which doc-triggers DUTY_TAX_PAID. */ async adviseDuty( bookingId: string, input: { amount: number; currency: string; declarationSerial?: string }, userId?: string, note?: string, ): Promise { return this.completeWithMetadata( bookingId, 'DUTY_TAXES_ADVISED', { dutyAmount: input.amount, dutyCurrency: input.currency, declarationSerial: input.declarationSerial, }, userId, note, ); } /** Complete a milestone and merge structured metadata onto it. */ private async completeWithMetadata( bookingId: string, code: string, metadata: MilestoneMetadata, userId?: string, note?: string, ): Promise { const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); milestone.triggeredByUserId = userId ?? null; milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; if (note) milestone.note = note; return this.repo.save(milestone); } /** Mark a pre-booking milestone complete (by code) on a contract cycle. */ async completeForContract( contractId: string, code: string, userId?: string, note?: string, ): Promise { const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') { return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); milestone.triggeredByUserId = userId ?? null; if (note) milestone.note = note; return this.repo.save(milestone); } /** Skip optional milestones (e.g. duty when not required). */ /** Reopen a completed contract milestone so review can continue after a query. */ async reopenForContract(contractId: string, code: string): Promise { const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); if (!milestone || milestone.status !== 'COMPLETED') return; milestone.status = 'PENDING'; milestone.triggeredAt = null; milestone.triggeredByUserId = null; await this.repo.save(milestone); } /** Reopen a completed booking milestone so review can continue after a query. */ async reopenForBooking(bookingId: string, code: string): Promise { const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); if (!milestone || milestone.status !== 'COMPLETED') return; milestone.status = 'PENDING'; milestone.triggeredAt = null; milestone.triggeredByUserId = null; await this.repo.save(milestone); } async skipForContract(contractId: string, code: string): Promise { const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') return milestone; milestone.status = 'SKIPPED'; milestone.triggeredAt = new Date(); return this.repo.save(milestone); } async skipForBooking(bookingId: string, code: string): Promise { const milestone = await this.repo.findOne({ where: { bookingId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for booking ${bookingId}`); } if (milestone.status === 'COMPLETED') return milestone; milestone.status = 'SKIPPED'; milestone.triggeredAt = new Date(); return this.repo.save(milestone); } async completeWithMetadataForBooking( bookingId: string, code: string, metadata: MilestoneMetadata, userId?: string, note?: string, ): Promise { return this.completeWithMetadata(bookingId, code, metadata, userId, note); } /** Complete a contract milestone with structured metadata (duty advice, etc.). */ async completeWithMetadataForContract( contractId: string, code: string, metadata: MilestoneMetadata, userId?: string, note?: string, ): Promise { const milestone = await this.repo.findOne({ where: { contractId, milestoneCode: code } }); if (!milestone) { throw new NotFoundException(`Milestone ${code} not found for contract ${contractId}`); } if (milestone.status === 'COMPLETED') { return milestone; } milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); milestone.triggeredByUserId = userId ?? null; milestone.metadata = { ...(milestone.metadata ?? {}), ...metadata }; if (note) milestone.note = note; return this.repo.save(milestone); } async adviseDutyForContract( contractId: string, input: { amount: number; currency: string; declarationSerial?: string }, userId?: string, ): Promise { return this.completeWithMetadataForContract( contractId, 'DUTY_TAXES_ADVISED', { dutyAmount: input.amount, dutyCurrency: input.currency, declarationSerial: input.declarationSerial, }, userId, ); } /** Complete a doc-triggered milestone when its document is uploaded/approved. */ async completeByDocTrigger( scope: { bookingId?: string; contractId?: string }, code: string, ): Promise { const where = scope.bookingId ? { bookingId: scope.bookingId, milestoneCode: code } : { contractId: scope.contractId, milestoneCode: code }; const milestone = await this.repo.findOne({ where }); if (!milestone || milestone.status === 'COMPLETED') return; milestone.status = 'COMPLETED'; milestone.triggeredAt = new Date(); await this.repo.save(milestone); } /** * ET ↔ DJ ownership handoff (doc §11.5/§12.3). On DEPARTED_FROM_DJIBOUTI the * lead transfers to GL Ethiopia + Operations; on DEPARTED_TO_DJIBOUTI to GL * Djibouti. Notifications are handled by the notification layer (out of scope); * here we only record the ownership flip on subsequent pending milestones. */ private async onHandoff(bookingId: string, code: string): Promise { void bookingId; void code; // Ownership region is already encoded per-milestone in the catalog; no // mutation is required. This hook exists for the notification dispatch that // the GL US-09 handoff requires once the notification module lands. } /** Catalog passthrough for the frontend timeline (labels + owners). */ catalogForDirection(tradeDirection: string): MilestoneDef[] { return milestonesForDirection(tradeDirection); } }