add GL operations for customs risk assignment, duty advising, and incident reporting

This commit is contained in:
Marshal
2026-06-28 16:09:45 +00:00
parent 7c744352d1
commit e1d54746c2
31 changed files with 1879 additions and 29 deletions

View File

@@ -1,7 +1,11 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import {
ClearanceMilestone,
CustomsRiskLevel,
MilestoneMetadata,
} from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import {
HANDOFF_MILESTONES,
@@ -104,6 +108,85 @@ export class ClearanceMilestoneService {
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') {
throw new BadRequestException(`Milestone ${code} is already completed.`);
}
milestone.status = 'COMPLETED';
milestone.triggeredAt = new Date();
milestone.triggeredByUserId = userId ?? null;
if (note) milestone.note = note;
return this.repo.save(milestone);
}
/** Complete a doc-triggered milestone when its document is uploaded/approved. */
async completeByDocTrigger(
scope: { bookingId?: string; contractId?: string },

View File

@@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { SignaturesService } from '../signatures/signatures.service';
import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto';
@@ -57,6 +58,13 @@ import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
AdviseDutyDto,
AssignRiskDto,
AssignStationDto,
CompleteMilestoneDto,
ReportIncidentDto,
} from './dto/gl-operations.dto';
@ApiTags('contracts')
@Controller('contracts')
@@ -69,6 +77,7 @@ export class ContractsController {
private readonly clearanceService: ContractClearanceService,
private readonly contractBookingService: ContractBookingService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly glOperationsService: GlOperationsService,
private readonly signaturesService: SignaturesService,
) {}
@@ -506,4 +515,121 @@ export class ContractsController {
body?.note,
);
}
@Post(':id/milestones/:code/complete')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL marks a pre-booking (contract) milestone complete' })
completeContractMilestone(
@Param('id', ParseUUIDPipe) id: string,
@Param('code') code: string,
@Body() body: CompleteMilestoneDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.completeForContract(
id,
code,
resolveAuthUserId(user),
body?.note,
);
}
// ── GL operational actions on a booking (doc §11§13) ──────────────────────
@Post('bookings/:bookingId/risk')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET assigns a customs risk level (GREEN/YELLOW/RED)' })
assignRisk(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignRiskDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.assignRisk(
bookingId,
dto.riskLevel,
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/duty')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL ET advises duty & tax amount + declaration serial' })
adviseDuty(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AdviseDutyDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.milestoneService.adviseDuty(
bookingId,
{ amount: dto.amount, currency: dto.currency, declarationSerial: dto.declarationSerial },
resolveAuthUserId(user),
dto.note,
);
}
@Post('bookings/:bookingId/station-assign')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'GL station manager routes the shipment + binds staff' })
assignStation(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: AssignStationDto,
) {
return this.glOperationsService.assignStation(bookingId, {
stationYardId: dto.stationYardId,
staffId: dto.staffId,
});
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL uploads post-booking operational documents (DO/RO/T1/…)',
})
uploadGlDocuments(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDocuments(bookingId, files ?? []);
}
@Post('bookings/:bookingId/duty-slip')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer uploads the duty/tax payment slip' })
uploadDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadDutySlip(bookingId, (files ?? [])[0]);
}
@Get('bookings/:bookingId/incidents')
@ApiOperation({ summary: 'List cargo exception/damage reports for a shipment' })
listIncidents(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.glOperationsService.listIncidents(bookingId);
}
@Post('bookings/:bookingId/incidents')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'GL DJ logs a cargo exception with photo evidence' })
reportIncident(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: ReportIncidentDto,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.reportIncident(
bookingId,
{
incidentType: dto.incidentType,
description: dto.description,
files: files ?? [],
},
resolveAuthUserId(user),
);
}
}

View File

