mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
||
import { DataSource } from 'typeorm';
|
||
|
||
import {
|
||
ClearanceMilestone,
|
||
CustomsRiskLevel,
|
||
MilestoneMetadata,
|
||
} from './entities/clearance-milestone.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<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 });
|
||
}
|
||
|
||
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[]> {
|
||
return this.repo.find({
|
||
where: { bookingId },
|
||
order: { sortOrder: 'ASC' },
|
||
});
|
||
}
|
||
|
||
/** 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.
|
||
*/
|
||
async assignRisk(
|
||
bookingId: string,
|
||
riskLevel: CustomsRiskLevel,
|
||
userId?: string,
|
||
note?: string,
|
||
): Promise<ClearanceMilestone> {
|
||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|