Files
edr-platform/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts

428 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (1823 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<ClearanceMilestone[]> {
return this.repo.find({
where: { contractId },
order: { sortOrder: 'ASC' },
});
}
async listForBooking(bookingId: string): Promise<ClearanceMilestone[]> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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.
*/
async assignRisk(
bookingId: string,
riskLevel: CustomsRiskLevel,
userId?: string,
note?: string,
): Promise<ClearanceMilestone> {
await this.assertT1Closed(bookingId);
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
}
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
private async assertT1Closed(bookingId: string): Promise<void> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<void> {
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<void> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<ClearanceMilestone> {
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<void> {
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> {
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);
}
}