@@ -19,6 +19,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { Contract } from './entities/contract.entity';
import { ContractRoute } from './entities/contract-route.entity';
@@ -30,6 +31,8 @@ import { ContractReviewNote } from './entities/contract-review-note.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ContractDocumentReview } from './entities/contract-document-review.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ClearanceIncident } from './entities/clearance-incident.entity';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
@@ -50,6 +53,8 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractClearanceCycle,
ContractDocumentReview,
ClearanceMilestone,
ClearanceIncident,
Booking,
BookingContainerUnit,
]),
RuleEngineModule,
@@ -76,6 +81,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
ContractClearanceService,
ContractBookingService,
ClearanceMilestoneService,
GlOperationsService,
// Contract PDF providers (template resolution + render + PDF) — stateless
// helpers reused from src/contracts/.
ContractTemplateResolver,

View File

@@ -0,0 +1,77 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsIn,
IsNumber,
IsOptional,
IsPositive,
IsString,
IsUUID,
MinLength,
} from 'class-validator';
import { CUSTOMS_RISK_LEVELS } from '../entities/clearance-milestone.entity';
import { INCIDENT_TYPES } from '../entities/clearance-incident.entity';
export class AssignRiskDto {
@ApiProperty({ enum: CUSTOMS_RISK_LEVELS })
@IsIn(CUSTOMS_RISK_LEVELS as unknown as string[])
riskLevel!: (typeof CUSTOMS_RISK_LEVELS)[number];
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AdviseDutyDto {
@ApiProperty({ description: 'Duty & tax amount advised to the customer' })
@Type(() => Number)
@IsNumber()
@IsPositive()
amount!: number;
@ApiProperty({ example: 'ETB' })
@IsString()
@MinLength(1)
currency!: string;
@ApiPropertyOptional({ description: 'Customs declaration serial number' })
@IsOptional()
@IsString()
declarationSerial?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}
export class AssignStationDto {
@ApiProperty({ description: 'Origin-station yard the shipment is routed to' })
@IsUUID()
stationYardId!: string;
@ApiPropertyOptional({ description: 'GL staff user bound to this shipment' })
@IsOptional()
@IsUUID()
staffId?: string;
}
export class ReportIncidentDto {
@ApiProperty({ enum: INCIDENT_TYPES })
@IsIn(INCIDENT_TYPES as unknown as string[])
incidentType!: (typeof INCIDENT_TYPES)[number];
@ApiProperty({ description: 'Mandatory free-text narrative of the anomaly' })
@IsString()
@MinLength(1)
description!: string;
}
export class CompleteMilestoneDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
note?: string;
}

View File

@@ -0,0 +1,49 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
/**
* Standard anomaly taxonomy for cargo exception reporting at a border/port
* station (GL Import US-07 AC1.2). GL Djibouti logs one of these against a
* shipment with a free-text narrative and photo evidence; a high-priority alert
* is then surfaced to GL Ethiopia.
*/
export const INCIDENT_TYPES = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];
/**
* A cargo exception/damage report raised during loading or handover. Attaches to
* a booking (post-booking phase) and carries one or more photo file references
* for verification. See docs/new-doc.md §14 gap #18, GL Import US-07.
*/
@Entity({ schema: 'freight', name: 'clearance_incidents' })
@Index(['bookingId'])
export class ClearanceIncident extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'incident_type', type: 'varchar', length: 32 })
incidentType!: IncidentType;
@Column({ name: 'description', type: 'text' })
description!: string;
/** MinIO file ids of the uploaded photo evidence (.jpg). */
@Column({ name: 'photo_file_ids', type: 'jsonb', default: () => "'[]'" })
photoFileIds!: string[];
@Column({ name: 'reported_by_user_id', type: 'uuid', nullable: true })
reportedByUserId?: string | null;
@Column({ name: 'reported_at', type: 'timestamptz', default: () => 'NOW()' })
reportedAt!: Date;
}

View File

