summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/partners/ocr/page.tsx133
-rw-r--r--app/api/ocr/enhanced/route.ts577
-rw-r--r--app/api/ocr/utils/imageRotation.ts244
-rw-r--r--app/api/ocr/utils/tableExtraction.ts556
-rw-r--r--app/api/sync/import/route.ts39
-rw-r--r--app/api/sync/import/status/route.ts41
-rw-r--r--app/api/sync/workflow/action/route.ts44
-rw-r--r--app/api/sync/workflow/status/route.ts41
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