From 56bb75c9a80682eadcf3c4eaad319310ca2dd03b Mon Sep 17 00:00:00 2001 From: marshal Date: Thu, 2 Jul 2026 12:22:07 +0300 Subject: [PATCH] fix transi permit file upload --- .../modules/contracts/contracts.controller.ts | 8 +- .../contracts/gl-operations.service.ts | 14 +- .../contracts/phased-clearance.util.ts | 64 +++++++ .../modules/payment/payment-client.service.ts | 4 +- .../src/modules/payment/payment.service.ts | 5 - .../contracts/PhasedClearanceActionPanel.tsx | 164 +++++++----------- .../contracts/TransitPermitMultiUpload.tsx | 106 +++++++++++ .../src/services/contracts.service.ts | 9 +- .../src/freight/clearance-files.catalog.ts | 20 +++ 9 files changed, 271 insertions(+), 123 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/TransitPermitMultiUpload.tsx 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 eebdd2eba..5e712a177 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -863,14 +863,14 @@ export class ContractsController { @Post('bookings/:bookingId/transport-document') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL ET uploads export transport document after wagon allocation' }) + @ApiOperation({ summary: 'GL ET uploads export transit permit documents (multi-file)' }) uploadTransportDocument( @Param('bookingId', ParseUUIDPipe) bookingId: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], ) { - return this.glOperationsService.uploadTransportDocument(bookingId, file); + return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []); } @Post('bookings/:bookingId/documents') 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 index 41d1db61c..73ca3a65d 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -8,6 +8,7 @@ import { IncidentType, } from './entities/clearance-incident.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; +import { persistExportTransportUploads } from './phased-clearance.util'; /** * Maps a GL post-booking document `code` to the milestone it auto-completes when @@ -165,7 +166,7 @@ export class GlOperationsService { */ async uploadTransportDocument( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { const booking = await this.getBooking(bookingId); if (booking.tradeDirection !== 'EXPORT') { @@ -182,14 +183,11 @@ export class GlOperationsService { ); } - if (!file) throw new BadRequestException('No transport document uploaded'); + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'export_transport_document', - file, - }); + await persistExportTransportUploads(this.filesService, bookingId, files); if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index ce02bd26f..89fbf797e 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -4,6 +4,8 @@ import { declarationFileLabel, isDeclarationFileCode, isImportTransitPermitFileCode, + isExportTransportFileCode, + exportTransportFileLabel, transitPermitFileLabel, type ClearanceWorkflowFile, } from '@edr/types'; @@ -114,6 +116,50 @@ export async function persistTransitPermitUploads( ); } +/** Require at least one export transport document in the upload batch. */ +export function assertExportTransportFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No transit permit documents uploaded'); + } +} + +export function normalizeExportTransportFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `export_transport_document_${index}`, + })); +} + +/** Replace all export transport documents on a booking with a new multi-file batch. */ +export async function persistExportTransportUploads( + store: DeclarationFileStore, + bookingId: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeExportTransportFieldNames(files); + assertExportTransportFiles(normalized); + + const existing = await store.findByResource(bookingId, 'bookings'); + await Promise.all( + existing + .filter((f) => f.code && isExportTransportFileCode(f.code)) + .map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId: bookingId, + resource: 'bookings', + code: `export_transport_document_${index}`, + file, + }), + ), + ); +} + export function parseDutyRequiredForm(value: string | boolean | undefined): boolean { if (typeof value === 'boolean') return value; if (value === undefined || value === '') return false; @@ -251,5 +297,23 @@ export function buildWorkflowFiles( }); } + if (tradeDirection === 'EXPORT') { + const extraExportTransport = files + .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraExportTransport.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: exportTransportFileLabel(file.code, index), + uploadedBy: 'gl_et', + category: 'transit', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + } + return out; } diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 68ed249a5..4c2ebe971 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -19,8 +19,8 @@ export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? - // "https://paymentcallback.triaplc.com" - "http://localhost:3003" + "https://paymentcallback.triaplc.com" + // "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 55e338a58..f1eb3ad30 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -12,8 +12,6 @@ import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; import { BillingService } from "../billing/billing.service"; -import { FirstMileService } from "../first-mile/first-mile.service"; -import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import * as fs from "fs"; import * as path from "path"; @@ -108,9 +106,6 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) private readonly billing: BillingService, - @Inject(forwardRef(() => BookingBatchService)) - private readonly bookingBatchService: BookingBatchService, - private readonly firstMileService: FirstMileService, ) { } async getAll(filters: { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 6292c7b80..4c5a16964 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -15,6 +15,10 @@ import { TextInput, } from "@mantine/core"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { + TransitPermitMultiUpload, + type TransitPermitUploadedRow, +} from "@/components/contracts/TransitPermitMultiUpload"; import { DateInput } from "@mantine/dates"; import { AlertTriangle, @@ -332,7 +336,7 @@ export function PhasedClearanceActionPanel({ > {showEt && canEt && - !clearance.bookingReady && + !bookingCreated && (activeStep >= 4 || isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? ( void; onDownloadFile?: (file: { id: string; name: string }) => void; }) { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(false); - - const uploaded = importTransitFilesFromWorkflow(workflowFiles); + const uploaded: TransitPermitUploadedRow[] = importTransitFilesFromWorkflow(workflowFiles); return ( - - - Transit Permit - - - {uploaded.length > 0 ? ( - - - Current file{uploaded.length > 1 ? "s" : ""} - - {uploaded.map((row) => ( - - ))} - - ) : null} - - - { + try { + if (isBooking) { + await bookingsService.uploadTransitPermit(entityId, payload); + } else { + await contractsService.uploadContractTransitPermit(entityId, payload); } - value={files} - onChange={setFiles} - replaceMode={replaceMode} - disabled={loading} - /> - - - - + toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + throw e; + } + }} + /> ); } @@ -1320,6 +1275,18 @@ function ExportPostBookingSection({ ); } +function exportTransitFilesFromWorkflow( + workflowFiles: Freight.ClearanceWorkflowFile[], +): TransitPermitUploadedRow[] { + return workflowFiles + .filter((f) => f.category === "transit" && f.file) + .map((f) => ({ + code: f.code, + label: f.label, + file: f.file!, + })); +} + function ExportTransitPermitStep({ bookingId, workflowFiles = [], @@ -1335,50 +1302,43 @@ function ExportTransitPermitStep({ onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; }) { - const [files, setFiles] = useState>({ - export_transport_document: null, - }); - const [loading, setLoading] = useState(false); - const hasFile = Boolean(files.export_transport_document); - const existing = findWorkflowFile(workflowFiles, "export_transport_document"); + const uploaded = exportTransitFilesFromWorkflow(workflowFiles); + const hasUploaded = uploaded.length > 0; - if (existing && !replaceMode) { + if (hasUploaded && !replaceMode) { return ( - + + + Transit Permit + + {uploaded.map((row) => ( + + ))} + ); } return ( - setFiles((prev) => ({ ...prev, [key]: file }))} - workflowFiles={workflowFiles} - replaceMode={replaceMode} - loading={loading} - disabled={!hasFile} - helperText="Screenshot or PDF of the transit permitted status — upload after wagon allocation." - submitLabel={replaceMode ? "Replace transit permit" : "Upload transit permit"} + { - const file = files.export_transport_document; - if (!file) return; - setLoading(true); + onSubmit={async (payload) => { try { - await contractsService.uploadTransportDocument(bookingId, file); - setFiles({ export_transport_document: null }); + await contractsService.uploadTransportDocument(bookingId, payload); toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded"); onChanged?.(); } catch (e) { toast.error(e instanceof Error ? e.message : "Upload failed"); - } finally { - setLoading(false); + throw e; } }} /> diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/TransitPermitMultiUpload.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/TransitPermitMultiUpload.tsx new file mode 100644 index 000000000..559b85707 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/TransitPermitMultiUpload.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { Button, Paper, Stack, Text } from "@mantine/core"; +import { Upload } from "lucide-react"; + +import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; +import { PhasedUploadedFileRow } from "@/components/contracts/PhasedUploadedFileRow"; + +export type TransitPermitUploadedRow = { + code: string; + label: string; + file: { id: string; name: string }; +}; + +export interface TransitPermitMultiUploadProps { + title?: string; + uploaded?: TransitPermitUploadedRow[]; + replaceMode?: boolean; + submitLabel?: string; + fileFieldPrefix: string; + disabled?: boolean; + onSubmit: (files: Record) => Promise; + onViewFile?: (file: { name: string; url: string }) => void; + onDownloadFile?: (file: { id: string; name: string }) => void; +} + +/** Multi-file transit permit upload — import pre-booking and export post-booking. */ +export function TransitPermitMultiUpload({ + title = "Transit Permit", + uploaded = [], + replaceMode = false, + submitLabel, + fileFieldPrefix, + disabled = false, + onSubmit, + onViewFile, + onDownloadFile, +}: TransitPermitMultiUploadProps) { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(false); + + const label = submitLabel ?? (replaceMode ? "Replace transit permit" : "Upload transit permit"); + + return ( + + + {title} + + + {uploaded.length > 0 ? ( + + + Current file{uploaded.length > 1 ? "s" : ""} + + {uploaded.map((row) => ( + + ))} + + ) : null} + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index f8763b2a4..465cc302d 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -319,9 +319,14 @@ export const contractsService = { finalizeExportClearance: (id: string) => postContract(C.CLEARANCE_FINALIZE_EXPORT(id)), - uploadTransportDocument: async (bookingId: string, file: File) => { + uploadTransportDocument: async ( + bookingId: string, + files: Record, + ) => { const form = new FormData(); - form.append("file", file); + for (const [key, file] of Object.entries(files)) { + if (file) form.append(key, file); + } const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, { headers: { "Content-Type": "multipart/form-data" }, }); diff --git a/packages/types/src/freight/clearance-files.catalog.ts b/packages/types/src/freight/clearance-files.catalog.ts index 3859f1818..fe6e9ebbf 100644 --- a/packages/types/src/freight/clearance-files.catalog.ts +++ b/packages/types/src/freight/clearance-files.catalog.ts @@ -101,6 +101,26 @@ export function transitPermitFileLabel(code: string, index?: number): string { return code; } +export const LEGACY_EXPORT_TRANSPORT_CODE = "export_transport_document"; + +export function isExportTransportFileCode(code: string | null | undefined): boolean { + if (!code) return false; + const lower = code.toLowerCase(); + return ( + lower === LEGACY_EXPORT_TRANSPORT_CODE || + lower.startsWith(`${LEGACY_EXPORT_TRANSPORT_CODE}_`) + ); +} + +export function exportTransportFileLabel(code: string, index?: number): string { + const lower = code.toLowerCase(); + if (lower === LEGACY_EXPORT_TRANSPORT_CODE) return "Transit Permit"; + if (lower.startsWith(`${LEGACY_EXPORT_TRANSPORT_CODE}_`)) { + return index != null ? `Transit permit ${index + 1}` : "Transit Permit"; + } + return code; +} + export function catalogEntriesForTradeDirection( tradeDirection: string, ): ClearanceWorkflowFileCatalogEntry[] {