@@ -9,6 +9,22 @@ export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number];
export const MILESTONE_OWNER_REGIONS = ['ET', 'DJ', 'OPS', 'CUST'] as const;
export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/**
* Structured payload some milestones carry beyond a plain note (doc §11.3):
* - RISK_ASSIGNED → `riskLevel`
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
* Stored on the milestone so the timeline can render the value inline.
*/
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
}
/**
* A GL clearance milestone (1823 per direction). Pre-booking milestones attach
* to contract_id + clearance_cycle_id; post-booking milestones to booking_id.
@@ -60,6 +76,9 @@ export class ClearanceMilestone extends BaseEntity {
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
@Column({ name: 'metadata', type: 'jsonb', nullable: true })
metadata?: MilestoneMetadata | null;
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
}

View File

@@ -0,0 +1,161 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { FilesService } from '../files/files.service';
import { Booking } from '../bookings/entities/booking.entity';
import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
* uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
* marks the milestone done so the timeline advances without a separate click.
*/
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
delivery_order: 'DO_COLLECTED', // import — GL DJ
t1_transport_document: 'T1_CLOSED', // import — GL ET
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
full_in_interchange: 'OFFLOADED', // export — GL DJ
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
};
/**
* Operational Global Logistics actions that hang off a shipment booking after GL
* creates it: station routing, damage/incident reporting, and the phased GL
* document uploads (Release Order, Delivery Order, T1, etc.) that advance
* doc-triggered milestones. See docs/new-doc.md §11§13, gap matrix #14/#16/#18.
*/
@Injectable()
export class GlOperationsService {
constructor(
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
) {}
private get bookings() {
return this.dataSource.getRepository(Booking);
}
private get incidents() {
return this.dataSource.getRepository(ClearanceIncident);
}
private async getBooking(bookingId: string): Promise<Booking> {
const booking = await this.bookings.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return booking;
}
/**
* Route a shipment to an origin station and (optionally) bind a GL staff user
* to it (GL US-02). Setting both moves the shipment to that station's queue.
*/
async assignStation(
bookingId: string,
input: { stationYardId: string; staffId?: string },
): Promise<Booking> {
const booking = await this.getBooking(bookingId);
booking.glStationYardId = input.stationYardId;
if (input.staffId) {
booking.glAssignedStaffId = input.staffId;
booking.glAssignedAt = new Date();
}
return this.bookings.save(booking);
}
/** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
async reportIncident(
bookingId: string,
input: {
incidentType: IncidentType;
description: string;
files: Express.Multer.File[];
},
userId?: string,
): Promise<ClearanceIncident> {
await this.getBooking(bookingId);
if (!input.description?.trim()) {
throw new BadRequestException('A description is required for an incident report.');
}
const photoFileIds: string[] = [];
for (const file of input.files ?? []) {
const record = await this.filesService.upload({
resourceId: bookingId,
resource: 'bookings',
code: 'incident_photo',
file,
});
photoFileIds.push(record.id);
}
const incident = this.incidents.create({
bookingId,
incidentType: input.incidentType,
description: input.description.trim(),
photoFileIds,
reportedByUserId: userId ?? null,
reportedAt: new Date(),
});
return this.incidents.save(incident);
}
async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
return this.incidents.find({
where: { bookingId },
order: { reportedAt: 'DESC' },
});
}
/**
* Customer uploads the duty/tax payment slip after GL advised the amount. The
* slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
*/
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
): Promise<{ milestoneCompleted: boolean }> {
await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt',
file,
});
await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
return { milestoneCompleted: true };
}
/**
* GL uploads a post-booking operational document (DO, RO, T1, import release,
* interchange…). The file attaches to the booking; if the code maps to a
* doc-triggered milestone, that milestone auto-completes.
*/
async uploadDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<{ uploaded: number; completedMilestones: string[] }> {
await this.getBooking(bookingId);
if (!files?.length) throw new BadRequestException('No documents uploaded');
const completedMilestones: string[] = [];
for (const file of files) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: file.fieldname,
file,
});
const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
if (milestoneCode) {
await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
completedMilestones.push(milestoneCode);
}
}
return { uploaded: files.length, completedMilestones };
}
}