mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
add GL operations for customs risk assignment, duty advising, and incident reporting
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Global Logistics Phase-2 operational features (docs/new-doc.md §11–§13, gap
|
||||
* matrix #14/#16/#17/#18):
|
||||
* - `clearance_milestones.metadata` — structured payload for RISK_ASSIGNED
|
||||
* (risk level) and DUTY_TAXES_ADVISED (amount, currency, declaration serial)
|
||||
* - `bookings.gl_station_yard_id` / `gl_assigned_staff_id` / `gl_assigned_at`
|
||||
* — station routing + staff binding (GL US-02)
|
||||
* - `freight.clearance_incidents` — cargo exception/damage reports with photos
|
||||
* (GL Import US-07)
|
||||
*/
|
||||
export class AddGlOperations1825000000000 implements MigrationInterface {
|
||||
name = 'AddGlOperations1825000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.clearance_milestones ADD COLUMN IF NOT EXISTS metadata JSONB;`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_station_yard_id UUID;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_staff_id UUID;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS gl_assigned_at TIMESTAMPTZ;`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.clearance_incidents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
booking_id UUID NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||
incident_type VARCHAR(32) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
photo_file_ids JSONB NOT NULL DEFAULT '[]',
|
||||
reported_by_user_id UUID,
|
||||
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS idx_clearance_incidents_booking ON freight.clearance_incidents(booking_id);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.clearance_incidents CASCADE;`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_at;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_assigned_staff_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS gl_station_yard_id;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.clearance_milestones DROP COLUMN IF EXISTS metadata;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -426,6 +426,18 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
|
||||
selectedForBatchAt?: Date | null;
|
||||
|
||||
// ── Global Logistics station routing (GL Import/Export US-02) ──────────────
|
||||
/** Origin-station yard the shipment is routed to for GL handling. */
|
||||
@Column({ name: 'gl_station_yard_id', type: 'uuid', nullable: true })
|
||||
glStationYardId?: string | null;
|
||||
|
||||
/** GL staff user bound to this shipment by the station manager. */
|
||||
@Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true })
|
||||
glAssignedStaffId?: string | null;
|
||||
|
||||
@Column({ name: 'gl_assigned_at', type: 'timestamptz', nullable: true })
|
||||
glAssignedAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (18–23 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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user