Files
edr-platform/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts
2026-07-02 12:06:17 +03:00

326 lines
11 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 { 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 (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 });
}
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);
}
}