fix transi permit file upload

This commit is contained in:
marshal
2026-07-02 12:22:07 +03:00
parent 4fefe4f827
commit 56bb75c9a8
9 changed files with 271 additions and 123 deletions

View File

@@ -863,14 +863,14 @@ export class ContractsController {
@Post('bookings/:bookingId/transport-document') @Post('bookings/:bookingId/transport-document')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data') @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( uploadTransportDocument(
@Param('bookingId', ParseUUIDPipe) bookingId: string, @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') @Post('bookings/:bookingId/documents')

View File

@@ -8,6 +8,7 @@ import {
IncidentType, IncidentType,
} from './entities/clearance-incident.entity'; } from './entities/clearance-incident.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service'; 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 * Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -165,7 +166,7 @@ export class GlOperationsService {
*/ */
async uploadTransportDocument( async uploadTransportDocument(
bookingId: string, bookingId: string,
file: Express.Multer.File, files: Express.Multer.File[],
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> { ): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
const booking = await this.getBooking(bookingId); const booking = await this.getBooking(bookingId);
if (booking.tradeDirection !== 'EXPORT') { 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({ await persistExportTransportUploads(this.filesService, bookingId, files);
resourceId: bookingId,
resource: 'bookings',
code: 'export_transport_document',
file,
});
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') { if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED'); await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');

View File

@@ -4,6 +4,8 @@ import {
declarationFileLabel, declarationFileLabel,
isDeclarationFileCode, isDeclarationFileCode,
isImportTransitPermitFileCode, isImportTransitPermitFileCode,
isExportTransportFileCode,
exportTransportFileLabel,
transitPermitFileLabel, transitPermitFileLabel,
type ClearanceWorkflowFile, type ClearanceWorkflowFile,
} from '@edr/types'; } 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<void> {
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 { export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value; if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false; 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; return out;
} }

View File

@@ -19,8 +19,8 @@ export class PaymentClientService {
private readonly logger = new Logger(PaymentClientService.name); private readonly logger = new Logger(PaymentClientService.name);
private readonly baseUrl = ( private readonly baseUrl = (
// process.env.PAYMENT_API_URL ?? // process.env.PAYMENT_API_URL ??
// "https://paymentcallback.triaplc.com" "https://paymentcallback.triaplc.com"
"http://localhost:3003" // "http://localhost:3003"
).replace(/\/$/, ""); ).replace(/\/$/, "");
private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? "";

View File

@@ -12,8 +12,6 @@ import { PaymentEntity } from "./entities/payment.entity";
import { PaymentRepository } from "./payment.repository"; import { PaymentRepository } from "./payment.repository";
import { PaymentClientService } from "./payment-client.service"; import { PaymentClientService } from "./payment-client.service";
import { BillingService } from "../billing/billing.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 fs from "fs";
import * as path from "path"; import * as path from "path";
@@ -108,9 +106,6 @@ export class PaymentService {
private readonly paymentClient: PaymentClientService, private readonly paymentClient: PaymentClientService,
@Inject(forwardRef(() => BillingService)) @Inject(forwardRef(() => BillingService))
private readonly billing: BillingService, private readonly billing: BillingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly firstMileService: FirstMileService,
) { } ) { }
async getAll(filters: { async getAll(filters: {

View File

@@ -15,6 +15,10 @@ import {
TextInput, TextInput,
} from "@mantine/core"; } from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
} from "@/components/contracts/TransitPermitMultiUpload";
import { DateInput } from "@mantine/dates"; import { DateInput } from "@mantine/dates";
import { import {
AlertTriangle, AlertTriangle,
@@ -332,7 +336,7 @@ export function PhasedClearanceActionPanel({
> >
{showEt && {showEt &&
canEt && canEt &&
!clearance.bookingReady && !bookingCreated &&
(activeStep >= 4 || (activeStep >= 4 ||
isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? ( isMilestoneDone(clearance.milestones, "TRANSIT_PERMIT_UPLOADED")) ? (
<TransitPermitStep <TransitPermitStep
@@ -839,79 +843,30 @@ function TransitPermitStep({
onViewFile?: (file: { name: string; url: string }) => void; onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void;
}) { }) {
const [files, setFiles] = useState<File[]>([]); const uploaded: TransitPermitUploadedRow[] = importTransitFilesFromWorkflow(workflowFiles);
const [loading, setLoading] = useState(false);
const uploaded = importTransitFilesFromWorkflow(workflowFiles);
return ( return (
<Stack gap="md"> <TransitPermitMultiUpload
<Text size="sm" fw={700}> uploaded={uploaded}
Transit Permit replaceMode={replaceMode}
</Text> fileFieldPrefix="transit_permit"
onViewFile={onViewFile}
{uploaded.length > 0 ? ( onDownloadFile={onDownloadFile}
<Stack gap={8}> onSubmit={async (payload) => {
<Text size="xs" fw={700} c="dimmed" tt="uppercase"> try {
Current file{uploaded.length > 1 ? "s" : ""} if (isBooking) {
</Text> await bookingsService.uploadTransitPermit(entityId, payload);
{uploaded.map((row) => ( } else {
<PhasedUploadedFileRow await contractsService.uploadContractTransitPermit(entityId, payload);
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<PhasedMultiFileDropzone
label="Transit permit documents"
description={
replaceMode
? "Replace transit permit files — upload one or more documents."
: "Upload one or more transit permit documents (PDF or image)."
} }
value={files} toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded");
onChange={setFiles} onChanged?.();
replaceMode={replaceMode} } catch (e) {
disabled={loading} toast.error(e instanceof Error ? e.message : "Upload failed");
/> throw e;
</Paper> }
}}
<Button />
color="edr-green"
loading={loading}
disabled={files.length === 0}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
const payload = Object.fromEntries(
files.map((file, index) => [`transit_permit_${index}`, file]),
) as Record<string, File>;
if (isBooking) {
await bookingsService.uploadTransitPermit(entityId, payload);
} else {
await contractsService.uploadContractTransitPermit(entityId, payload);
}
setFiles([]);
toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded");
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setLoading(false);
}
}}
>
{replaceMode ? "Replace transit permit" : "Upload transit permit"}
</Button>
</Stack>
); );
} }
@@ -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({ function ExportTransitPermitStep({
bookingId, bookingId,
workflowFiles = [], workflowFiles = [],
@@ -1335,50 +1302,43 @@ function ExportTransitPermitStep({
onViewFile?: (file: { name: string; url: string }) => void; onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void;
}) { }) {
const [files, setFiles] = useState<Record<string, File | null>>({ const uploaded = exportTransitFilesFromWorkflow(workflowFiles);
export_transport_document: null, const hasUploaded = uploaded.length > 0;
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.export_transport_document);
const existing = findWorkflowFile(workflowFiles, "export_transport_document");
if (existing && !replaceMode) { if (hasUploaded && !replaceMode) {
return ( return (
<PhasedUploadedFileRow <Stack gap="sm">
label="Transit permit" <Text size="sm" fw={700}>
file={existing} Transit Permit
onView={onViewFile} </Text>
onDownload={onDownloadFile} {uploaded.map((row) => (
/> <PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
); );
} }
return ( return (
<PhasedDocumentUploadField <TransitPermitMultiUpload
fields={[{ key: "export_transport_document", label: "Transit permit" }]} replaceMode={replaceMode || hasUploaded}
files={files} uploaded={uploaded}
onChange={(key, file) => setFiles((prev) => ({ ...prev, [key]: file }))} fileFieldPrefix="export_transport_document"
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"}
onViewFile={onViewFile} onViewFile={onViewFile}
onDownloadFile={onDownloadFile} onDownloadFile={onDownloadFile}
onSubmit={async () => { onSubmit={async (payload) => {
const file = files.export_transport_document;
if (!file) return;
setLoading(true);
try { try {
await contractsService.uploadTransportDocument(bookingId, file); await contractsService.uploadTransportDocument(bookingId, payload);
setFiles({ export_transport_document: null });
toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded"); toast.success(replaceMode ? "Transit permit updated" : "Transit permit uploaded");
onChanged?.(); onChanged?.();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed"); toast.error(e instanceof Error ? e.message : "Upload failed");
} finally { throw e;
setLoading(false);
} }
}} }}
/> />

View File

@@ -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<string, File>) => Promise<void>;
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<File[]>([]);
const [loading, setLoading] = useState(false);
const label = submitLabel ?? (replaceMode ? "Replace transit permit" : "Upload transit permit");
return (
<Stack gap="md">
<Text size="sm" fw={700}>
{title}
</Text>
{uploaded.length > 0 ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
Current file{uploaded.length > 1 ? "s" : ""}
</Text>
{uploaded.map((row) => (
<PhasedUploadedFileRow
key={row.code}
label={row.label}
file={row.file}
onView={onViewFile}
onDownload={onDownloadFile}
/>
))}
</Stack>
) : null}
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<PhasedMultiFileDropzone
label="Transit permit documents"
description={
replaceMode
? "Replace transit permit files — upload one or more documents (PDF or image)."
: "Upload one or more transit permit documents (PDF or image)."
}
value={files}
onChange={setFiles}
replaceMode={replaceMode}
disabled={disabled || loading}
/>
</Paper>
<Button
color="edr-green"
loading={loading}
disabled={disabled || files.length === 0}
leftSection={<Upload size={16} />}
fullWidth
onClick={async () => {
setLoading(true);
try {
const payload = Object.fromEntries(
files.map((file, index) => [`${fileFieldPrefix}_${index}`, file]),
) as Record<string, File>;
await onSubmit(payload);
setFiles([]);
} catch {
// Caller shows toast for upload errors.
} finally {
setLoading(false);
}
}}
>
{label}
</Button>
</Stack>
);
}

View File

@@ -319,9 +319,14 @@ export const contractsService = {
finalizeExportClearance: (id: string) => finalizeExportClearance: (id: string) =>
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_EXPORT(id)), postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_EXPORT(id)),
uploadTransportDocument: async (bookingId: string, file: File) => { uploadTransportDocument: async (
bookingId: string,
files: Record<string, File | null>,
) => {
const form = new FormData(); 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, { const response = await client.post(C.BOOKING_TRANSPORT_DOCUMENT(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" }, headers: { "Content-Type": "multipart/form-data" },
}); });

View File

@@ -101,6 +101,26 @@ export function transitPermitFileLabel(code: string, index?: number): string {
return code; 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( export function catalogEntriesForTradeDirection(
tradeDirection: string, tradeDirection: string,
): ClearanceWorkflowFileCatalogEntry[] { ): ClearanceWorkflowFileCatalogEntry[] {