diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-05 01:53:35 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-05 01:53:35 +0000 |
| commit | 610d3bccf1cb640e2a21df28d8d2a954c2bf337e (patch) | |
| tree | e7e6d72fecf14ddcff1b5b52263d14119b7c488c /app | |
| parent | 15969dfedffc4e215c81d507164bc2bb383974e5 (diff) | |
(대표님) 변경사항 0604 - OCR 관련 및 drizzle generated sqls
Diffstat (limited to 'app')
| -rw-r--r-- | app/[lng]/partners/ocr/page.tsx | 133 | ||||
| -rw-r--r-- | app/api/ocr/enhanced/route.ts | 577 | ||||
| -rw-r--r-- | app/api/ocr/utils/imageRotation.ts | 244 | ||||
| -rw-r--r-- | app/api/ocr/utils/tableExtraction.ts | 556 | ||||
| -rw-r--r-- | app/api/sync/import/route.ts | 39 | ||||
| -rw-r--r-- | app/api/sync/import/status/route.ts | 41 | ||||
| -rw-r--r-- | app/api/sync/workflow/action/route.ts | 44 | ||||
| -rw-r--r-- | app/api/sync/workflow/status/route.ts | 41 |
8 files changed, 1675 insertions, 0 deletions
diff --git a/app/[lng]/partners/ocr/page.tsx b/app/[lng]/partners/ocr/page.tsx new file mode 100644 index 00000000..b75df420 --- /dev/null +++ b/app/[lng]/partners/ocr/page.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import { type SearchParams } from "@/types/table" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" +import { Shell } from "@/components/shell" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { LogIn } from "lucide-react" +import { searchParamsCache } from "@/lib/welding/validation" +import { getOcrRows } from "@/lib/welding/service" +import { OcrTable } from "@/lib/welding/table/ocr-table" + +interface IndexPageProps { + searchParams: Promise<SearchParams> +} + +export default async function IndexPage(props: IndexPageProps) { + const searchParams = await props.searchParams + const search = searchParamsCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + // Get session + const session = await getServerSession(authOptions) + + // Check if user is logged in + if (!session || !session.user) { + // Return login required UI instead of redirecting + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + Welding OCR + </h2> + <p className="text-muted-foreground"> + PDF 파일을 업로드하면 테이블에 값이 삽입됩니다. + </p> + </div> + </div> + + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">로그인이 필요합니다</h3> + <p className="mb-6 text-muted-foreground"> + OCR을 사용하려면 먼저 로그인하세요. + </p> + <Button size="lg" asChild> + <Link href="/partners"> + <LogIn className="mr-2 h-4 w-4" /> + 로그인하기 + </Link> + </Button> + </div> + </div> + </Shell> + ) + } + + // User is logged in, proceed with vendor ID + const vendorId = session.user.companyId + + // Validate vendorId (should be a number) + const idAsNumber = Number(vendorId) + + if (isNaN(idAsNumber)) { + // Handle invalid vendor ID (this shouldn't happen if authentication is working properly) + return ( + <Shell className="gap-6"> + <div className="flex items-center justify-between"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + RFQ + </h2> + </div> + </div> + <div className="flex flex-col items-center justify-center py-12 text-center"> + <div className="rounded-lg border border-dashed p-10 shadow-sm"> + <h3 className="mb-2 text-xl font-semibold">계정 오류</h3> + <p className="mb-6 text-muted-foreground"> + 업체 정보가 올바르게 설정되지 않았습니다. 관리자에게 문의하세요. + </p> + </div> + </div> + </Shell> + ) + } + + // If we got here, we have a valid vendor ID + const promises = Promise.all([ + getOcrRows({ + ...search, + filters: validFilters, + }) + ]) + + return ( + <Shell className="gap-2"> + <div className="flex items-center justify-between space-y-2"> + <div className="flex items-center justify-between space-y-2"> + <div> + <h2 className="text-2xl font-bold tracking-tight"> + RFQ + </h2> + <p className="text-muted-foreground"> + RFQ를 응답하고 커뮤니케이션을 할 수 있습니다. + </p> + </div> + </div> + </div> + + <React.Suspense fallback={<Skeleton className="h-7 w-52" />}> + {/* DateRangePicker can go here */} + </React.Suspense> + + <React.Suspense + fallback={ + <DataTableSkeleton + columnCount={6} + searchableColumnCount={1} + filterableColumnCount={2} + cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem", "8rem"]} + shrinkZero + /> + } + > + <OcrTable promises={promises} /> + </React.Suspense> + </Shell> + ) +}
\ No newline at end of file diff --git a/app/api/ocr/enhanced/route.ts b/app/api/ocr/enhanced/route.ts new file mode 100644 index 00000000..14a81399 --- /dev/null +++ b/app/api/ocr/enhanced/route.ts @@ -0,0 +1,577 @@ +// app/api/ocr/rotation-enhanced/route.ts +// DB 저장 기능이 추가된 회전 감지 및 테이블 추출 API + +import { NextRequest, NextResponse } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +import db from '@/db/db'; // DB 인스턴스 import +import { + ocrSessions, + ocrTables, + ocrRows, + ocrRotationAttempts, + type NewOcrSession, + type NewOcrTable, + type NewOcrRow, + type NewOcrRotationAttempt, + type BaseExtractedRow, + type ExtractedRow +} from '@/db/schema/ocr'; +import { extractTablesFromOCR, analyzeOCRQuality } from '../utils/tableExtraction'; +import { + rotateImageBase64, + enhanceImageQuality, + convertPDFToImage, + needsRotation +} from '../utils/imageRotation'; + +interface RotationTestResult { + rotation: number; + confidence: number; + tablesFound: number; + textQuality: number; + keywordCount: number; + extractedTables?: ExtractedRow[][]; +} + +interface ProcessingResult { + success: boolean; + sessionId: string; + metadata: { + totalTables: number; + totalRows: number; + processingTime: number; + fileName: string; + fileSize: number; + bestRotation: number; + imageEnhanced: boolean; + pdfConverted: boolean; + }; + error?: string; + warnings?: string[]; +} + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const warnings: string[] = []; + let sessionId: string | null = null; + + try { + console.log('🔄 Starting rotation-enhanced OCR processing...'); + + const formData = await request.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ + success: false, + error: 'No file provided' + }, { status: 400 }); + } + + console.log(`📁 Processing file: ${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`); + + // 파일 검증 + if (!file.type.includes('pdf') && !file.type.includes('image')) { + return NextResponse.json({ + success: false, + error: 'Invalid file type. Only PDF and image files are supported.' + }, { status: 400 }); + } + + if (file.size > 10 * 1024 * 1024) { // 10MB 제한 + return NextResponse.json({ + success: false, + error: 'File size too large. Maximum size is 10MB.' + }, { status: 400 }); + } + + // 파일을 Buffer로 변환 + const buffer = await file.arrayBuffer(); + const fileBuffer = Buffer.from(buffer); + const fileformat = file.type.includes('pdf') ? 'pdf' : 'image'; + + let base64: string; + let imageEnhanced = false; + let pdfConverted = false; + + // PDF vs 이미지 처리 + if (fileformat === 'pdf') { + console.log('📄 Converting PDF to high-quality image...'); + base64 = await convertPDFToImage(fileBuffer, 0); + pdfConverted = true; + } else { + base64 = fileBuffer.toString('base64'); + + // 이미지 품질 개선 + console.log('🎨 Enhancing image quality...'); + base64 = await enhanceImageQuality(base64); + imageEnhanced = true; + } + + // 회전 테스트 수행 + console.log('🔍 Testing multiple rotations...'); + const rotationResults = await tryMultipleRotations(base64, file.name); + + // 최적 회전 각도 선택 + const bestResult = findBestRotation(rotationResults); + console.log(`✅ Best rotation found: ${bestResult.rotation}° (score: ${bestResult.score?.toFixed(2)})`); + + if (bestResult.rotation !== 0) { + warnings.push(`Document was auto-rotated ${bestResult.rotation}° for optimal OCR results`); + } + + // 최적 결과 사용 + const finalTables = bestResult.extractedTables || []; + const totalRows = finalTables.reduce((sum, table) => sum + table.length, 0); + const processingTime = Date.now() - startTime; + + // 🗃️ DB에 저장 + console.log('💾 Saving results to database...'); + sessionId = await saveToDatabase({ + file, + fileformat, + processingTime, + bestRotation: bestResult.rotation, + finalTables, + totalRows, + rotationResults, + imageEnhanced, + pdfConverted, + warnings + }); + + console.log(`⏱️ Processing completed in ${(processingTime / 1000).toFixed(1)}s, saved as session ${sessionId}`); + + const result: ProcessingResult = { + success: true, + sessionId, + metadata: { + totalTables: finalTables.length, + totalRows, + processingTime, + fileName: file.name, + fileSize: file.size, + bestRotation: bestResult.rotation, + imageEnhanced, + pdfConverted + }, + warnings: warnings.length > 0 ? warnings : undefined + }; + + return NextResponse.json(result); + + } catch (error) { + console.error('❌ OCR processing error:', error); + + const processingTime = Date.now() - startTime; + + // 에러 발생시에도 세션 저장 (실패 기록) + if (!sessionId) { + try { + const errorFile = await request.formData().then(fd => fd.get('file') as File); + if (errorFile) { + sessionId = await saveErrorToDatabase({ + file: errorFile, + processingTime, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }); + } + } catch (dbError) { + console.error('Failed to save error to database:', dbError); + } + } + + return NextResponse.json({ + success: false, + sessionId: sessionId || null, + error: error instanceof Error ? error.message : 'Unknown error occurred', + metadata: { + processingTime, + totalTables: 0, + totalRows: 0, + fileName: '', + fileSize: 0, + bestRotation: 0, + imageEnhanced: false, + pdfConverted: false + } + }, { status: 500 }); + } +} + +// DB 저장 함수 +async function saveToDatabase({ + file, + fileformat, + processingTime, + bestRotation, + finalTables, + totalRows, + rotationResults, + imageEnhanced, + pdfConverted, + warnings +}: { + file: File; + fileformat: string; + processingTime: number; + bestRotation: number; + finalTables: ExtractedRow[][]; + totalRows: number; + rotationResults: (RotationTestResult & { score?: number })[]; + imageEnhanced: boolean; + pdfConverted: boolean; + warnings: string[]; +}): Promise<string> { + + return await db.transaction(async (tx) => { + // 1. 세션 저장 + const sessionData: NewOcrSession = { + fileName: file.name, + fileSize: file.size, + fileType: fileformat, + processingTime, + bestRotation, + totalTables: finalTables.length, + totalRows, + imageEnhanced, + pdfConverted, + success: true, + warnings: warnings.length > 0 ? warnings : null, + }; + + const [session] = await tx.insert(ocrSessions).values(sessionData).returning({ id: ocrSessions.id }); + const sessionId = session.id; + + // 2. 테이블들 저장 + const tableIds: string[] = []; + for (let tableIndex = 0; tableIndex < finalTables.length; tableIndex++) { + const table = finalTables[tableIndex]; + + const tableData: NewOcrTable = { + sessionId, + tableIndex, + rowCount: table.length, + }; + + const [savedTable] = await tx.insert(ocrTables).values(tableData).returning({ id: ocrTables.id }); + tableIds.push(savedTable.id); + + // 3. 각 테이블의 행들 저장 + if (table.length > 0) { + const rowsData: NewOcrRow[] = table.map((row, rowIndex) => ({ + tableId: savedTable.id, + sessionId, + rowIndex, + reportNo: row.reportNo || null, + no: row.no || null, + identificationNo: row.identificationNo || null, + tagNo: row.tagNo || null, + jointNo: row.jointNo || null, + jointType: row.jointType || null, + weldingDate: row.weldingDate || null, + confidence: row.confidence.toString(), + sourceTable: row.sourceTable, + sourceRow: row.sourceRow, + })); + + await tx.insert(ocrRows).values(rowsData); + } + } + + // 4. 회전 시도 결과들 저장 + const rotationAttemptsData: NewOcrRotationAttempt[] = rotationResults.map((result) => { + const extractedRowsCount = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0; + + return { + sessionId, + rotation: result.rotation, + confidence: result.confidence.toString(), + tablesFound: result.tablesFound, + textQuality: result.textQuality.toString(), + keywordCount: result.keywordCount, + score: result.score?.toString() || '0', + extractedRowsCount, + }; + }); + + if (rotationAttemptsData.length > 0) { + await tx.insert(ocrRotationAttempts).values(rotationAttemptsData); + } + + console.log(`✅ Successfully saved session ${sessionId} with ${finalTables.length} tables and ${totalRows} rows`); + return sessionId; + }); +} + +// 에러 저장 함수 +async function saveErrorToDatabase({ + file, + processingTime, + error +}: { + file: File; + processingTime: number; + error: string; +}): Promise<string> { + + const sessionData: NewOcrSession = { + fileName: file.name, + fileSize: file.size, + fileType: file.type.includes('pdf') ? 'pdf' : 'image', + processingTime, + bestRotation: 0, + totalTables: 0, + totalRows: 0, + imageEnhanced: false, + pdfConverted: false, + success: false, + errorMessage: error, + }; + + const [session] = await db.insert(ocrSessions).values(sessionData).returning({ id: ocrSessions.id }); + console.log(`💾 Error session saved: ${session.id}`); + return session.id; +} + +// OCR 결과에서 Report No 추출 +function extractReportNo(ocrResult: any): string { + try { + console.log('📊 Extracting Report No from OCR result...'); + + // OCR 결과에서 tables와 fields 모두 확인 + const allTexts: string[] = []; + + // tables에서 텍스트 추출 + if (ocrResult.images?.[0]?.tables) { + for (const table of ocrResult.images[0].tables) { + if (table.cells) { + for (const cell of table.cells) { + if (cell.cellTextLines) { + for (const textLine of cell.cellTextLines) { + if (textLine.cellWords) { + for (const word of textLine.cellWords) { + if (word.inferText) { + allTexts.push(word.inferText.trim()); + } + } + } + } + } + } + } + } + } + + // fields에서 텍스트 추출 + if (ocrResult.images?.[0]?.fields) { + for (const field of ocrResult.images[0].fields) { + if (field.inferText) { + allTexts.push(field.inferText.trim()); + } + } + } + + // Report No. 패턴을 찾기 + let reportNo = ''; + let foundReportNoLabel = false; + + for (let i = 0; i < allTexts.length; i++) { + const text = allTexts[i]; + + // "Report", "No.", "Report No." 등의 패턴 찾기 + if (text.toLowerCase().includes('report') && + (text.toLowerCase().includes('no') || + (i + 1 < allTexts.length && allTexts[i + 1].toLowerCase().includes('no')))) { + foundReportNoLabel = true; + console.log(`📋 Found Report No label at index ${i}: "${text}"`); + continue; + } + + // Report No 라벨 다음에 오는 값이나 특정 패턴 (예: SN2661FT20250526) 찾기 + if (foundReportNoLabel || /^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text)) { + // Report No 형식 패턴 체크 (예: SN2661FT20250526) + if (/^[A-Z]{2}\d{4}[A-Z]{2}\d{8}$/.test(text) || + /^[A-Z0-9]{10,20}$/.test(text)) { + reportNo = text; + console.log(`✅ Found Report No: "${reportNo}"`); + break; + } + } + } + + // 패턴이 없으면 더 관대한 검색 + if (!reportNo) { + // Report나 No 근처의 영숫자 조합 찾기 + for (let i = 0; i < allTexts.length; i++) { + const text = allTexts[i]; + if ((text.toLowerCase().includes('report') || text.toLowerCase().includes('no')) && + i + 1 < allTexts.length) { + const nextText = allTexts[i + 1]; + if (/^[A-Z0-9]{6,}$/.test(nextText)) { + reportNo = nextText; + console.log(`✅ Found Report No (fallback): "${reportNo}"`); + break; + } + } + } + } + + // 기본값 설정 + if (!reportNo) { + console.warn('⚠️ Could not extract Report No, using default'); + reportNo = 'UNKNOWN'; + } + + return reportNo; + + } catch (error) { + console.error('❌ Error extracting Report No:', error); + return 'UNKNOWN'; + } +} + +// 여러 회전 각도 테스트 +async function tryMultipleRotations(base64: string, filename: string): Promise<(RotationTestResult & { score?: number })[]> { + const rotations = [0, 90, 180, 270]; + const results: RotationTestResult[] = []; + + console.log('🔍 Testing rotations with server-side image processing...'); + + for (const rotation of rotations) { + try { + console.log(` Testing ${rotation}° rotation...`); + + // 🔄 서버 사이드에서 실제 이미지 회전 + const rotatedBase64 = await rotateImageBase64(base64, rotation); + + // OCR API 호출 + const ocrResult = await callOCRAPI(rotatedBase64, 'jpg', `${rotation}_${filename}`); + + console.log(ocrResult) + + // Report No 추출 + const reportNo = extractReportNo(ocrResult); + console.log(` ${rotation}°: Report No = "${reportNo}"`); + + const analysis = analyzeOCRQuality(ocrResult); + const rawTables = await extractTablesFromOCR(ocrResult) as BaseExtractedRow[][]; // 기존 함수 호출 + + // reportNo를 각 행에 추가 + const extractedTables: ExtractedRow[][] = rawTables.map(table => + table.map(row => ({ + ...row, + reportNo // reportNo 필드 추가 + })) + ); + + const result: RotationTestResult = { + rotation, + confidence: analysis.confidence, + tablesFound: analysis.tablesFound, + textQuality: analysis.textQuality, + keywordCount: analysis.keywordCount, + extractedTables + }; + + results.push(result); + + const extractedRows = extractedTables.reduce((sum, table) => sum + table.length, 0); + console.log(` ${rotation}°: confidence=${(analysis.confidence * 100).toFixed(1)}%, tables=${analysis.tablesFound}, quality=${(analysis.textQuality * 100).toFixed(1)}%, keywords=${analysis.keywordCount}, rows=${extractedRows}`); + + } catch (error) { + console.warn(` ${rotation}°: Failed -`, error instanceof Error ? error.message : 'Unknown error'); + results.push({ + rotation, + confidence: 0, + tablesFound: 0, + textQuality: 0, + keywordCount: 0, + extractedTables: [] + }); + } + } + + return results.map(result => ({ ...result, score: 0 })); // score는 findBestRotation에서 계산 +} + +// 최적 회전 각도 찾기 +function findBestRotation(results: (RotationTestResult & { score?: number })[]): RotationTestResult & { score?: number } { + console.log('🎯 Analyzing rotation results...'); + + const scoredResults = results.map(result => { + const extractedRows = result.extractedTables?.reduce((sum, table) => sum + table.length, 0) || 0; + + // 개선된 점수 계산 알고리즘 + let score = 0; + + // 1. OCR 신뢰도 (25%) + score += result.confidence * 0.25; + + // 2. 발견된 테이블 수 (25%) + score += Math.min(result.tablesFound / 3, 1) * 0.25; // 최대 3개 테이블까지 점수 + + // 3. 텍스트 품질 (20%) + score += result.textQuality * 0.20; + + // 4. 키워드 개수 (15%) + score += Math.min(result.keywordCount / 10, 1) * 0.15; // 최대 10개 키워드까지 점수 + + // 5. 추출된 행 수 (15%) + score += Math.min(extractedRows / 20, 1) * 0.15; // 최대 20행까지 점수 + + // 보너스: 실제 데이터가 추출된 경우 + if (extractedRows > 0) { + score += 0.1; // 10% 보너스 + } + + console.log(` ${result.rotation}°: score=${score.toFixed(3)} (conf=${(result.confidence * 100).toFixed(1)}%, tables=${result.tablesFound}, quality=${(result.textQuality * 100).toFixed(1)}%, keywords=${result.keywordCount}, rows=${extractedRows})`); + + return { ...result, score }; + }); + + const bestResult = scoredResults.reduce((best, current) => + current.score > best.score ? current : best + ); + + console.log(`🏆 Winner: ${bestResult.rotation}° with score ${bestResult.score?.toFixed(3)}`); + + return bestResult; +} + +// OCR API 호출 +async function callOCRAPI(base64: string, format: string, filename: string, rotation?: number): Promise<any> { + const ocrBody = { + version: "V2", + requestId: uuidv4(), + timestamp: Math.floor(Date.now() / 1000), + lang: "ko", + images: [{ + format, + url: null, + data: base64, + name: filename, + ...(rotation !== undefined && rotation !== 0 && { rotation }) + }], + enableTableDetection: true + }; + + const response = await fetch( + "https://n4a9z5rb5r.apigw.ntruss.com/custom/v1/35937/41dcc5202686a37ee5f4fae85bd07f599e3627e9c00d33bf6e469f409fe0ca62/general", + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-OCR-SECRET': process.env.OCR_SECRET_KEY || '', + }, + body: JSON.stringify(ocrBody), + signal: AbortSignal.timeout(60000) // 60초 타임아웃 + } + ); + + if (!response.ok) { + throw new Error(`OCR API error: ${response.status} ${response.statusText}`); + } + + return response.json(); +}
\ No newline at end of file diff --git a/app/api/ocr/utils/imageRotation.ts b/app/api/ocr/utils/imageRotation.ts new file mode 100644 index 00000000..fe9cf840 --- /dev/null +++ b/app/api/ocr/utils/imageRotation.ts @@ -0,0 +1,244 @@ +// app/api/ocr/utils/imageRotation.ts +// Sharp을 사용한 서버 사이드 이미지 회전 + +import sharp from 'sharp'; + +/** + * 서버 사이드에서 이미지를 회전시킵니다 + * @param base64 - base64 인코딩된 이미지 데이터 + * @param degrees - 회전 각도 (0, 90, 180, 270) + * @returns Promise<string> - 회전된 이미지의 base64 데이터 + */ +export async function rotateImageBase64(base64: string, degrees: number): Promise<string> { + try { + console.log(`🔄 Rotating image by ${degrees} degrees...`); + + // base64를 Buffer로 변환 + const inputBuffer = Buffer.from(base64, 'base64'); + + // 회전 각도에 따른 처리 + let rotatedBuffer: Buffer; + + switch (degrees) { + case 0: + // 회전 없음 + rotatedBuffer = inputBuffer; + break; + + case 90: + rotatedBuffer = await sharp(inputBuffer) + .rotate(90) + .jpeg({ + quality: 90, + progressive: true + }) + .toBuffer(); + break; + + case 180: + rotatedBuffer = await sharp(inputBuffer) + .rotate(180) + .jpeg({ + quality: 90, + progressive: true + }) + .toBuffer(); + break; + + case 270: + rotatedBuffer = await sharp(inputBuffer) + .rotate(270) + .jpeg({ + quality: 90, + progressive: true + }) + .toBuffer(); + break; + + default: + console.warn(`⚠️ Unsupported rotation angle: ${degrees}°. Using original image.`); + rotatedBuffer = inputBuffer; + } + + // Buffer를 다시 base64로 변환 + const rotatedBase64 = rotatedBuffer.toString('base64'); + + console.log(`✅ Image rotated successfully (${degrees}°)`); + return rotatedBase64; + + } catch (error) { + console.error(`❌ Error rotating image by ${degrees}°:`, error); + console.warn('Using original image due to rotation error'); + return base64; // 실패시 원본 반환 + } +} + +/** + * 이미지 품질을 개선합니다 + * @param base64 - base64 인코딩된 이미지 데이터 + * @returns Promise<string> - 개선된 이미지의 base64 데이터 + */ +export async function enhanceImageQuality(base64: string): Promise<string> { + try { + console.log('🎨 Enhancing image quality...'); + + const inputBuffer = Buffer.from(base64, 'base64'); + + const enhancedBuffer = await sharp(inputBuffer) + .resize(2000, 2000, { + fit: 'inside', + withoutEnlargement: true + }) + // 개별 매개변수 방식으로 수정 + .sharpen(1, 1, 2) // sigma, m1(flat), m2(jagged) + .normalize() // 히스토그램 정규화 + .gamma(1.1) // 약간의 감마 보정 + .jpeg({ + quality: 95, + progressive: true, + mozjpeg: true + }) + .toBuffer(); + + const enhancedBase64 = enhancedBuffer.toString('base64'); + + console.log('✅ Image quality enhanced'); + return enhancedBase64; + + } catch (error) { + console.error('❌ Error enhancing image:', error); + return base64; + } +} + +/** + * PDF를 고품질 이미지로 변환합니다 + * @param pdfBuffer - PDF Buffer 데이터 + * @param pageIndex - 변환할 페이지 인덱스 (0부터 시작) + * @returns Promise<string> - 변환된 이미지의 base64 데이터 + */ +export async function convertPDFToImage(pdfBuffer: Buffer, pageIndex: number = 0): Promise<string> { + try { + console.log(`📄 Converting PDF page ${pageIndex + 1} to image...`); + + // pdf2pic 라이브러리 사용 + const pdf2pic = require('pdf2pic'); + + const convert = pdf2pic.fromBuffer(pdfBuffer, { + density: 300, // 300 DPI for high quality + saveFilename: "page", + savePath: "/tmp", // 임시 경로 + format: "jpeg", + width: 2480, // A4 크기 @ 300 DPI + height: 3508, + quality: 100 + }); + + const result = await convert(pageIndex + 1, { responseType: "buffer" }); + const base64 = result.buffer.toString('base64'); + + console.log('✅ PDF converted to image successfully'); + return base64; + + } catch (error) { + console.error('❌ Error converting PDF to image:', error); + throw new Error('Failed to convert PDF to image'); + } +} + +/** + * 이미지에서 텍스트 방향을 감지합니다 + * @param base64 - base64 인코딩된 이미지 데이터 + * @returns Promise<number> - 감지된 올바른 회전 각도 + */ +export async function detectTextOrientation(base64: string): Promise<number> { + // 이 함수는 간단한 방향 감지를 시뮬레이션합니다 + // 실제로는 더 정교한 알고리즘이 필요할 수 있습니다 + + console.log('🧭 Detecting text orientation...'); + + const rotations = [0, 90, 180, 270]; + const scores: { rotation: number; score: number }[] = []; + + for (const rotation of rotations) { + try { + const rotatedBase64 = await rotateImageBase64(base64, rotation); + + // 간단한 품질 측정 (실제로는 OCR API 호출이나 다른 방법 사용) + const score = await estimateTextQuality(rotatedBase64); + scores.push({ rotation, score }); + + console.log(` ${rotation}°: quality score = ${score.toFixed(3)}`); + + } catch (error) { + console.warn(` ${rotation}°: Failed to test orientation`); + scores.push({ rotation, score: 0 }); + } + } + + const bestOrientation = scores.reduce((best, current) => + current.score > best.score ? current : best + ); + + console.log(`🎯 Best orientation detected: ${bestOrientation.rotation}°`); + return bestOrientation.rotation; +} + +/** + * 이미지의 텍스트 품질을 추정합니다 (간단한 버전) + */ +async function estimateTextQuality(base64: string): Promise<number> { + try { + const buffer = Buffer.from(base64, 'base64'); + + // Sharp을 사용해 이미지 통계 분석 + const stats = await sharp(buffer) + .greyscale() + .stats(); + + // 간단한 품질 지표 계산 + // 실제로는 더 복잡한 알고리즘이 필요합니다 + const contrast = stats.channels[0].max - stats.channels[0].min; + const sharpness = stats.channels[0].stdev; + + return (contrast + sharpness) / 510; // 0-1 범위로 정규화 + + } catch (error) { + return 0; + } +} + +/** + * 이미지가 회전이 필요한지 빠르게 체크합니다 + * @param base64 - base64 인코딩된 이미지 데이터 + * @returns Promise<boolean> - 회전이 필요하면 true + */ +export async function needsRotation(base64: string): Promise<boolean> { + try { + const buffer = Buffer.from(base64, 'base64'); + + // 이미지 메타데이터 확인 + const metadata = await sharp(buffer).metadata(); + + // EXIF 방향 정보가 있으면 회전 필요 + if (metadata.orientation && metadata.orientation > 1) { + console.log(`📐 EXIF orientation detected: ${metadata.orientation}`); + return true; + } + + // 가로/세로 비율 체크 (일반적으로 문서는 세로가 더 긺) + if (metadata.width && metadata.height) { + const aspectRatio = metadata.width / metadata.height; + if (aspectRatio > 1.5) { + console.log(`📐 Wide aspect ratio detected: ${aspectRatio.toFixed(2)}`); + return true; // 너무 가로로 긴 이미지는 회전 필요할 가능성 + } + } + + return false; + + } catch (error) { + console.warn('Error checking if rotation needed:', error); + return false; + } +}
\ No newline at end of file diff --git a/app/api/ocr/utils/tableExtraction.ts b/app/api/ocr/utils/tableExtraction.ts new file mode 100644 index 00000000..ea543f8e --- /dev/null +++ b/app/api/ocr/utils/tableExtraction.ts @@ -0,0 +1,556 @@ +// app/api/ocr/utils/tableExtraction.ts +// 완전한 테이블 추출 로직 구현 + +interface ExtractedRow { + no: string; + identificationNo: string; + tagNo: string; + jointNo: string; + jointType: string; + weldingDate: string; + confidence: number; + sourceTable: number; + sourceRow: number; +} + +interface TableCell { + cellTextLines: Array<{ + cellWords: Array<{ + inferText: string; + inferConfidence: number; + }>; + }>; + rowIndex: number; + columnIndex: number; + rowSpan: number; + columnSpan: number; + inferConfidence: number; +} + +interface OCRTable { + cells: TableCell[]; + inferConfidence: number; +} + +interface ColumnMapping { + no: number; + identification: number; + tagNo: number; + jointNo: number; + jointType: number; + weldingDate: number; +} + +// 메인 테이블 추출 함수 +export async function extractTablesFromOCR(ocrResult: any): Promise<ExtractedRow[][]> { + const extractedTables: ExtractedRow[][] = []; + const warnings: string[] = []; + + if (!ocrResult || !ocrResult.images) { + console.warn('No OCR images found in result'); + return []; + } + + for (let imageIndex = 0; imageIndex < ocrResult.images.length; imageIndex++) { + const image = ocrResult.images[imageIndex]; + + if (!image.tables || image.tables.length === 0) { + console.warn(`No tables found in image ${imageIndex}`); + continue; + } + + for (let tableIndex = 0; tableIndex < image.tables.length; tableIndex++) { + const table = image.tables[tableIndex]; + + try { + if (isRelevantTable(table)) { + const extractedRows = extractTableData(table, imageIndex, tableIndex); + + if (extractedRows.length > 0) { + extractedTables.push(extractedRows); + console.log(`Successfully extracted ${extractedRows.length} rows from table ${tableIndex + 1} in image ${imageIndex + 1}`); + } else { + console.warn(`Table ${tableIndex + 1} in image ${imageIndex + 1} was identified as relevant but no data could be extracted`); + } + } else { + console.log(`Table ${tableIndex + 1} in image ${imageIndex + 1} is not relevant (no required headers found)`); + } + } catch (error) { + console.error(`Error processing table ${tableIndex + 1} in image ${imageIndex + 1}:`, error); + } + } + } + + console.log(`Total extracted tables: ${extractedTables.length}`); + return extractedTables; +} + +// 관련 테이블인지 확인 +function isRelevantTable(table: OCRTable): boolean { + if (!table.cells || table.cells.length === 0) { + return false; + } + + // 첫 3행에서 헤더 찾기 + const headerCells = table.cells.filter(cell => cell.rowIndex <= 2); + const headerTexts = headerCells + .map(cell => getCellText(cell).toLowerCase()) + .filter(text => text.length > 0); + + console.log('Header texts found:', headerTexts); + + // 필수 키워드 확인 + const hasNo = headerTexts.some(text => + text.includes('no.') || + text === 'no' || + text.includes('번호') || + text.match(/^no\.?$/i) + ); + + const hasIdentification = headerTexts.some(text => + text.includes('identification') || + text.includes('식별') || + text.includes('ident') || + text.includes('id') + ); + + // 테이블 품질 확인 + const hasMinimumCells = table.cells.length >= 6; // 최소 헤더 + 데이터 + const hasReasonableConfidence = table.inferConfidence >= 0.5; // 신뢰도 기준 완화 + + const isRelevant = hasNo && hasIdentification && hasMinimumCells && hasReasonableConfidence; + + console.log(`Table relevance check: hasNo=${hasNo}, hasIdentification=${hasIdentification}, minCells=${hasMinimumCells}, confidence=${hasReasonableConfidence} => ${isRelevant}`); + + return isRelevant; +} + +// 테이블 데이터 추출 +function extractTableData(table: OCRTable, imageIndex: number, tableIndex: number): ExtractedRow[] { + console.log(`Processing table ${tableIndex + 1} in image ${imageIndex + 1}`); + + // 테이블 그리드 구축 + const tableGrid = buildTableGrid(table); + + if (tableGrid.length < 2) { + console.warn('Table has less than 2 rows (need header + data)'); + return []; + } + + console.log(`Table grid built: ${tableGrid.length} rows, ${tableGrid[0]?.length || 0} columns`); + + // 헤더 행 찾기 + const headerRowIndex = findHeaderRow(tableGrid); + if (headerRowIndex === -1) { + console.warn('No header row found'); + return []; + } + + console.log(`Header row found at index: ${headerRowIndex}`); + + // 테이블 형식 결정 + const headerRow = tableGrid[headerRowIndex]; + const tableFormat = determineTableFormat(headerRow); + console.log(`Table format detected: ${tableFormat}`); + + // 컬럼 매핑 찾기 + const columnMapping = findColumnMapping(headerRow, tableFormat); + console.log('Column mapping:', columnMapping); + + // 데이터 행 추출 + const dataRows: ExtractedRow[] = []; + + for (let i = headerRowIndex + 1; i < tableGrid.length; i++) { + const row = tableGrid[i]; + + if (row && row.length > 0 && !isEmptyRow(row)) { + try { + const extractedRow = extractRowData(row, tableFormat, columnMapping, imageIndex, tableIndex, i); + if (extractedRow && isValidRow(extractedRow)) { + dataRows.push(extractedRow); + } + } catch (error) { + console.warn(`Error processing row ${i}:`, error); + } + } + } + + console.log(`Extracted ${dataRows.length} valid rows from table`); + return dataRows; +} + +// 테이블 그리드 구축 +function buildTableGrid(table: OCRTable): string[][] { + if (!table.cells || table.cells.length === 0) { + return []; + } + + const maxRow = Math.max(...table.cells.map(cell => cell.rowIndex + cell.rowSpan - 1)) + 1; + const maxCol = Math.max(...table.cells.map(cell => cell.columnIndex + cell.columnSpan - 1)) + 1; + + const grid: string[][] = Array(maxRow).fill(null).map(() => Array(maxCol).fill('')); + + // 셀 내용으로 그리드 채우기 + table.cells.forEach(cell => { + const text = getCellText(cell); + + for (let r = cell.rowIndex; r < cell.rowIndex + cell.rowSpan; r++) { + for (let c = cell.columnIndex; c < cell.columnIndex + cell.columnSpan; c++) { + if (grid[r] && grid[r][c] !== undefined) { + // 기존 텍스트가 있으면 결합 + grid[r][c] = grid[r][c] ? `${grid[r][c]} ${text}`.trim() : text; + } + } + } + }); + + return grid; +} + +// 셀 텍스트 추출 +function getCellText(cell: TableCell): string { + if (!cell.cellTextLines || cell.cellTextLines.length === 0) { + return ''; + } + + return cell.cellTextLines + .map(line => + line.cellWords + .map(word => word.inferText || '') + .join(' ') + ) + .join('\n') + .trim(); +} + +// 헤더 행 찾기 +function findHeaderRow(tableGrid: string[][]): number { + for (let i = 0; i < Math.min(3, tableGrid.length); i++) { + const row = tableGrid[i]; + const rowText = row.join(' ').toLowerCase(); + + console.log(`Checking row ${i}: "${rowText}"`); + + const hasNo = rowText.includes('no.') || rowText.includes('번호') || /\bno\b/.test(rowText); + const hasIdent = rowText.includes('identification') || rowText.includes('식별') || rowText.includes('ident'); + + if (hasNo && hasIdent) { + console.log(`Header row found at ${i}`); + return i; + } + } + return -1; +} + +// 테이블 형식 결정 +function determineTableFormat(headerRow: string[]): 'format1' | 'format2' { + const headerText = headerRow.join(' ').toLowerCase(); + + // Format 2: Tag No와 Joint No가 분리된 컬럼 + const hasTagNoColumn = headerText.includes('tag') && headerText.includes('no'); + const hasJointNoColumn = headerText.includes('joint') && headerText.includes('no'); + + if (hasTagNoColumn && hasJointNoColumn) { + return 'format2'; + } + + // Format 1: Identification No에 통합 + return 'format1'; +} + +// 컬럼 매핑 찾기 +function findColumnMapping(headerRow: string[], format: 'format1' | 'format2'): ColumnMapping { + const mapping: ColumnMapping = { + no: -1, + identification: -1, + tagNo: -1, + jointNo: -1, + jointType: -1, + weldingDate: -1 + }; + + headerRow.forEach((header, index) => { + const lowerHeader = header.toLowerCase().trim(); + + console.log(`Column ${index}: "${header}" -> "${lowerHeader}"`); + + if ((lowerHeader.includes('no.') || lowerHeader === 'no') && + !lowerHeader.includes('identification') && + !lowerHeader.includes('tag') && + !lowerHeader.includes('joint')) { + mapping.no = index; + console.log(` -> Mapped to 'no'`); + } else if (lowerHeader.includes('identification') || lowerHeader.includes('ident')) { + mapping.identification = index; + console.log(` -> Mapped to 'identification'`); + } else if (lowerHeader.includes('tag') && lowerHeader.includes('no')) { + mapping.tagNo = index; + console.log(` -> Mapped to 'tagNo'`); + } else if (lowerHeader.includes('joint') && lowerHeader.includes('no')) { + mapping.jointNo = index; + console.log(` -> Mapped to 'jointNo'`); + } else if (lowerHeader.includes('joint') && lowerHeader.includes('type')) { + mapping.jointType = index; + console.log(` -> Mapped to 'jointType'`); + } else if (lowerHeader.includes('type') && !lowerHeader.includes('joint')) { + mapping.jointType = index; + console.log(` -> Mapped to 'jointType'`); + } else if (lowerHeader.includes('welding') || lowerHeader.includes('date')) { + mapping.weldingDate = index; + console.log(` -> Mapped to 'weldingDate'`); + } + }); + + console.log('Final column mapping:', mapping); + return mapping; +} + +// 행 데이터 추출 +function extractRowData( + row: string[], + format: 'format1' | 'format2', + columnMapping: ColumnMapping, + imageIndex: number, + tableIndex: number, + rowIndex: number +): ExtractedRow | null { + + const extractedRow: ExtractedRow = { + no: '', + identificationNo: '', + tagNo: '', + jointNo: '', + jointType: '', + weldingDate: '', + confidence: 0, + sourceTable: tableIndex, + sourceRow: rowIndex + }; + + console.log(`Processing row ${rowIndex}: [${row.map(cell => `"${cell}"`).join(', ')}]`); + + // No. 추출 + if (columnMapping.no >= 0 && columnMapping.no < row.length) { + extractedRow.no = cleanText(row[columnMapping.no]); + } + + if (format === 'format1') { + // Format 1: 통합된 identification 데이터 + if (columnMapping.identification >= 0 && columnMapping.identification < row.length) { + const combinedText = row[columnMapping.identification]; + const parsedData = parseIdentificationData(combinedText); + extractedRow.identificationNo = parsedData.identificationNo; + extractedRow.tagNo = parsedData.tagNo; + extractedRow.jointNo = parsedData.jointNo; + + console.log(` Parsed identification: "${combinedText}" -> `, parsedData); + } + } else { + // Format 2: 분리된 컬럼들 + if (columnMapping.identification >= 0 && columnMapping.identification < row.length) { + extractedRow.identificationNo = cleanText(row[columnMapping.identification]); + } + if (columnMapping.tagNo >= 0 && columnMapping.tagNo < row.length) { + extractedRow.tagNo = cleanText(row[columnMapping.tagNo]); + } + if (columnMapping.jointNo >= 0 && columnMapping.jointNo < row.length) { + extractedRow.jointNo = cleanText(row[columnMapping.jointNo]); + } + } + + // Joint Type 추출 + if (columnMapping.jointType >= 0 && columnMapping.jointType < row.length) { + extractedRow.jointType = cleanText(row[columnMapping.jointType]); + } + + // Welding Date 추출 (컬럼 매핑이 있으면 사용, 없으면 날짜 패턴으로 찾기) + if (columnMapping.weldingDate >= 0 && columnMapping.weldingDate < row.length) { + extractedRow.weldingDate = cleanText(row[columnMapping.weldingDate]); + } else { + const dateIndex = findDateColumn(row); + if (dateIndex >= 0) { + extractedRow.weldingDate = cleanText(row[dateIndex]); + } + } + + // 신뢰도 계산 + extractedRow.confidence = calculateRowConfidence(extractedRow); + + console.log(` Extracted row:`, extractedRow); + + return extractedRow; +} + +// Identification 데이터 파싱 (Format 1용) +function parseIdentificationData(combinedText: string): { + identificationNo: string; + tagNo: string; + jointNo: string; +} { + const cleanedText = cleanText(combinedText); + + console.log(`Parsing identification data: "${cleanedText}"`); + + // 줄바꿈으로 먼저 분리 + const lines = cleanedText.split(/[\r\n]+/).map(line => line.trim()).filter(line => line.length > 0); + + const allParts: string[] = []; + lines.forEach(line => { + // 공백과 특수문자로 분리 + const parts = line.split(/[\s\-_]+/).filter(part => part.length > 0); + allParts.push(...parts); + }); + + console.log(` Split into parts:`, allParts); + + if (allParts.length === 0) { + return { identificationNo: cleanedText, tagNo: '', jointNo: '' }; + } + + if (allParts.length === 1) { + return { identificationNo: allParts[0], tagNo: '', jointNo: '' }; + } + + // 길이별로 정렬하여 식별 + const sortedParts = [...allParts].sort((a, b) => b.length - a.length); + + const identificationNo = sortedParts[0]; // 가장 긴 것 + const jointNo = allParts.find(part => part.length <= 3 && /^[A-Z0-9]+$/i.test(part)) || + sortedParts[sortedParts.length - 1]; // 3글자 이하 영숫자 또는 가장 짧은 것 + const tagNo = allParts.find(part => part !== identificationNo && part !== jointNo) || ''; + + const result = { identificationNo, tagNo, jointNo }; + console.log(` Parsed result:`, result); + + return result; +} + +// 날짜 컬럼 찾기 +function findDateColumn(row: string[]): number { + const datePattern = /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/; + + for (let i = 0; i < row.length; i++) { + if (datePattern.test(row[i])) { + console.log(` Found date in column ${i}: "${row[i]}"`); + return i; + } + } + + return -1; +} + +// 텍스트 정리 +function cleanText(text: string): string { + return text + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// 빈 행 확인 +function isEmptyRow(row: string[]): boolean { + return row.every(cell => !cell || cell.trim().length === 0); +} + +// 유효한 행 확인 +function isValidRow(row: ExtractedRow): boolean { + // 번호나 식별번호 중 하나라도 있으면 유효 + const hasBasicData = !!(row.no || row.identificationNo); + + // 너무 짧은 데이터는 제외 (오인식 방지) + const hasReasonableLength = (row.identificationNo?.length || 0) >= 3 || + (row.no?.length || 0) >= 1; + + return hasBasicData && hasReasonableLength; +} + +// 행 신뢰도 계산 +function calculateRowConfidence(row: ExtractedRow): number { + let score = 0; + let maxScore = 0; + + // 각 필드별 가중치 + const weights = { + no: 1, + identificationNo: 3, // 가장 중요 + tagNo: 2, + jointNo: 2, + jointType: 1, + weldingDate: 1 + }; + + Object.entries(weights).forEach(([field, weight]) => { + maxScore += weight; + const value = row[field as keyof ExtractedRow] as string; + + if (value && value.length > 0) { + // 기본 점수 + score += weight * 0.5; + + // 길이 보너스 + if (field === 'identificationNo' && value.length > 10) { + score += weight * 0.3; + } else if (field === 'no' && /^\d+$/.test(value)) { + score += weight * 0.3; + } else if (field === 'weldingDate' && /\d{4}[.\-\/]\d{1,2}[.\-\/]\d{1,2}/.test(value)) { + score += weight * 0.3; + } else if (value.length > 2) { + score += weight * 0.2; + } + } + }); + + return maxScore > 0 ? Math.min(score / maxScore, 1) : 0; +} + +// 유틸리티: OCR 결과 품질 분석 +export function analyzeOCRQuality(ocrResult: any): { + confidence: number; + tablesFound: number; + textQuality: number; + keywordCount: number; +} { + let totalConfidence = 0; + let totalFields = 0; + let tablesFound = 0; + let relevantKeywords = 0; + + const keywords = ['no.', 'identification', 'joint', 'tag', 'type', 'weld', 'date']; + + if (ocrResult.images) { + ocrResult.images.forEach((image: any) => { + // 테이블 분석 + if (image.tables) { + tablesFound += image.tables.length; + } + + // 필드 신뢰도 분석 + if (image.fields) { + image.fields.forEach((field: any) => { + const confidence = field.inferConfidence || 0; + const text = (field.inferText || '').toLowerCase(); + + totalConfidence += confidence; + totalFields++; + + // 관련 키워드 확인 + keywords.forEach(keyword => { + if (text.includes(keyword)) { + relevantKeywords++; + } + }); + }); + } + }); + } + + const avgConfidence = totalFields > 0 ? totalConfidence / totalFields : 0; + const textQuality = totalFields > 0 ? relevantKeywords / totalFields : 0; + + return { + confidence: avgConfidence, + tablesFound, + textQuality, + keywordCount: relevantKeywords + }; +}
\ No newline at end of file diff --git a/app/api/sync/import/route.ts b/app/api/sync/import/route.ts new file mode 100644 index 00000000..a6496578 --- /dev/null +++ b/app/api/sync/import/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server" +import { importService } from "@/lib/vendor-document-list/import-service" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { contractId, sourceSystem = 'DOLCE' } = body + + if (!contractId) { + return NextResponse.json( + { error: 'Contract ID is required' }, + { status: 400 } + ) + } + + const result = await importService.importFromExternalSystem( + contractId, + sourceSystem + ) + + return NextResponse.json(result) + } catch (error) { + console.error('Import failed:', error) + return NextResponse.json( + { + error: 'Import failed', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +} diff --git a/app/api/sync/import/status/route.ts b/app/api/sync/import/status/route.ts new file mode 100644 index 00000000..c5b4b0bd --- /dev/null +++ b/app/api/sync/import/status/route.ts @@ -0,0 +1,41 @@ +// app/api/sync/import/status/route.ts +import { NextRequest, NextResponse } from "next/server" +import { importService } from "@/lib/vendor-document-list/import-service" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const contractId = searchParams.get('contractId') + const sourceSystem = searchParams.get('sourceSystem') || 'DOLCE' + + if (!contractId) { + return NextResponse.json( + { error: 'Contract ID is required' }, + { status: 400 } + ) + } + + const status = await importService.getImportStatus( + Number(contractId), + sourceSystem + ) + + return NextResponse.json(status) + } catch (error) { + console.error('Failed to get import status:', error) + return NextResponse.json( + { + error: 'Failed to get import status', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/sync/workflow/action/route.ts b/app/api/sync/workflow/action/route.ts new file mode 100644 index 00000000..b6b1a94f --- /dev/null +++ b/app/api/sync/workflow/action/route.ts @@ -0,0 +1,44 @@ +// app/api/sync/workflow/action/route.ts +import { NextRequest, NextResponse } from "next/server" +import { workflowService } from "@/lib/vendor-document-list/workflow-service" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await request.json() + const { contractId, targetSystem = 'SWP', action, documents } = body + + if (!contractId || !action) { + return NextResponse.json( + { error: 'Contract ID and action are required' }, + { status: 400 } + ) + } + + const result = await workflowService.executeWorkflowAction( + contractId, + targetSystem, + action, + documents || [], + session.user.id, + session.user.name + ) + + return NextResponse.json(result) + } catch (error) { + console.error('Workflow action failed:', error) + return NextResponse.json( + { + error: 'Workflow action failed', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +}
\ No newline at end of file diff --git a/app/api/sync/workflow/status/route.ts b/app/api/sync/workflow/status/route.ts new file mode 100644 index 00000000..a4c5d1d0 --- /dev/null +++ b/app/api/sync/workflow/status/route.ts @@ -0,0 +1,41 @@ +// app/api/sync/workflow/status/route.ts +import { NextRequest, NextResponse } from "next/server" +import { workflowService } from "@/lib/vendor-document-list/workflow-service" +import { getServerSession } from "next-auth" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const contractId = searchParams.get('contractId') + const targetSystem = searchParams.get('targetSystem') || 'SWP' + + if (!contractId) { + return NextResponse.json( + { error: 'Contract ID is required' }, + { status: 400 } + ) + } + + const status = await workflowService.getWorkflowStatus( + Number(contractId), + targetSystem + ) + + return NextResponse.json(status) + } catch (error) { + console.error('Failed to get workflow status:', error) + return NextResponse.json( + { + error: 'Failed to get workflow status', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ) + } +}
\ No newline at end of file |
