diff options
| -rw-r--r-- | app/api/basic-contract/get-template/route.ts | 52 | ||||
| -rw-r--r-- | lib/basic-contract/service.ts | 12 | ||||
| -rw-r--r-- | lib/basic-contract/template/template-editor-wrapper.tsx | 103 | ||||
| -rw-r--r-- | lib/basic-contract/vendor-table/basic-contract-columns.tsx | 38 | ||||
| -rw-r--r-- | lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx | 10 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 11 | ||||
| -rw-r--r-- | lib/pdftron/serverSDK/createBasicContractPdf.ts | 137 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 992 | ||||
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 193 | ||||
| -rw-r--r-- | pages/api/pdftron/createBasicContractPdf.ts | 359 |
10 files changed, 1483 insertions, 424 deletions
diff --git a/app/api/basic-contract/get-template/route.ts b/app/api/basic-contract/get-template/route.ts new file mode 100644 index 00000000..111532f0 --- /dev/null +++ b/app/api/basic-contract/get-template/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; +import db from "@/db/db"; +import { basicContractTemplates } from '@/db/schema'; +import { eq } from 'drizzle-orm'; + +export async function POST(request: NextRequest) { + try { + const { templateId } = await request.json(); + + if (!templateId) { + return NextResponse.json({ error: '템플릿 ID가 누락되었습니다.' }, { status: 400 }); + } + + // 데이터베이스에서 템플릿 정보 가져오기 + const template = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, templateId) + }); + + if (!template) { + return NextResponse.json({ error: '템플릿을 찾을 수 없습니다.' }, { status: 404 }); + } + + if (!template.filePath) { + return NextResponse.json({ error: '템플릿 파일 경로가 없습니다.' }, { status: 404 }); + } + + // 파일 시스템에서 파일 읽기 + const fullPath = path.join(process.cwd(), "public", template.filePath); + + try { + const fileBuffer = await fs.readFile(fullPath); + + // 파일을 Blob으로 반환 + return new NextResponse(fileBuffer, { + status: 200, + headers: { + 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(template.fileName || 'template.docx')}"`, + }, + }); + } catch (fileError) { + console.error('파일 읽기 오류:', fileError); + return NextResponse.json({ error: '템플릿 파일을 읽을 수 없습니다.' }, { status: 500 }); + } + + } catch (error) { + console.error('템플릿 가져오기 오류:', error); + return NextResponse.json({ error: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 9b5505b5..03b27f96 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -1129,4 +1129,16 @@ export async function getExistingTemplateNames(): Promise<string[]> { .groupBy(basicContractTemplates.templateName);
return rows.map((r) => r.templateName);
+}
+
+export async function getExistingTemplateNamesById(id:number): Promise<string> {
+ const rows = await db
+ .select({
+ templateName: basicContractTemplates.templateName,
+ })
+ .from(basicContractTemplates)
+ .where(and(eq(basicContractTemplates.status,"ACTIVE"),eq(basicContractTemplates.id,id)))
+ .limit(1)
+
+ return rows[0].templateName;
}
\ No newline at end of file diff --git a/lib/basic-contract/template/template-editor-wrapper.tsx b/lib/basic-contract/template/template-editor-wrapper.tsx index ea353b91..96e2330f 100644 --- a/lib/basic-contract/template/template-editor-wrapper.tsx +++ b/lib/basic-contract/template/template-editor-wrapper.tsx @@ -7,7 +7,7 @@ import { Save, RefreshCw, Type, FileText, AlertCircle } from "lucide-react"; import type { WebViewerInstance } from "@pdftron/webviewer"; import { Badge } from "@/components/ui/badge"; import { BasicContractTemplateViewer } from "./basic-contract-template-viewer"; -import { saveTemplateFile } from "../service"; +import { getExistingTemplateNamesById, saveTemplateFile } from "../service"; interface TemplateEditorWrapperProps { templateId: string | number; @@ -16,9 +16,27 @@ interface TemplateEditorWrapperProps { refreshAction?: () => Promise<void>; } +// 템플릿 이름별 변수 매핑 (영문 변수명 사용) +const TEMPLATE_VARIABLES_MAP = { + "준법서약 (한글)": ["vendor_name", "address", "representative_name", "today_date"], + "준법서약 (영문)": ["vendor_name", "address", "representative_name", "today_date"], + "기술자료 요구서": ["vendor_name", "address", "representative_name", "today_date"], + "비밀유지 계약서": ["vendor_name", "address", "representative_name", "today_date"], + "표준하도급기본 계약서": ["vendor_name", "address", "representative_name", "today_date"], + "GTC": ["vendor_name", "address", "representative_name", "today_date"], + "안전보건관리 약정서": ["vendor_name", "address", "representative_name", "today_date"], + "동반성장": ["vendor_name", "address", "representative_name", "today_date"], + "윤리규범 준수 서약서": ["vendor_name", "address", "representative_name", "today_date"], + "기술자료 동의서": ["vendor_name", "address", "representative_name", "today_date"], + "내국신용장 미개설 합의서": ["vendor_name", "address", "representative_name", "today_date"], + "직납자재 하도급대급등 연동제 의향서": ["vendor_name", "address", "representative_name", "today_date"] +} as const; + // 변수 패턴 감지를 위한 정규식 const VARIABLE_PATTERN = /\{\{([^}]+)\}\}/g; + + export function TemplateEditorWrapper({ templateId, filePath, @@ -28,6 +46,35 @@ export function TemplateEditorWrapper({ const [instance, setInstance] = React.useState<WebViewerInstance | null>(null); const [isSaving, setIsSaving] = React.useState(false); const [documentVariables, setDocumentVariables] = React.useState<string[]>([]); + const [templateName, setTemplateName] = React.useState<string>(""); + const [predefinedVariables, setPredefinedVariables] = React.useState<string[]>([]); + + console.log(templateId, "templateId"); + + // 템플릿 이름 로드 및 변수 설정 + React.useEffect(() => { + const loadTemplateInfo = async () => { + try { + const name = await getExistingTemplateNamesById(Number(templateId)); + setTemplateName(name); + + // 템플릿 이름에 따른 변수 설정 + const variables = TEMPLATE_VARIABLES_MAP[name as keyof typeof TEMPLATE_VARIABLES_MAP] || []; + setPredefinedVariables(variables); + + console.log("🏷️ 템플릿 이름:", name); + console.log("📝 할당된 변수들:", variables); + } catch (error) { + console.error("템플릿 정보 로드 오류:", error); + // 기본 변수 설정 + setPredefinedVariables(["회사명", "주소", "대표자명", "오늘날짜"]); + } + }; + + if (templateId) { + loadTemplateInfo(); + } + }, [templateId]); // 문서에서 변수 추출 const extractVariablesFromDocument = async () => { @@ -220,13 +267,6 @@ export function TemplateEditorWrapper({ window.location.reload(); }; - // 미리 정의된 변수들 - const predefinedVariables = [ - "회사명", "계약자명", "계약일자", "계약금액", - "납기일자", "담당자명", "담당자연락처", "프로젝트명", - "계약번호", "사업부", "부서명", "승인자명" - ]; - return ( <div className="h-full flex flex-col"> {/* 상단 도구 모음 */} @@ -267,6 +307,12 @@ export function TemplateEditorWrapper({ <FileText className="mr-1 h-3 w-3" /> {fileName} </Badge> + {templateName && ( + <Badge variant="secondary"> + <Type className="mr-1 h-3 w-3" /> + {templateName} + </Badge> + )} {documentVariables.length > 0 && ( <Badge variant="secondary"> <Type className="mr-1 h-3 w-3" /> @@ -283,6 +329,11 @@ export function TemplateEditorWrapper({ <h4 className="text-sm font-medium flex items-center"> <Type className="mr-2 h-4 w-4 text-blue-500" /> 변수 관리 + {templateName && ( + <span className="ml-2 text-xs text-muted-foreground"> + ({templateName}) + </span> + )} </h4> </div> @@ -301,23 +352,27 @@ export function TemplateEditorWrapper({ </div> )} - {/* 미리 정의된 변수들 */} - <div> - <p className="text-xs text-muted-foreground mb-2">자주 사용하는 변수 (클릭하여 복사):</p> - <div className="flex flex-wrap gap-1"> - {predefinedVariables.map((variable, index) => ( - <Button - key={index} - variant="ghost" - size="sm" - className="h-6 px-2 text-xs" - onClick={() => insertVariable(variable)} - > - {`{{${variable}}}`} - </Button> - ))} + {/* 템플릿별 미리 정의된 변수들 */} + {predefinedVariables.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2"> + {templateName ? `${templateName}에 권장되는 변수` : "자주 사용하는 변수"} (클릭하여 복사): + </p> + <div className="flex flex-wrap gap-1"> + {predefinedVariables.map((variable, index) => ( + <Button + key={index} + variant="ghost" + size="sm" + className="h-6 px-2 text-xs hover:bg-blue-50" + onClick={() => insertVariable(variable)} + > + {`{{${variable}}}`} + </Button> + ))} + </div> </div> - </div> + )} </div> </div> )} diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx index b79487d7..c9e8da53 100644 --- a/lib/basic-contract/vendor-table/basic-contract-columns.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" import { formatDate, formatDateTime } from "@/lib/utils" +import { downloadFile } from "@/lib/file-download" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -36,28 +37,24 @@ interface GetColumnsProps { /** * 파일 다운로드 함수 */ -/** - * 파일 다운로드 함수 - */ -const handleFileDownload = (filePath: string | null, fileName: string | null) => { +const handleFileDownload = async (filePath: string | null, fileName: string | null) => { if (!filePath || !fileName) { toast.error("파일 정보가 없습니다."); return; } try { - // 전체 URL 생성 - const fullUrl = `${window.location.origin}${filePath}`; + // /api/files/ 엔드포인트를 통한 안전한 다운로드 + const apiFilePath = `/api/files/${filePath.startsWith('/') ? filePath.substring(1) : filePath}`; - // a 태그를 생성하여 다운로드 실행 - const link = document.createElement('a'); - link.href = fullUrl; - link.download = fileName; // 다운로드될 파일명 설정 - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - toast.success("파일 다운로드를 시작합니다."); + const result = await downloadFile(apiFilePath, fileName, { + action: 'download', + showToast: true + }); + + if (!result.success) { + console.error("파일 다운로드 실패:", result.error); + } } catch (error) { console.error("파일 다운로드 오류:", error); toast.error("파일 다운로드 중 오류가 발생했습니다."); @@ -104,16 +101,19 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicCo id: "download", header: "", cell: ({ row }) => { - const template = row.original; - const filePath = template.status === "PENDING" ? template.filePath : template.signedFilePath + const contract = row.original; + // PENDING 상태일 때는 원본 PDF 파일 (signedFilePath), COMPLETED일 때는 서명된 파일 (signedFilePath) + const filePath = contract.signedFilePath; + const fileName = contract.signedFileName; return ( <Button variant="ghost" size="icon" - onClick={() => handleFileDownload(filePath, template.fileName)} - title={`${template.fileName} 다운로드`} + onClick={() => handleFileDownload(filePath, fileName)} + title={`${fileName} 다운로드`} className="hover:bg-muted" + disabled={!filePath || !fileName} > <Paperclip className="h-4 w-4" /> <span className="sr-only">다운로드</span> diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 28a4fd71..7bffdac9 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -68,7 +68,7 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS const term = searchTerm.toLowerCase(); return contracts.filter(contract => (contract.templateName || '').toLowerCase().includes(term) || - (contract.userName || '').toLowerCase().includes(term) + (contract.requestedByName || '').toLowerCase().includes(term) ); }, [contracts, searchTerm]); @@ -98,7 +98,7 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS const formData = new FormData(); formData.append('file', new Blob([data], { type: 'application/pdf' })); formData.append('tableRowId', selectedContract.id.toString()); - formData.append('templateName', selectedContract.fileName || ''); + formData.append('templateName', selectedContract.signedFileName || ''); // API 호출 const response = await fetch('/api/upload/signed-contract', { @@ -227,7 +227,7 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500"> <div className="flex items-center"> <User className="h-3 w-3 mr-1" /> - <span className="truncate">{contract.userName || '알 수 없음'}</span> + <span className="truncate">{contract.requestedByName || '알 수 없음'}</span> </div> <div className="flex items-center justify-end"> <Calendar className="h-3 w-3 mr-1" /> @@ -255,7 +255,7 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS <div className="flex justify-between items-center mt-1 text-xs text-gray-500"> <span className="flex items-center"> <User className="h-3 w-3 mr-1" /> - 요청자: {selectedContract.userName || '알 수 없음'} + 요청자: {selectedContract.requestedByName || '알 수 없음'} </span> <span className="flex items-center"> <Clock className="h-3 w-3 mr-1" /> @@ -266,7 +266,7 @@ export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractS <div className="flex-grow overflow-hidden border-b"> <BasicContractSignViewer contractId={selectedContract.id} - filePath={selectedContract.filePath || undefined} + filePath={selectedContract.signedFilePath || undefined} instance={instance} setInstance={setInstance} /> diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 0409151e..8995c560 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -108,8 +108,17 @@ export function BasicContractSignViewer({ // 문서 로드 useEffect(() => { if (!instance || !filePath) return; + console.log("📄 파일 로드 시도:", { filePath }); - loadDocument(instance, filePath); + + // filePath를 /api/files/ 엔드포인트를 통해 접근하도록 변환 + // 한글 파일명의 경우 URL 인코딩 처리 + const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath; + const encodedPath = normalizedPath.split('/').map(part => encodeURIComponent(part)).join('/'); + const apiFilePath = `/api/files/${encodedPath}`; + + console.log("📄 파일 로드 시도:", { originalPath: filePath, encodedPath: apiFilePath }); + loadDocument(instance, apiFilePath); }, [instance, filePath]); // 간소화된 문서 로드 함수 diff --git a/lib/pdftron/serverSDK/createBasicContractPdf.ts b/lib/pdftron/serverSDK/createBasicContractPdf.ts new file mode 100644 index 00000000..a2e0b350 --- /dev/null +++ b/lib/pdftron/serverSDK/createBasicContractPdf.ts @@ -0,0 +1,137 @@ +const { PDFNet } = require("@pdftron/pdfnet-node"); + +type CreateBasicContractPdf = ( + templateBuffer: Buffer, + templateData: { + [key: string]: any; + } +) => Promise<{ + result: boolean; + buffer?: ArrayBuffer; + error?: any; +}>; + +export const createBasicContractPdf: CreateBasicContractPdf = async ( + templateBuffer, + templateData +) => { + const main = async () => { + await PDFNet.initialize(process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY); + + console.log("🔄 PDFTron 기본계약서 PDF 변환 시작"); + console.log("📝 템플릿 데이터:", JSON.stringify(templateData, null, 2)); + + // 템플릿 데이터가 있는 경우 변수 치환 후 PDF 변환 + if (Object.keys(templateData).length > 0) { + console.log("🔄 템플릿 변수 치환 시작"); + + try { + // createReport.ts 방식처럼 템플릿 변수 치환 (UTF-8 인코딩 지원) + const options = new PDFNet.Convert.OfficeToPDFOptions(); + + // UTF-8 인코딩 명시 설정 시도 + try { + options.setCharset("UTF-8"); + console.log("✅ UTF-8 인코딩 설정 완료"); + } catch (charsetError) { + console.warn("⚠️ UTF-8 인코딩 설정 실패, 기본 설정 사용:", charsetError); + } + + // 템플릿 데이터를 UTF-8로 명시적으로 인코딩 + const templateDataJson = JSON.stringify(templateData, null, 2); + const utf8TemplateData = Buffer.from(templateDataJson, 'utf8').toString('utf8'); + console.log("📝 UTF-8 인코딩된 템플릿 데이터:", utf8TemplateData); + + const tempPath = `/tmp/temp_template_${Date.now()}.docx`; + + // 파일도 UTF-8로 저장 (바이너리 데이터는 그대로 유지) + require('fs').writeFileSync(tempPath, templateBuffer, { encoding: null }); // 바이너리로 저장 + + // Office 템플릿 생성 및 변수 치환 + const templateDoc = await PDFNet.Convert.createOfficeTemplateWithPath( + tempPath, + options + ); + + const filledDoc = await templateDoc.fillTemplateJson(utf8TemplateData); + + // 임시 파일 삭제 + require('fs').unlinkSync(tempPath); + + console.log("✅ 템플릿 변수 치환 및 PDF 변환 완료"); + + const buffer = await filledDoc.saveMemoryBuffer( + PDFNet.SDFDoc.SaveOptions.e_linearized + ); + + return { + result: true, + buffer, + }; + + } catch (templateError) { + console.warn("⚠️ 템플릿 변수 치환 실패, 기본 변환 수행:", templateError); + + // 템플릿 처리 실패 시 기본 PDF 변환만 수행 (UTF-8 인코딩 적용) + const fallbackOptions = new PDFNet.Convert.OfficeToPDFOptions(); + try { + fallbackOptions.setCharset("UTF-8"); + } catch (charsetError) { + console.warn("⚠️ 폴백 UTF-8 인코딩 설정 실패:", charsetError); + } + + const buf = await PDFNet.Convert.office2PDFBuffer(templateBuffer, fallbackOptions); + const templateDoc = await PDFNet.PDFDoc.createFromBuffer(buf); + + const buffer = await templateDoc.saveMemoryBuffer( + PDFNet.SDFDoc.SaveOptions.e_linearized + ); + + return { + result: true, + buffer, + }; + } + } else { + // 템플릿 데이터가 없는 경우 단순 변환 (UTF-8 인코딩 적용) + console.log("📄 단순 PDF 변환 수행 (UTF-8 인코딩)"); + + const simpleOptions = new PDFNet.Convert.OfficeToPDFOptions(); + try { + simpleOptions.setCharset("UTF-8"); + console.log("✅ 단순 변환 UTF-8 인코딩 설정 완료"); + } catch (charsetError) { + console.warn("⚠️ 단순 변환 UTF-8 인코딩 설정 실패:", charsetError); + } + + const buf = await PDFNet.Convert.office2PDFBuffer(templateBuffer, simpleOptions); + const templateDoc = await PDFNet.PDFDoc.createFromBuffer(buf); + + const buffer = await templateDoc.saveMemoryBuffer( + PDFNet.SDFDoc.SaveOptions.e_linearized + ); + + return { + result: true, + buffer, + }; + } + }; + + const result = await PDFNet.runWithCleanup( + main, + process.env.NEXT_PUBLIC_PDFTRON_SERVER_KEY + ) + .catch((err: any) => { + console.error("❌ PDFTron 기본계약서 PDF 변환 오류:", err); + return { + result: false, + error: err, + }; + }) + .then(async (data: any) => { + return data; + }); + + return result; +}; diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 7c8df1a6..d91fbd03 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -5,11 +5,14 @@ import db from "@/db/db"; import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMateirals, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema"; import logger from '@/lib/logger'; import * as z from "zod" +import crypto from 'crypto'; +import fs from 'fs/promises'; +import path from 'path'; +import { v4 as uuidv4 } from 'uuid'; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; -import { headers } from 'next/headers'; import { selectVendors, @@ -49,17 +52,17 @@ import type { import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; -import path from "path"; import { sendEmail } from "../mail/sendEmail"; import { PgTransaction } from "drizzle-orm/pg-core"; import { items, materials } from "@/db/schema/items"; -import { roles, userRoles, users } from "@/db/schema/users"; +import { mfaTokens, roles, userRoles, users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema"; -import { deleteFile, saveFile } from "../file-stroage"; - - +import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorsLogs } from "@/db/schema"; +import { deleteFile, saveFile, saveBuffer } from "../file-stroage"; +import { basicContractTemplates } from "@/db/schema/basicContractDocumnet"; +import { basicContract } from "@/db/schema/basicContractDocumnet"; +import { headers } from 'next/headers'; /* ----------------------------------------------------- 1) 조회 관련 ----------------------------------------------------- */ @@ -74,14 +77,14 @@ export async function getVendors(input: GetVendorsSchema) { async () => { try { const offset = (input.page - 1) * input.perPage; - + // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); - + // 2) 글로벌 검색 let globalWhere; if (input.search) { @@ -95,10 +98,10 @@ export async function getVendors(input: GetVendorsSchema) { ilike(vendorsWithTypesView.vendorTypeName, s) ); } - + // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); - + // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName @@ -109,10 +112,10 @@ export async function getVendors(input: GetVendorsSchema) { ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); - + // 실제 사용될 where const where = finalWhere; - + // 정렬 const orderBy = input.sort.length > 0 @@ -120,7 +123,7 @@ export async function getVendors(input: GetVendorsSchema) { item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) : [asc(vendorsWithTypesView.createdAt)]; - + // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { // 1) vendor 목록 조회 (view 사용) @@ -130,7 +133,7 @@ export async function getVendors(input: GetVendorsSchema) { offset, limit: input.perPage, }); - + // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { @@ -142,7 +145,7 @@ export async function getVendors(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + return { ...vendor, hasAttachments: attachments.length > 0, @@ -150,17 +153,17 @@ export async function getVendors(input: GetVendorsSchema) { }; }) ); - + // 3) 전체 개수 const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); console.log(total) - + // 페이지 수 const pageCount = Math.ceil(total / input.perPage); - + return { data, pageCount }; } catch (err) { console.error("Error fetching vendors:", err); @@ -231,11 +234,11 @@ async function storeVendorFiles( files: File[], attachmentType: string ) { - + for (const file of files) { - const saveResult = await saveFile({file, directory:`vendors/${vendorId}` }) + const saveResult = await saveFile({ file, directory: `vendors/${vendorId}` }) // Insert attachment record await tx.insert(vendorAttachments).values({ @@ -250,7 +253,7 @@ async function storeVendorFiles( export async function getVendorTypes() { unstable_noStore(); // Next.js server action caching prevention - + try { const types = await db .select({ @@ -261,7 +264,7 @@ export async function getVendorTypes() { }) .from(vendorTypes) .orderBy(vendorTypes.nameKo); - + return { data: types, error: null }; } catch (error) { return { data: null, error: getErrorMessage(error) }; @@ -278,12 +281,12 @@ export type CreateVendorData = { address?: string email: string phone?: string - + representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string - + creditAgency?: string creditRating?: string cashFlowRating?: string @@ -299,7 +302,7 @@ export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -312,17 +315,17 @@ export async function createVendor(params: { }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 - + try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params - + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); - + // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { return { @@ -330,14 +333,14 @@ export async function createVendor(params: { error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + // taxId 중복 검사 추가 const existingVendor = await db .select({ id: vendors.id }) .from(vendors) .where(eq(vendors.taxId, vendorData.taxId)) .limit(1); - + // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 if (existingVendor.length > 0) { return { @@ -345,7 +348,7 @@ export async function createVendor(params: { error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` }; } - + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -360,36 +363,36 @@ export async function createVendor(params: { taxId: vendorData.taxId, vendorTypeId: vendorData.vendorTypeId, items: vendorData.items || null, - + // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, - + // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) - + // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } - + // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } - + // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } - + for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, @@ -401,7 +404,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -414,7 +417,7 @@ export async function createVendor(params: { /** 단건 업데이트 */ export async function modifyVendor( - input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가 + input: UpdateVendorSchema & { id: string; userId: number; comment: string; } // userId 추가 ) { unstable_noStore(); try { @@ -915,16 +918,16 @@ export async function deleteVendorItem( ) ) - revalidateTag(`vendor-items-${vendorId}`); - + revalidateTag(`vendor-items-${vendorId}`); + return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) - return { - success: false, - message: error instanceof z.ZodError - ? error.errors[0].message - : "Failed to delete item" + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" } } } @@ -935,7 +938,7 @@ export async function updateVendorItem( newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - + try { const validatedData = updateVendorItemSchema.parse({ oldItemCode, @@ -963,12 +966,12 @@ export async function updateVendorItem( // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -979,7 +982,7 @@ export async function removeVendorItems(input: { vendorId: number }) { unstable_noStore() - + try { const validatedData = removeVendorItemsSchema.parse(input) @@ -993,12 +996,12 @@ export async function removeVendorItems(input: { ) revalidateTag(`vendor-items-${validatedData.vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1111,16 +1114,16 @@ export async function deleteVendorMaterial( ) ) - revalidateTag(`vendor-materials-${vendorId}`); - + revalidateTag(`vendor-materials-${vendorId}`); + return { success: true, message: "Item deleted successfully" } } catch (error) { console.error("Error deleting vendor item:", error) - return { - success: false, - message: error instanceof z.ZodError - ? error.errors[0].message - : "Failed to delete item" + return { + success: false, + message: error instanceof z.ZodError + ? error.errors[0].message + : "Failed to delete item" } } } @@ -1131,7 +1134,7 @@ export async function updateVendorMaterial( newItemCode: string ) { unstable_noStore(); // Next.js 서버 액션 캐싱 방지 - + try { const validatedData = updateVendorMaterialSchema.parse({ oldItemCode, @@ -1159,12 +1162,12 @@ export async function updateVendorMaterial( // 캐시 무효화 revalidateTag(`vendor-items-${vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error updating vendor item:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1175,7 +1178,7 @@ export async function removeVendorMaterials(input: { vendorId: number }) { unstable_noStore() - + try { const validatedData = removeVendormaterialsSchema.parse(input) @@ -1189,12 +1192,12 @@ export async function removeVendorMaterials(input: { ) revalidateTag(`vendor-materials-${validatedData.vendorId}`) - + return { data: null, error: null } } catch (err) { console.error("Error deleting vendor items:", err) - return { - data: null, + return { + data: null, error: getErrorMessage(err) } } @@ -1366,13 +1369,13 @@ interface CreateCompanyInput { * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ -export async function downloadVendorAttachments(vendorId:number, fileId?:number) { +export async function downloadVendorAttachments(vendorId: number, fileId?: number) { try { // API 경로 생성 (단일 파일 또는 모든 파일) const url = fileId ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; - + // fetch 요청 (기본적으로 Blob으로 응답 받기) const response = await fetch(url, { method: 'GET', @@ -1380,29 +1383,29 @@ export async function downloadVendorAttachments(vendorId:number, fileId?:number) 'Content-Type': 'application/json', }, }); - + if (!response.ok) { throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } - + // 파일명 가져오기 (Content-Disposition 헤더에서) const contentDisposition = response.headers.get('content-disposition'); let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`; - + if (contentDisposition) { const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); if (matches && matches[1]) { fileName = matches[1].replace(/['"]/g, ''); } } - + // Blob으로 응답 변환 const blob = await response.blob(); - + // Blob URL 생성 const blobUrl = window.URL.createObjectURL(blob); - - return { + + return { url: blobUrl, fileName, blob @@ -1442,9 +1445,9 @@ interface ApproveVendorsInput { */ export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); - + try { - // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 활성화 및 이메일 발송 const result = await db.transaction(async (tx) => { // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx @@ -1465,73 +1468,135 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 협력업체 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 (국가 정보 포함) const updatedVendors = await tx .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email, + country: vendors.country, // 언어 설정용 국가 정보 }) .from(vendors) .where(inArray(vendors.id, input.ids)); - // 3. 각 벤더에 대한 유저 계정 생성 + // 3. 각 벤더에 대한 유저 계정 처리 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 - // 이미 존재하는 유저인지 확인 - const existingUser = await tx.query.users.findFirst({ - where: eq(users.email, vendor.email), - columns: { - id: true + // 기존 유저 확인 (비활성 포함) + const existingUser = await tx + .select({ + id: users.id, + isActive: users.isActive, + language: users.language, + }) + .from(users) + .where(eq(users.email, vendor.email)) + .limit(1); + + let currentUser; + + if (existingUser.length > 0) { + // 🔄 기존 사용자 존재 시 - 활성화 + const user = existingUser[0]; + console.log(`👤 기존 사용자 발견: ${vendor.email} (활성상태: ${user.isActive})`); + + if (!user.isActive) { + // 비활성 사용자 활성화 + const [activatedUser] = await tx + .update(users) + .set({ + isActive: true, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)) + .returning({ id: users.id }); + + console.log(`✅ 사용자 활성화 완료: ${vendor.email} (ID: ${user.id})`); + currentUser = { id: user.id }; + } else { + console.log(`ℹ️ 사용자가 이미 활성 상태: ${vendor.email}`); + currentUser = { id: user.id }; } - }); + } else { + // 🆕 새 사용자 생성 (회원가입을 거치지 않은 경우 - 드문 케이스) + console.log(`🆕 새 사용자 생성: ${vendor.email}`); + + // 국가코드에 따른 언어 설정 + const language = vendor.country === 'KR' ? 'ko' : 'en'; - // 유저가 존재하지 않는 경우에만 생성 - if (!existingUser) { - // 유저 생성 const [newUser] = await tx.insert(users).values({ name: vendor.vendorName, email: vendor.email, companyId: vendor.id, - domain: "partners", // 기본값으로 이미 설정되어 있지만 명시적으로 지정 + domain: "partners", + language, // 국가별 언어 설정 + isActive: true, // 승인과 동시에 활성화 + // 기본 보안 설정 + mfaEnabled: false, + isLocked: false, + failedLoginAttempts: 0, + passwordChangeRequired: true, // 패스워드 설정 필요 + requiresConsentUpdate: false, }).returning({ id: users.id }); - // "Vendor Admin" 역할 찾기 또는 생성 - let vendorAdminRole = await tx.query.roles.findFirst({ - where: and( - eq(roles.name, "Vendor Admin"), - eq(roles.domain, "partners"), - eq(roles.companyId, vendor.id) - ), - columns: { - id: true - } - }); + console.log(`✅ 새 사용자 생성 완료: ${vendor.email} (ID: ${newUser.id}, 언어: ${language})`); + currentUser = newUser; + } - // "Vendor Admin" 역할이 없다면 생성 - if (!vendorAdminRole) { - const [newRole] = await tx.insert(roles).values({ - name: "Vendor Admin", - domain: "partners", - companyId: vendor.id, - description: "Vendor Administrator role", - }).returning({ id: roles.id }); - - vendorAdminRole = newRole; + // 4. 역할 할당 (기존 로직 유지) + // "Vendor Admin" 역할 찾기 또는 생성 + let vendorAdminRole = await tx.query.roles.findFirst({ + where: and( + eq(roles.name, "Vendor Admin"), + eq(roles.domain, "partners"), + eq(roles.companyId, vendor.id) + ), + columns: { + id: true } + }); - // userRoles 테이블에 관계 생성 + // "Vendor Admin" 역할이 없다면 생성 + if (!vendorAdminRole) { + const [newRole] = await tx.insert(roles).values({ + name: "Vendor Admin", + domain: "partners", + companyId: vendor.id, + description: "Vendor Administrator role", + }).returning({ id: roles.id }); + + vendorAdminRole = newRole; + console.log(`🎭 새 역할 생성: Vendor Admin (업체: ${vendor.vendorName})`); + } + + // 기존 사용자-역할 관계 확인 + const existingUserRole = await tx + .select({ id: userRoles.id }) + .from(userRoles) + .where( + and( + eq(userRoles.userId, currentUser.id), + eq(userRoles.roleId, vendorAdminRole.id) + ) + ) + .limit(1); + + // 역할이 할당되지 않은 경우에만 추가 + if (existingUserRole.length === 0) { await tx.insert(userRoles).values({ - userId: newUser.id, + userId: currentUser.id, roleId: vendorAdminRole.id, }); + console.log(`🔗 역할 할당: 사용자 ${currentUser.id} → Vendor Admin`); + } else { + console.log(`ℹ️ 역할이 이미 할당됨: 사용자 ${currentUser.id}`); } }) ); - // 4. 로그 기록 + // 5. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ @@ -1540,44 +1605,73 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_REVIEW", - comment: "Vendor approved for review", + comment: "Vendor approved and user account activated", }); }) ); - // 5. 각 벤더에게 이메일 발송 + // 6. 각 벤더에게 승인 완료 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 + // 사용자 언어 확인 + const userInfo = await tx + .select({ language: users.language }) + .from(users) + .where(eq(users.email, vendor.email)) + .limit(1); + + // 새 토큰 생성 (32바이트 랜덤) + const resetToken = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); // 1시간 후 만료 + + await db.insert(mfaTokens).values({ + userId: userInfo[0].id, + token: resetToken, + type: 'password_reset', + expiresAt, + isActive: true, + }); - const subject = - "[eVCP] Admin Account Created"; + const userLang = userInfo.length > 0 ? userInfo[0].language : + (vendor.country === 'KR' ? 'ko' : 'en'); + + const subject = userLang === 'ko' + ? "[eVCP] 업체 승인 완료 - 계정 활성화" + : "[eVCP] Vendor Approved - Account Activated"; const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; - const baseUrl = `http://${host}` - const loginUrl = `${baseUrl}/en/login`; + const protocol = headersList.get('x-forwarded-proto') || 'http'; + const baseUrl = `${protocol}://${host}`; + const loginUrl = `${baseUrl}/${userLang}/login`; + const passwordSetupUrl = `${baseUrl}/${userLang}/auth/reset-password?token=${resetToken}`; // 패스워드 설정 URL await sendEmail({ to: vendor.email, subject, - template: "admin-created", // 이메일 템플릿 이름 + template: "vendor-approved", // 승인 완료 템플릿 context: { vendorName: vendor.vendorName, loginUrl, + passwordSetupUrl, language: userLang, + isNewAccount: false, // 기존 계정 활성화임을 표시 }, }); + + console.log(`📧 승인 완료 이메일 발송: ${vendor.email}`); } catch (emailError) { - console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + console.error(`이메일 발송 실패 - 업체 ${vendor.id}:`, emailError); // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); + console.log(`🎉 협력업체 승인 완료: ${updatedVendors.length}개 업체`); return updated; }); @@ -1590,7 +1684,7 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb return { data: result, error: null }; } catch (err) { - console.error("Error approving vendors:", err); + console.error("협력업체 승인 처리 오류:", err); return { data: null, error: getErrorMessage(err) }; } } @@ -1612,11 +1706,11 @@ export async function generatePQNumber(isProject: boolean = false) { const month = (now.getMonth() + 1).toString().padStart(2, '0'); // 월 (01-12) const day = now.getDate().toString().padStart(2, '0'); // 일 (01-31) const dateStr = `${year}${month}${day}`; - + // 접두사 설정 (일반 PQ vs 프로젝트 PQ) const prefix = isProject ? "PPQ" : "PQ"; const datePrefix = `${prefix}-${dateStr}`; - + // 오늘 생성된 가장 큰 시퀀스 번호 조회 const latestPQ = await db .select({ pqNumber: vendorPQSubmissions.pqNumber }) @@ -1626,9 +1720,9 @@ export async function generatePQNumber(isProject: boolean = false) { ) .orderBy(desc(vendorPQSubmissions.pqNumber)) .limit(1); - + let sequenceNumber = 1; // 기본값은 1 - + // 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산 if (latestPQ.length > 0 && latestPQ[0].pqNumber) { const lastPQ = latestPQ[0].pqNumber; @@ -1637,13 +1731,13 @@ export async function generatePQNumber(isProject: boolean = false) { sequenceNumber = parseInt(lastSequence) + 1; } } - + // 5자리 시퀀스 번호로 포맷팅 (00001, 00002, ...) const formattedSequence = sequenceNumber.toString().padStart(5, '0'); - + // 최종 PQ 번호 생성 const pqNumber = `${datePrefix}-${formattedSequence}`; - + return pqNumber; } catch (error) { console.error('Error generating PQ number:', error); @@ -1654,206 +1748,7 @@ export async function generatePQNumber(isProject: boolean = false) { } } -export async function requestPQVendors(input: ApproveVendorsInput & { - userId: number, - agreements?: Record<string, boolean>, - dueDate?: string | null, - type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", - extraNote?: string, - pqItems?: string -}) { - unstable_noStore(); - const session = await getServerSession(authOptions); - const requesterId = session?.user?.id ? Number(session.user.id) : null; - - try { - let projectInfo = null; - if (input.projectId) { - const project = await db - .select({ - id: projects.id, - projectCode: projects.code, - projectName: projects.name, - }) - .from(projects) - .where(eq(projects.id, input.projectId)) - .limit(1); - - if (project.length > 0) { - projectInfo = project[0]; - } - } - - const result = await db.transaction(async (tx) => { - const vendorsBeforeUpdate = await tx - .select({ id: vendors.id, status: vendors.status }) - .from(vendors) - .where(inArray(vendors.id, input.ids)); - - const [updated] = await tx - .update(vendors) - .set({ status: "IN_PQ", updatedAt: new Date() }) - .where(inArray(vendors.id, input.ids)) - .returning(); - - const updatedVendors = await tx - .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) - .from(vendors) - .where(inArray(vendors.id, input.ids)); - - const pqType = input.type; - const currentDate = new Date(); - - const existingSubmissions = await tx - .select({ vendorId: vendorPQSubmissions.vendorId }) - .from(vendorPQSubmissions) - .where( - and( - inArray(vendorPQSubmissions.vendorId, input.ids), - pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, - input.projectId - ? eq(vendorPQSubmissions.projectId, input.projectId) - : isNull(vendorPQSubmissions.projectId) - ) - ); - - const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); - const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); - - if (newVendorIds.length > 0) { - const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { - const pqNumber = await generatePQNumber(pqType === "PROJECT"); - - return { - vendorId, - pqNumber, - projectId: input.projectId || null, - type: pqType, - status: "REQUESTED", - requesterId: input.userId || requesterId, - dueDate: input.dueDate ? new Date(input.dueDate) : null, - agreements: input.agreements ?? {}, - pqItems: input.pqItems || null, - createdAt: currentDate, - updatedAt: currentDate, - }; - }); - - const vendorPQData = await Promise.all(vendorPQDataPromises); - - await tx.insert(vendorPQSubmissions).values(vendorPQData); - } - - await Promise.all( - vendorsBeforeUpdate.map(async (vendorBefore) => { - await tx.insert(vendorsLogs).values({ - vendorId: vendorBefore.id, - userId: input.userId, - action: "status_change", - oldStatus: vendorBefore.status, - newStatus: "IN_PQ", - comment: input.projectId - ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` - : "General PQ requested", - }); - }) - ); - - const headersList = await headers(); - const host = headersList.get("host") || "localhost:3000"; - - await Promise.all( - updatedVendors.map(async (vendor) => { - if (!vendor.email) return; - - try { - const userLang = "en"; - - const vendorPQ = await tx - .select({ pqNumber: vendorPQSubmissions.pqNumber }) - .from(vendorPQSubmissions) - .where( - and( - eq(vendorPQSubmissions.vendorId, vendor.id), - eq(vendorPQSubmissions.type, pqType), - input.projectId - ? eq(vendorPQSubmissions.projectId, input.projectId) - : isNull(vendorPQSubmissions.projectId) - ) - ) - .limit(1) - .then((rows) => rows[0]); - - const subject = input.projectId - ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` - : input.type === "NON_INSPECTION" - ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` - : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; - - const baseLoginUrl = `${host}/partners/pq`; - const loginUrl = input.projectId - ? `${baseLoginUrl}?projectId=${input.projectId}` - : baseLoginUrl; - - // 체크된 계약 항목 배열 생성 - const contracts = input.agreements - ? Object.entries(input.agreements) - .filter(([_, checked]) => checked) - .map(([name, _]) => name) - : []; - - // PQ 대상 품목 - const pqItems = input.pqItems || " - "; - - await sendEmail({ - to: vendor.email, - subject, - template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", - context: { - vendorName: vendor.vendorName, - vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 - pqNumber: vendorPQ?.pqNumber || "", - senderName: session?.user?.name || "eVCP", - senderEmail: session?.user?.email || "noreply@evcp.com", - dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", - pqItems, - contracts, - extraNote: input.extraNote || "", - currentYear: new Date().getFullYear().toString(), - loginUrl, - language: userLang, - projectCode: projectInfo?.projectCode || "", - projectName: projectInfo?.projectName || "", - hasProject: !!input.projectId, - pqType: input.type || "GENERAL", - }, - }); - } catch (emailError) { - console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); - } - }) - ); - - return updated; - }); - - revalidateTag("vendors"); - revalidateTag("vendor-status-counts"); - revalidateTag("vendor-pq-submissions"); - revalidateTag("pq-submissions"); - - if (input.projectId) { - revalidateTag(`project-${input.projectId}`); - revalidateTag(`project-pq-submissions-${input.projectId}`); - } - - return { data: result, error: null }; - } catch (err) { - console.error("Error requesting PQ from vendors:", err); - return { data: null, error: getErrorMessage(err) }; - } -} interface SendVendorsInput { @@ -2054,7 +1949,7 @@ export async function requestInfo({ ids, userId }: RequestInfoProps) { const headersList = await headers(); const host = headersList.get('host') || 'localhost:3000'; - + // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 for (const vendor of vendorList) { // 로그 기록 @@ -2290,7 +2185,7 @@ export async function updateVendorInfo(params: { // 3-2. 파일 시스템에서 파일 삭제 for (const attachment of attachmentsToDelete) { try { - + await deleteFile(attachment.filePath) } catch (error) { @@ -2400,7 +2295,7 @@ export async function exportVendorContacts(vendorId: number) { .from(vendorContacts) .where(eq(vendorContacts.vendorId, vendorId)) .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); - + return contacts; } catch (error) { console.error("Failed to export vendor contacts:", error); @@ -2427,7 +2322,7 @@ export async function exportVendorItems(vendorId: number) { .from(vendorItemsView) .where(eq(vendorItemsView.vendorId, vendorId)) .orderBy(vendorItemsView.itemName); - + return vendorItems; } catch (error) { console.error("Failed to export vendor items:", error); @@ -2446,7 +2341,7 @@ export async function exportVendorRFQs(vendorId: number) { .from(vendorRfqView) .where(eq(vendorRfqView.vendorId, vendorId)) .orderBy(vendorRfqView.rfqVendorUpdated); - + return rfqs; } catch (error) { console.error("Failed to export vendor RFQs:", error); @@ -2465,7 +2360,7 @@ export async function exportVendorContracts(vendorId: number) { .from(contractsDetailView) .where(eq(contractsDetailView.vendorId, vendorId)) .orderBy(contractsDetailView.createdAt); - + return contracts; } catch (error) { console.error("Failed to export vendor contracts:", error); @@ -2480,7 +2375,7 @@ export async function exportVendorContracts(vendorId: number) { export async function exportVendorDetails(vendorIds: number[]) { try { if (!vendorIds.length) return []; - + // 벤더 기본 정보 조회 const vendorsData = await db .select({ @@ -2507,8 +2402,8 @@ export async function exportVendorDetails(vendorIds: number[]) { }) .from(vendors) .where( - vendorIds.length === 1 - ? eq(vendors.id, vendorIds[0]) + vendorIds.length === 1 + ? eq(vendors.id, vendorIds[0]) : inArray(vendors.id, vendorIds) ); @@ -2517,16 +2412,16 @@ export async function exportVendorDetails(vendorIds: number[]) { vendorsData.map(async (vendor) => { // 연락처 조회 const contacts = await exportVendorContacts(vendor.id); - + // 아이템 조회 const items = await exportVendorItems(vendor.id); - + // RFQ 조회 const rfqs = await exportVendorRFQs(vendor.id); - + // 계약 조회 const contracts = await exportVendorContracts(vendor.id); - + return { ...vendor, vendorContacts: contacts, @@ -2536,7 +2431,7 @@ export async function exportVendorDetails(vendorIds: number[]) { }; }) ); - + return vendorsWithDetails; } catch (error) { console.error("Failed to export vendor details:", error); @@ -2551,7 +2446,7 @@ export async function exportVendorDetails(vendorIds: number[]) { export async function searchVendors(searchTerm: string = "", limit: number = 100) { try { let whereCondition; - + if (searchTerm.trim()) { const s = `%${searchTerm.trim()}%`; whereCondition = or( @@ -2559,7 +2454,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 ilike(vendorsWithTypesView.vendorCode, s) ); } - + const vendors = await db .select({ id: vendorsWithTypesView.id, @@ -2578,7 +2473,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 ) .orderBy(asc(vendorsWithTypesView.vendorName)) .limit(limit); - + return vendors; } catch (error) { console.error("벤더 검색 오류:", error); @@ -2592,7 +2487,7 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 */ export async function getVendorBasicInfo(vendorId: number) { unstable_noStore(); - + try { return await db.transaction(async (tx) => { // 1. 기본 벤더 정보 조회 (vendorsWithTypesView 사용) @@ -2647,7 +2542,7 @@ export async function getVendorBasicInfo(vendorId: number) { cashFlowRating: vendor.cashFlowRating, createdAt: vendor.createdAt, updatedAt: vendor.updatedAt, - + // 연락처 정보 contacts: contacts.map(contact => ({ id: contact.id, @@ -2657,7 +2552,7 @@ export async function getVendorBasicInfo(vendorId: number) { contactPhone: contact.contactPhone, isPrimary: contact.isPrimary, })), - + // 첨부파일 정보 attachments: attachments.map(attachment => ({ id: attachment.id, @@ -2666,26 +2561,26 @@ export async function getVendorBasicInfo(vendorId: number) { attachmentType: attachment.attachmentType, createdAt: attachment.createdAt, })), - + // 추가 정보는 임시로 null (나중에 실제 데이터로 교체) additionalInfo: { businessType: vendor.vendorTypeId ? `Type ${vendor.vendorTypeId}` : null, employeeCount: 0, // 실제 데이터가 있을 수 있으므로 유지 mainBusiness: null, }, - + // 매출 정보 (구현 예정 - 나중에 실제 테이블 연결) salesInfo: null, // 구현 시 { "2023": { totalSales: "1000", totalDebt: "500", ... }, "2022": { ... } } 형태로 연도별 키 사용 - + // 추가 정보들 (구현 예정 - 나중에 실제 테이블 연결) organization: null, - + factoryInfo: null, - + inspectionInfo: null, - + evaluationInfo: null, - + classificationInfo: { vendorClassification: null, groupCompany: null, @@ -2693,11 +2588,11 @@ export async function getVendorBasicInfo(vendorId: number) { industryType: "제조업", // 기본값으로 유지 isoCertification: null, }, - + contractDetails: null, - + capacityInfo: null, - + calculatedMetrics: null, // 구현 시 { "20231231": { debtRatio: 0, ... }, "20221231": { ... } } 형태로 YYYYMMDD 키 사용 }; }); @@ -2705,4 +2600,373 @@ export async function getVendorBasicInfo(vendorId: number) { console.error("Error fetching vendor basic info:", error); return null; } -}
\ No newline at end of file +} +interface RequestBasicContractInfoProps { + vendorIds: number[]; + requestedBy: number; + templateId: number; + pdfBuffer?: Buffer | Uint8Array | ArrayBuffer; // 생성된 PDF 버퍼 (선택적, 다양한 타입 지원) +} + + +export async function requestPQVendors(input: ApproveVendorsInput & { + userId: number, + agreements?: Record<string, boolean>, + dueDate?: string | null, + type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", + extraNote?: string, + pqItems?: string, + templateId?: number | null +}) { + unstable_noStore(); + + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + + try { + let projectInfo = null; + if (input.projectId) { + const project = await db + .select({ + id: projects.id, + projectCode: projects.code, + projectName: projects.name, + }) + .from(projects) + .where(eq(projects.id, input.projectId)) + .limit(1); + + if (project.length > 0) { + projectInfo = project[0]; + } + } + + const result = await db.transaction(async (tx) => { + const vendorsBeforeUpdate = await tx + .select({ id: vendors.id, status: vendors.status }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + const [updated] = await tx + .update(vendors) + .set({ status: "IN_PQ", updatedAt: new Date() }) + .where(inArray(vendors.id, input.ids)) + .returning(); + + const updatedVendors = await tx + .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + const pqType = input.type; + const currentDate = new Date(); + + const existingSubmissions = await tx + .select({ vendorId: vendorPQSubmissions.vendorId }) + .from(vendorPQSubmissions) + .where( + and( + inArray(vendorPQSubmissions.vendorId, input.ids), + pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ); + + const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); + const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); + + if (newVendorIds.length > 0) { + const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { + const pqNumber = await generatePQNumber(pqType === "PROJECT"); + + return { + vendorId, + pqNumber, + projectId: input.projectId || null, + type: pqType, + status: "REQUESTED", + requesterId: input.userId || requesterId, + dueDate: input.dueDate ? new Date(input.dueDate) : null, + agreements: input.agreements ?? {}, + pqItems: input.pqItems || null, + createdAt: currentDate, + updatedAt: currentDate, + }; + }); + + const vendorPQData = await Promise.all(vendorPQDataPromises); + + await tx.insert(vendorPQSubmissions).values(vendorPQData); + } + + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_PQ", + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + : "General PQ requested", + }); + }) + ); + + const headersList = await headers(); + const host = headersList.get("host") || "localhost:3000"; + + await Promise.all( + updatedVendors.map(async (vendor) => { + if (!vendor.email) return; + + try { + const userLang = "en"; + + const vendorPQ = await tx + .select({ pqNumber: vendorPQSubmissions.pqNumber }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.vendorId, vendor.id), + eq(vendorPQSubmissions.type, pqType), + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ) + .limit(1) + .then((rows) => rows[0]); + + const subject = input.projectId + ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` + : input.type === "NON_INSPECTION" + ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` + : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; + + const baseLoginUrl = `${host}/partners/pq`; + const loginUrl = input.projectId + ? `${baseLoginUrl}?projectId=${input.projectId}` + : baseLoginUrl; + + // 체크된 계약 항목 배열 생성 + const contracts = input.agreements + ? Object.entries(input.agreements) + .filter(([_, checked]) => checked) + .map(([name, _]) => name) + : []; + + // PQ 대상 품목 + const pqItems = input.pqItems || " - "; + + await sendEmail({ + to: vendor.email, + subject, + template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", + context: { + vendorName: vendor.vendorName, + vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 + pqNumber: vendorPQ?.pqNumber || "", + senderName: session?.user?.name || "eVCP", + senderEmail: session?.user?.email || "noreply@evcp.com", + dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", + pqItems, + contracts, + extraNote: input.extraNote || "", + currentYear: new Date().getFullYear().toString(), + loginUrl, + language: userLang, + projectCode: projectInfo?.projectCode || "", + projectName: projectInfo?.projectName || "", + hasProject: !!input.projectId, + pqType: input.type || "GENERAL", + }, + }); + } catch (emailError) { + console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); + } + }) + ); + + return updated; + }); + + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("vendor-pq-submissions"); + revalidateTag("pq-submissions"); + + if (input.projectId) { + revalidateTag(`project-${input.projectId}`); + revalidateTag(`project-pq-submissions-${input.projectId}`); + } + + return { data: result, error: null }; + } catch (err) { + console.error("Error requesting PQ from vendors:", err); + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function requestBasicContractInfo({ + vendorIds, + requestedBy, + templateId, + pdfBuffer +}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> { + unstable_noStore(); + + if (!vendorIds || vendorIds.length === 0) { + return { error: "요청할 협력업체가 선택되지 않았습니다." }; + } + + if (!templateId) { + return { error: "계약서 템플릿이 선택되지 않았습니다." }; + } + + try { + // 1. 선택된 템플릿 정보 가져오기 + const template = await db.query.basicContractTemplates.findFirst({ + where: eq(basicContractTemplates.id, templateId) + }); + + if (!template) { + return { error: "선택한 템플릿을 찾을 수 없습니다." }; + } + + // 2. PDF 버퍼가 제공된 경우 파일로 저장, 아니면 원본 템플릿 파일 사용 + let finalFileName = template.fileName || `${template.templateName}.docx`; + let finalFilePath = template.filePath || `/basicContract/${finalFileName}`; + + if (pdfBuffer) { + try { + const fileId = uuidv4(); + const fileName = `${fileId}.pdf`; + const relativePath = `/basicContract/${fileName}`; + const publicDir = path.join(process.cwd(), "public", "basicContract"); + const absolutePath = path.join(publicDir, fileName); + + // 디렉토리 생성 + await fs.mkdir(publicDir, { recursive: true }); + + // PDF 파일 저장 (다양한 타입을 Buffer로 변환) + let bufferData: Buffer; + if (Buffer.isBuffer(pdfBuffer)) { + bufferData = pdfBuffer; + } else if (pdfBuffer instanceof ArrayBuffer) { + bufferData = Buffer.from(pdfBuffer); + } else if (pdfBuffer instanceof Uint8Array) { + bufferData = Buffer.from(pdfBuffer); + } else { + bufferData = Buffer.from(pdfBuffer as any); + } + await fs.writeFile(absolutePath, bufferData); + + finalFileName = fileName; + finalFilePath = relativePath; + + console.log(`✅ PDF 파일 저장 완료: ${absolutePath}`); + } catch (pdfSaveError) { + console.error('PDF 파일 저장 오류:', pdfSaveError); + return { error: `PDF 파일 저장 실패: ${pdfSaveError instanceof Error ? pdfSaveError.message : '알 수 없는 오류'}` }; + } + } else if (!template.fileName || !template.filePath) { + return { error: "템플릿 파일 정보가 없고 PDF 버퍼도 제공되지 않았습니다." }; + } + + // 3. 협력업체 정보 가져오기 + const vendorList = await db + .select() + .from(vendors) + .where(inArray(vendors.id, vendorIds)); + + if (!vendorList || vendorList.length === 0) { + return { error: "선택한 협력업체 정보를 찾을 수 없습니다." }; + } + + // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송 + const results = await Promise.all( + vendorList.map(async (vendor) => { + if (!vendor.email) return; // 이메일이 없으면 스킵 + + try { + // 3-1. basic_contract 테이블에 레코드 추가 + const [newContract] = await db + .insert(basicContract) + .values({ + templateId: template.id, + vendorId: vendor.id, + requestedBy: requestedBy, + status: "PENDING", + fileName: finalFileName, // PDF 변환된 파일 이름 사용 + filePath: finalFilePath, // PDF 변환된 파일 경로 사용 + }) + .returning(); + + // 3-2. 협력업체에 이메일 발송 + const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`; + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // 로그인 또는 서명 페이지 URL 생성 + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/partners/basic-contract`; + + // 사용자 언어 설정 (기본값은 한국어) + const userLang = "ko"; + + // 이메일 발송 + await sendEmail({ + to: vendor.email, + subject, + template: "contract-sign-request", // 이메일 템플릿 이름 + context: { + vendorName: vendor.vendorName, + contractId: newContract.id, + templateName: template.templateName, + loginUrl, + language: userLang, + }, + }); + + return { vendorId: vendor.id, success: true }; + } catch (err) { + console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err); + return { vendorId: vendor.id, success: false, error: getErrorMessage(err) }; + } + }) + ); + + // 4. 실패한 케이스가 있는지 확인 + const failedVendors = results.filter(r => r && !r.success); + + if (failedVendors.length > 0) { + console.error("일부 협력업체 처리 실패:", failedVendors); + if (failedVendors.length === vendorIds.length) { + // 모든 협력업체 처리 실패 + return { error: "모든 협력업체에 대한 처리가 실패했습니다." }; + } else { + // 일부 협력업체만 처리 실패 + return { + success: true, + error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패` + }; + } + } + + // 5. 캐시 무효화 + revalidateTag("basic-contract-requests"); + + return { success: true }; + } catch (error) { + console.error("기본계약서 요청 중 오류 발생:", error); + return { + error: error instanceof Error + ? error.message + : "기본계약서 요청 처리 중 오류가 발생했습니다." + }; + } +} diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index 6d477d9f..1df2d72c 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -36,11 +36,13 @@ import { import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Vendor } from "@/db/schema/vendors"
-import { requestPQVendors } from "../service"
+import { requestBasicContractInfo, requestPQVendors } from "../service"
import { getProjectsWithPQList } from "@/lib/pq/service"
import type { Project } from "@/lib/pq/service"
import { useSession } from "next-auth/react"
import { DatePicker } from "@/components/ui/date-picker"
+import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
+import type { BasicContractTemplate } from "@/db/schema"
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
vendors: Row<Vendor>["original"][]
@@ -72,6 +74,9 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro const [extraNote, setExtraNote] = React.useState<string>("")
const [pqItems, setPqItems] = React.useState<string>("")
const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+ const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
+ const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
+ const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
React.useEffect(() => {
if (type === "PROJECT") {
@@ -81,6 +86,15 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro }
}, [type])
+ // 기본계약서 템플릿 로딩
+ React.useEffect(() => {
+ setIsLoadingTemplates(true)
+ getALLBasicContractTemplates()
+ .then(setBasicContractTemplates)
+ .catch(() => toast.error("기본계약서 템플릿 로딩 실패"))
+ .finally(() => setIsLoadingTemplates(false))
+ }, [])
+
React.useEffect(() => {
if (!props.open) {
setType(null)
@@ -89,6 +103,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro setDueDate(null)
setPqItems("")
setExtraNote("")
+ setSelectedTemplateIds([])
}
}, [props.open])
@@ -99,7 +114,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro if (!session?.user?.id) return toast.error("인증 실패")
startApproveTransition(async () => {
- const { error } = await requestPQVendors({
+ try {
+ // 1단계: PQ 생성
+ console.log("🚀 1단계: PQ 생성 시작")
+ const { error: pqError } = await requestPQVendors({
ids: vendors.map((v) => v.id),
userId: Number(session.user.id),
agreements,
@@ -108,16 +126,132 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro type: type || "GENERAL",
extraNote,
pqItems,
+ templateId: selectedTemplateIds.length > 0 ? selectedTemplateIds[0] : null,
})
-
- if (error) {
- toast.error(error)
- return
+
+ if (pqError) {
+ toast.error(`PQ 생성 실패: ${pqError}`)
+ return
+ }
+ console.log("✅ 1단계: PQ 생성 완료")
+
+ // 2단계 & 3단계: 기본계약서 템플릿이 선택된 경우에만 실행 (여러 템플릿 처리)
+ if (selectedTemplateIds.length > 0) {
+ console.log(`🚀 2단계 & 3단계: ${selectedTemplateIds.length}개 템플릿 처리 시작`)
+
+ let successCount = 0
+ let errorCount = 0
+ const errors: string[] = []
+
+ // 템플릿별로 반복 처리
+ for (let i = 0; i < selectedTemplateIds.length; i++) {
+ const templateId = selectedTemplateIds[i]
+ const selectedTemplate = basicContractTemplates.find(t => t.id === templateId)
+
+ if (!selectedTemplate) {
+ console.error(`템플릿 ID ${templateId}를 찾을 수 없습니다`)
+ errorCount++
+ errors.push(`템플릿 ID ${templateId}를 찾을 수 없습니다`)
+ continue
+ }
+
+ try {
+ console.log(`📄 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 2단계: DOCX to PDF 변환 시작`)
+
+ // 템플릿 파일을 가져와서 PDF로 변환
+ const formData = new FormData()
+
+ // 템플릿 파일 가져오기 (서버에서 파일 읽기)
+ const templateResponse = await fetch('/api/basic-contract/get-template', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ templateId })
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error(`템플릿 파일을 가져올 수 없습니다: ${selectedTemplate.templateName}`)
+ }
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 템플릿 파일 가져오기 완료`)
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], selectedTemplate.fileName || 'template.docx')
+
+ // 템플릿 데이터 생성 (첫 번째 협력업체 정보 기반)
+ const firstVendor = vendors[0]
+ const templateData = {
+ // 영문 변수명으로 변경 (PDFTron이 한글 변수명을 지원하지 않음)
+ vendor_name: firstVendor?.vendorName || '협력업체명',
+ address: firstVendor?.address || '주소',
+ representative_name: firstVendor?.representativeName || '대표자명',
+ today_date: new Date().toLocaleDateString('ko-KR'),
+ }
+
+ console.log(`📝 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 생성된 템플릿 데이터:`, templateData)
+
+ formData.append('templateFile', templateFile)
+ formData.append('outputFileName', `${selectedTemplate.templateName}_converted.pdf`)
+ formData.append('templateData', JSON.stringify(templateData))
+
+ // PDF 변환 호출
+ const pdfResponse = await fetch('/api/pdftron/createBasicContractPdf', {
+ method: 'POST',
+ body: formData,
+ })
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - PDF 변환 호출 완료`)
+
+ if (!pdfResponse.ok) {
+ const errorText = await pdfResponse.text()
+ throw new Error(`PDF 변환 실패 (${selectedTemplate.templateName}): ${errorText}`)
+ }
+
+ const pdfBuffer = await pdfResponse.arrayBuffer()
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - PDF 변환 완료`)
+
+ // 3단계: 변환된 PDF로 기본계약 생성
+ console.log(`📋 [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 3단계: 기본계약 생성 시작`)
+ const { error: contractError } = await requestBasicContractInfo({
+ vendorIds: vendors.map((v) => v.id),
+ requestedBy: Number(session.user.id),
+ templateId,
+ pdfBuffer: new Uint8Array(pdfBuffer), // ArrayBuffer를 Uint8Array로 변환하여 전달
+ })
+
+ if (contractError) {
+ console.error(`기본계약 생성 오류 (${selectedTemplate.templateName}):`, contractError)
+ errorCount++
+ errors.push(`${selectedTemplate.templateName}: ${contractError}`)
+ } else {
+ console.log(`✅ [${i+1}/${selectedTemplateIds.length}] ${selectedTemplate.templateName} - 3단계: 기본계약 생성 완료`)
+ successCount++
+ }
+ } catch (templateError) {
+ console.error(`템플릿 처리 오류 (${selectedTemplate.templateName}):`, templateError)
+ errorCount++
+ errors.push(`${selectedTemplate.templateName}: ${templateError instanceof Error ? templateError.message : '알 수 없는 오류'}`)
+ }
+ }
+
+ // 결과 토스트 메시지
+ if (successCount > 0 && errorCount === 0) {
+ toast.success(`PQ 요청 및 ${successCount}개 기본계약서 생성이 모두 완료되었습니다!`)
+ } else if (successCount > 0 && errorCount > 0) {
+ toast.success(`PQ는 성공적으로 요청되었습니다. ${successCount}개 기본계약서 성공, ${errorCount}개 실패`)
+ console.error('기본계약서 생성 오류들:', errors)
+ } else if (errorCount > 0) {
+ toast.error(`PQ는 성공적으로 요청되었지만, 모든 기본계약서 생성이 실패했습니다`)
+ console.error('기본계약서 생성 오류들:', errors)
+ }
+ } else {
+ // 기본계약서 템플릿이 선택되지 않은 경우
+ toast.success("PQ가 성공적으로 요청되었습니다")
+ }
+
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error('전체 프로세스 오류:', error)
+ toast.error(`처리 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
}
-
- props.onOpenChange?.(false)
- toast.success("PQ가 성공적으로 요청되었습니다")
- onSuccess?.()
})
}
@@ -204,7 +338,44 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro />
</div>
+ {/* 기본계약서 템플릿 선택 (다중 선택) */}
<div className="space-y-2">
+ <Label>기본계약서 템플릿 (선택사항, 복수 선택 가능)</Label>
+ {isLoadingTemplates ? (
+ <div className="text-sm text-muted-foreground">템플릿 로딩 중...</div>
+ ) : (
+ <div className="space-y-2 max-h-40 overflow-y-auto border rounded-md p-3">
+ {basicContractTemplates.map((template) => (
+ <div key={template.id} className="flex items-center gap-2">
+ <Checkbox
+ id={`template-${template.id}`}
+ checked={selectedTemplateIds.includes(template.id)}
+ onCheckedChange={(checked) => {
+ if (checked) {
+ setSelectedTemplateIds(prev => [...prev, template.id])
+ } else {
+ setSelectedTemplateIds(prev => prev.filter(id => id !== template.id))
+ }
+ }}
+ />
+ <Label htmlFor={`template-${template.id}`} className="text-sm">
+ {template.templateName}
+ </Label>
+ </div>
+ ))}
+ {basicContractTemplates.length === 0 && (
+ <div className="text-sm text-muted-foreground">사용 가능한 템플릿이 없습니다.</div>
+ )}
+ </div>
+ )}
+ {selectedTemplateIds.length > 0 && (
+ <div className="text-xs text-muted-foreground">
+ {selectedTemplateIds.length}개 템플릿이 선택되었습니다.
+ </div>
+ )}
+ </div>
+
+ {/* <div className="space-y-2">
<Label>계약 항목 선택</Label>
{AGREEMENT_LIST.map((label) => (
<div key={label} className="flex items-center gap-2">
@@ -218,7 +389,7 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro <Label htmlFor={label}>{label}</Label>
</div>
))}
- </div>
+ </div> */}
</div>
)
diff --git a/pages/api/pdftron/createBasicContractPdf.ts b/pages/api/pdftron/createBasicContractPdf.ts new file mode 100644 index 00000000..1122c022 --- /dev/null +++ b/pages/api/pdftron/createBasicContractPdf.ts @@ -0,0 +1,359 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import type { File as FormidableFile } from "formidable"; +import formidable from "formidable"; +import fs from "fs/promises"; +import path from "path"; +import { createBasicContractPdf } from "@/lib/pdftron/serverSDK/createBasicContractPdf"; + +export const config = { + api: { + bodyParser: false, + }, +}; + +// 보안 설정 +const SECURITY_CONFIG = { + ALLOWED_EXTENSIONS: new Set(['docx', 'doc']), + FORBIDDEN_EXTENSIONS: new Set([ + 'exe', 'bat', 'cmd', 'scr', 'vbs', 'js', 'jar', 'com', 'pif', + 'msi', 'reg', 'ps1', 'sh', 'php', 'asp', 'jsp', 'py', 'pl', + 'html', 'htm', 'xhtml', 'xml', 'svg' + ]), + MAX_FILE_SIZE: 50 * 1024 * 1024, // 50MB + MAX_FILENAME_LENGTH: 255, +}; + +// 간단한 보안 검증 함수들 +function validateExtension(fileName: string): { valid: boolean; error?: string } { + const extension = path.extname(fileName).toLowerCase().substring(1); + + if (!extension) { + return { valid: false, error: "파일 확장자가 없습니다" }; + } + + if (SECURITY_CONFIG.FORBIDDEN_EXTENSIONS.has(extension)) { + return { valid: false, error: `금지된 파일 형식입니다: .${extension}` }; + } + + if (!SECURITY_CONFIG.ALLOWED_EXTENSIONS.has(extension)) { + return { valid: false, error: `허용되지 않은 파일 형식입니다: .${extension}` }; + } + + return { valid: true }; +} + +function validateFileName(fileName: string): { valid: boolean; error?: string } { + if (fileName.length > SECURITY_CONFIG.MAX_FILENAME_LENGTH) { + return { valid: false, error: "파일명이 너무 깁니다" }; + } + + // 위험한 문자 체크 + const dangerousPatterns = [ + /[<>:"'|?*]/, + /[\x00-\x1f]/, + /^\./, + /\.\./, + /\/|\\$/, + /javascript:/i, + /data:/i, + /vbscript:/i, + /on\w+=/i, + /<script/i, + /<iframe/i, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(fileName)) { + return { valid: false, error: "안전하지 않은 파일명입니다" }; + } + } + + return { valid: true }; +} + +function validateFileSize(size: number): { valid: boolean; error?: string } { + if (size <= 0) { + return { valid: false, error: "파일이 비어있습니다" }; + } + + if (size > SECURITY_CONFIG.MAX_FILE_SIZE) { + const maxSizeMB = Math.round(SECURITY_CONFIG.MAX_FILE_SIZE / (1024 * 1024)); + return { valid: false, error: `파일 크기가 너무 큽니다 (최대 ${maxSizeMB}MB)` }; + } + + return { valid: true }; +} + +function validateTemplateData(templateData: any): { valid: boolean; error?: string } { + try { + if (typeof templateData !== 'object' || templateData === null) { + return { valid: false, error: "템플릿 데이터는 객체여야 합니다" }; + } + + // 객체 크기 제한 + const dataString = JSON.stringify(templateData); + if (dataString.length > 100000) { // 100KB + return { valid: false, error: "템플릿 데이터가 너무 큽니다" }; + } + + // XSS 방지 검증 + const dangerousPatterns = [ + /<script[\s\S]*?>/i, + /<iframe[\s\S]*?>/i, + /javascript\s*:/i, + /vbscript\s*:/i, + /on\w+\s*=/i, + ]; + + for (const pattern of dangerousPatterns) { + if (pattern.test(dataString)) { + return { valid: false, error: "템플릿 데이터에 잠재적으로 위험한 스크립트가 포함되어 있습니다" }; + } + } + + return { valid: true }; + } catch (error) { + return { valid: false, error: "템플릿 데이터 검증 중 오류가 발생했습니다" }; + } +} + +async function validateFileContent(buffer: Buffer, fileName: string): Promise<{ valid: boolean; error?: string }> { + try { + // 실행 파일 패턴 검색 + const executablePatterns = [ + Buffer.from([0x4D, 0x5A]), // MZ (Windows executable) + Buffer.from([0x7F, 0x45, 0x4C, 0x46]), // ELF (Linux executable) + ]; + + for (const pattern of executablePatterns) { + if (buffer.subarray(0, pattern.length).equals(pattern)) { + return { valid: false, error: "실행 파일은 업로드할 수 없습니다" }; + } + } + + return { valid: true }; + } catch (error) { + console.error("파일 내용 검증 오류:", error); + return { valid: false, error: "파일 내용을 검증할 수 없습니다" }; + } +} + +// DRM 복호화 함수 (create report 로직과 동일) +async function decryptBufferWithDRM(buffer: Buffer, originalFileName: string): Promise<Buffer> { + try { + // 1. 기본 보안 검증 + const extensionCheck = validateExtension(originalFileName); + if (!extensionCheck.valid) { + console.error(`🚨 파일 확장자 보안 위반: ${originalFileName} - ${extensionCheck.error}`); + throw new Error(extensionCheck.error); + } + + const fileNameCheck = validateFileName(originalFileName); + if (!fileNameCheck.valid) { + console.error(`🚨 파일명 보안 위반: ${originalFileName} - ${fileNameCheck.error}`); + throw new Error(fileNameCheck.error); + } + + const sizeCheck = validateFileSize(buffer.length); + if (!sizeCheck.valid) { + console.error(`🚨 파일 크기 보안 위반: ${originalFileName} - ${sizeCheck.error}`); + throw new Error(sizeCheck.error); + } + + // 2. 파일 내용 기본 검증 + const contentCheck = await validateFileContent(buffer, originalFileName); + if (!contentCheck.valid) { + console.error(`🚨 파일 내용 보안 위반: ${originalFileName} - ${contentCheck.error}`); + throw new Error(contentCheck.error); + } + + console.log(`✅ 보안 검증 완료: ${originalFileName}`); + + // 3. DRM 복호화 진행 + const blob = new Blob([buffer]); + const file = new File([blob], originalFileName); + + const formData = new FormData(); + formData.append('file', file); + + const backendUrl = "http://localhost:6543/api/drm-proxy/decrypt"; + + console.log(`[DRM] 서버에서 파일 복호화 시도: ${originalFileName} (크기: ${buffer.length} bytes)`); + + const response = await fetch(backendUrl, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => '응답 텍스트를 가져올 수 없음'); + throw new Error(`DRM 서버 응답 오류 [${response.status}]: ${errorText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + const decryptedBuffer = Buffer.from(arrayBuffer); + + console.log(`[DRM] 서버에서 파일 복호화 성공: ${originalFileName} (결과 크기: ${decryptedBuffer.length} bytes)`); + + return decryptedBuffer; + + } catch (error) { + const errorMessage = error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); + + console.error(`[DRM] 서버 복호화 오류: ${errorMessage}`, { + fileName: originalFileName, + fileSize: buffer.length, + error + }); + + // 보안 검증 실패인 경우 원본도 반환하지 않음 + if (errorMessage.includes('보안') || errorMessage.includes('위반') || errorMessage.includes('금지된') || errorMessage.includes('허용되지')) { + throw error; // 보안 오류는 재throw + } + + return buffer; // 일반적인 DRM 오류는 원본 버퍼 반환 (폴백) + } +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== "POST") { + return res.status(405).json({ error: "Method not allowed" }); + } + + // 요청 ID 생성 (로깅용) + const requestId = `basiccontract_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + console.log(`🚀 [${requestId}] 기본계약서 PDF 변환 요청 시작`); + + try { + const form = formidable({ + multiples: false, + maxFileSize: SECURITY_CONFIG.MAX_FILE_SIZE, + maxFieldsSize: 10 * 1024 * 1024, // 10MB 필드 크기 제한 + maxFields: 20, // 최대 필드 수 제한 + }); + + form.parse(req, async (err, fields, files) => { + if (err) { + console.error(`❌ [${requestId}] Form parsing 오류:`, err); + return res.status(500).json({ + error: "파일 업로드 처리 중 오류가 발생했습니다", + requestId + }); + } + + try { + const outputFileName = fields?.outputFileName?.[0] ?? ""; + const templateFile: FormidableFile | undefined = files?.templateFile?.[0]; + + // 1. 기본 필드 검증 + if (!templateFile || outputFileName.length === 0) { + return res.status(400).json({ + error: "필수 데이터가 누락되었습니다 (템플릿 파일, 출력 파일명)", + requestId + }); + } + + // 2. 파일명 보안 검증 + const fileNameCheck = validateFileName(outputFileName); + if (!fileNameCheck.valid) { + console.error(`🚨 [${requestId}] 출력 파일명 보안 위반:`, fileNameCheck.error); + return res.status(400).json({ + error: `출력 파일명 오류: ${fileNameCheck.error}`, + requestId + }); + } + + // 3. 템플릿 데이터 파싱 및 검증 + let templateData: any = {}; + try { + const templateDataString = fields?.templateData?.[0] ?? "{}"; + templateData = JSON.parse(templateDataString); + } catch (parseError) { + console.error(`❌ [${requestId}] 템플릿 데이터 파싱 오류:`, parseError); + return res.status(400).json({ + error: "템플릿 데이터 형식이 올바르지 않습니다", + requestId + }); + } + + const dataValidation = validateTemplateData(templateData); + if (!dataValidation.valid) { + console.error(`🚨 [${requestId}] 템플릿 데이터 보안 위반:`, dataValidation.error); + return res.status(400).json({ + error: `템플릿 데이터 오류: ${dataValidation.error}`, + requestId + }); + } + + console.log(`✅ [${requestId}] 모든 보안 검증 완료 - 파일 처리 시작`); + + // 4. 원본 파일 읽기 + const originalBuffer = await fs.readFile(templateFile.filepath); + + // 5. DRM 복호화 처리 (보안 검증 포함) + console.log(`🔐 [${requestId}] DRM 복호화 시작: ${templateFile.originalFilename || 'unknown'}`); + const decryptedBuffer = await decryptBufferWithDRM( + originalBuffer, + templateFile.originalFilename || 'template.docx' + ); + + // 6. 복호화된 버퍼로 기본계약서 PDF 생성 + console.log(`📄 [${requestId}] 기본계약서 PDF 생성 시작`); + const { + result, + buffer: pdfBuffer, + error, + } = await createBasicContractPdf(decryptedBuffer, templateData); + + if (result && pdfBuffer) { + console.log(`✅ [${requestId}] 기본계약서 PDF 생성 성공: ${outputFileName}`); + + // 보안 헤더 설정 + res.setHeader("Content-Type", "application/pdf"); + + // 한글 파일명을 위한 UTF-8 인코딩 + const encodedFileName = encodeURIComponent(outputFileName); + res.setHeader("Content-Disposition", `attachment; filename*=UTF-8''${encodedFileName}`); + + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + + return res.send(Buffer.from(pdfBuffer)); + } + + console.error(`❌ [${requestId}] 기본계약서 PDF 생성 실패:`, error); + return res.status(500).json({ + success: false, + message: "기본계약서 PDF 생성에 실패하였습니다.", + error, + requestId, + }); + + } catch (e) { + console.error(`❌ [${requestId}] 처리 중 오류:`, e); + + // 보안 오류와 일반 오류 구분 + const errorMessage = e instanceof Error ? e.message : "처리 중 오류가 발생했습니다"; + const isSecurityError = errorMessage.includes('보안') || errorMessage.includes('위반') || + errorMessage.includes('금지된') || errorMessage.includes('허용되지'); + + return res.status(isSecurityError ? 400 : 500).json({ + error: isSecurityError ? errorMessage : "처리 중 오류가 발생했습니다", + requestId + }); + } + }); + } catch (err) { + console.error(`❌ [${requestId}] 전역 오류:`, err); + return res.status(500).json({ + error: "서버 내부 오류가 발생했습니다", + requestId + }); + } +} |
