From e1d54746c236af60e9ca0699e45577cca5656484 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 28 Jun 2026 16:09:45 +0000 Subject: [PATCH] add GL operations for customs risk assignment, duty advising, and incident reporting --- .../1825000000000-AddGlOperations.ts | 65 +++++++ .../bookings/entities/booking.entity.ts | 12 ++ .../contracts/clearance-milestone.service.ts | 85 ++++++++- .../modules/contracts/contracts.controller.ts | 126 +++++++++++++ .../src/modules/contracts/contracts.module.ts | 6 + .../contracts/dto/gl-operations.dto.ts | 77 ++++++++ .../entities/clearance-incident.entity.ts | 49 +++++ .../entities/clearance-milestone.entity.ts | 19 ++ .../contracts/gl-operations.service.ts | 161 ++++++++++++++++ .../contracts/gl-actions/ActionShell.tsx | 70 +++++++ .../contracts/gl-actions/AdviseDutyCard.tsx | 94 ++++++++++ .../contracts/gl-actions/AssignRiskCard.tsx | 71 +++++++ .../gl-actions/AssignStationCard.tsx | 53 ++++++ .../contracts/gl-actions/GlActionsPanel.tsx | 67 +++++++ .../gl-actions/GlDocumentUploadCard.tsx | 80 ++++++++ .../gl-actions/IncidentReportCard.tsx | 121 ++++++++++++ .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 11 ++ .../src/hooks/contracts/useContracts.ts | 103 ++++++++++ .../pages/contracts/BookingMilestonesPage.tsx | 36 ++-- .../src/services/contracts.service.ts | 71 +++++++ .../portal/src/constants/URLS.ts | 2 + .../src/pages/MyPortalPage/MyPortalPage.tsx | 2 +- .../portal/src/pages/MyPortalPage/actions.ts | 52 ++++- .../components/ActionNeededSection.tsx | 85 ++++++++- .../BookingDetailPage/ReadonlyBookingView.tsx | 3 + .../components/ShipmentTrackingCard.tsx | 177 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 62 ++++++ .../portal/src/services/contracts.service.ts | 13 ++ docs/freight-platform/SYSTEM-FLOW.md | 100 ++++++++++ packages/types/src/freight/contracts.ts | 33 ++++ 31 files changed, 1879 insertions(+), 29 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts create mode 100644 apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AdviseDutyCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignRiskCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AssignStationCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/IncidentReportCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ShipmentTrackingCard.tsx diff --git a/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts new file mode 100644 index 000000000..cf0cc51f4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1825000000000-AddGlOperations.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index e90bd92ef..ac7fe636a 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -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[]; diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 1190d1f10..947fc6979 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index eeff2ec52..e59afdc29 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -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), + ); + } } diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts index 1c8dbb3b1..089081c3e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.module.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.module.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts new file mode 100644 index 000000000..d99478554 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/dto/gl-operations.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts new file mode 100644 index 000000000..6d2afce3c --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-incident.entity.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index ddf61c0fb..502afaf6a 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts new file mode 100644 index 000000000..e8f53f4c6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx new file mode 100644 index 000000000..0ec7ad59f --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/ActionShell.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from "react"; +import { Badge, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import { Check, type LucideIcon } from "lucide-react"; + +export interface ActionShellProps { + icon: LucideIcon; + title: string; + subtitle?: string; + /** When true the action is already done — children are hidden, a done badge shows. */ + done?: boolean; + doneLabel?: ReactNode; + children: ReactNode; +} + +/** + * Consistent container for one GL action card: icon, title, and either the + * input controls (pending) or a completed badge (done). Keeps every GL action + * visually uniform inside {@link GlActionsPanel}. + */ +export function ActionShell({ + icon: Icon, + title, + subtitle, + done, + doneLabel, + children, +}: ActionShellProps) { + return ( + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {done ? ( + typeof doneLabel === "string" || !doneLabel ? ( + } + > + {doneLabel ?? "Done"} + + ) : ( + doneLabel + ) + ) : null} + + {!done ? children : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AdviseDutyCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AdviseDutyCard.tsx new file mode 100644 index 000000000..51c24ef28 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/AdviseDutyCard.tsx @@ -0,0 +1,94 @@ +import { useState } from "react"; +import { + Button, + Group, + NumberInput, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { Receipt } from "lucide-react"; +import type { Freight } from "@edr/types"; + +import { useAdviseDuty } from "@/hooks/contracts/useContracts"; +import { ActionShell } from "./ActionShell"; + +export function AdviseDutyCard({ + bookingId, + milestone, +}: { + bookingId: string; + milestone: Freight.IClearanceMilestone; +}) { + const advise = useAdviseDuty(bookingId); + const [amount, setAmount] = useState(""); + const [currency, setCurrency] = useState("ETB"); + const [serial, setSerial] = useState(""); + + const done = milestone.status === "COMPLETED"; + const meta = milestone.metadata; + + return ( + + + + + ({ value: y.id, label: y.label }))} + value={stationYardId} + onChange={setStationYardId} + size="sm" + /> + + + + The shipment moves to the selected station's queue. + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx new file mode 100644 index 000000000..ca0038512 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlActionsPanel.tsx @@ -0,0 +1,67 @@ +import { useMemo } from "react"; +import { Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; +import { Flag } from "lucide-react"; + +import { AssignStationCard } from "./AssignStationCard"; +import { AssignRiskCard } from "./AssignRiskCard"; +import { AdviseDutyCard } from "./AdviseDutyCard"; +import { GlDocumentUploadCard } from "./GlDocumentUploadCard"; +import { IncidentReportCard } from "./IncidentReportCard"; + +export interface GlActionsPanelProps { + bookingId: string; + milestones: Freight.IClearanceMilestone[]; +} + +/** Find a milestone by code (post-booking milestones live on the booking). */ +function findMilestone( + milestones: Freight.IClearanceMilestone[], + code: string, +): Freight.IClearanceMilestone | undefined { + return milestones.find((m) => m.milestoneCode === code); +} + +/** + * Global Logistics action surface for a shipment. Each card is gated by whether + * its milestone exists on this shipment (import vs export differ) and renders the + * structured action (risk level, duty advice, document upload, incident report, + * station routing) that the plain "Complete" button can't capture. + */ +export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) { + const riskMs = useMemo( + () => findMilestone(milestones, "RISK_ASSIGNED"), + [milestones], + ); + const dutyMs = useMemo( + () => findMilestone(milestones, "DUTY_TAXES_ADVISED"), + [milestones], + ); + + return ( + + + + Structured GL operations for this shipment. Uploading a document + advances its milestone automatically. + + + + + {dutyMs ? ( + + ) : null} + + + + {riskMs ? ( + + ) : null} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx new file mode 100644 index 000000000..d4db86ce0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/gl-actions/GlDocumentUploadCard.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import { Button, FileButton, Group, Select, Stack, Text } from "@mantine/core"; +import { FileUp, Upload } from "lucide-react"; + +import { useUploadGlDocuments } from "@/hooks/contracts/useContracts"; +import { ActionShell } from "./ActionShell"; + +/** + * GL post-booking document slots. The fieldname (value) maps server-side to a + * doc-triggered milestone in gl-operations.service.ts — uploading auto-advances + * the matching milestone. + */ +const GL_DOC_SLOTS = [ + { value: "delivery_order", label: "Delivery Order (DO)" }, + { value: "release_order", label: "Release Order (RO)" }, + { value: "t1_transport_document", label: "T1 Transport Document" }, + { value: "import_release", label: "Import Release" }, + { value: "full_in_interchange", label: "Full-in Interchange" }, + { value: "final_declaration", label: "Final Declaration" }, +]; + +export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) { + const upload = useUploadGlDocuments(bookingId); + const [slot, setSlot] = useState(GL_DOC_SLOTS[0].value); + const [file, setFile] = useState(null); + + const submit = () => { + if (!slot || !file) return; + upload.mutate({ [slot]: file }); + setFile(null); + }; + + return ( + + + setType((v as Freight.IncidentType) ?? "SEAL_BROKEN")} + size="sm" + allowDeselect={false} + /> +