summaryrefslogtreecommitdiff
path: root/lib/basic-contract/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/service.ts')
-rw-r--r--lib/basic-contract/service.ts7368
1 files changed, 3684 insertions, 3684 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts
index d7b3edc8..eb3d49f5 100644
--- a/lib/basic-contract/service.ts
+++ b/lib/basic-contract/service.ts
@@ -1,3685 +1,3685 @@
-"use server";
-
-import { revalidateTag, unstable_noStore } from "next/cache";
-import db from "@/db/db";
-import { getErrorMessage } from "@/lib/handle-error";
-import { unstable_cache } from "@/lib/unstable-cache";
-import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count,like } from "drizzle-orm";
-import { v4 as uuidv4 } from "uuid";
-import {
- basicContract,
- BasicContractTemplate,
- basicContractTemplates,
- basicContractView,
- complianceQuestionOptions,
- complianceQuestions,
- complianceResponseAnswers,
- complianceResponseFiles,
- complianceResponses,
- complianceSurveyTemplates,
- vendorAttachments, basicContractTemplateStatsView,
- type BasicContractTemplate as DBBasicContractTemplate,
- type NewComplianceResponse,
- type NewComplianceResponseAnswer,
- type NewComplianceResponseFile,
- gtcVendorDocuments,
- gtcVendorClauses,
- gtcClauses,
- gtcDocuments,
- vendors,
- vendorContacts,
- gtcNegotiationHistory,
- type GtcVendorClause,
- type GtcClause,
- projects,
- legalWorks,
- BasicContractView, users
-} from "@/db/schema";
-import path from "path";
-
-import {
- GetBasicContractTemplatesSchema,
- CreateBasicContractTemplateSchema,
- GetBasciContractsSchema,
- GetBasciContractsVendorSchema,
- GetBasciContractsByIdSchema,
- updateStatusSchema,
-} from "./validations";
-import { readFile } from "fs/promises"
-
-import {
- insertBasicContractTemplate,
- selectBasicContractTemplates,
- countBasicContractTemplates,
- deleteBasicContractTemplates,
- getBasicContractTemplateById,
- selectBasicContracts,
- countBasicContracts,
- findAllTemplates,
- countBasicContractsById,
- selectBasicContractsById,
- selectBasicContractsVendor,
- countBasicContractsVendor
-} from "./repository";
-import { revalidatePath } from 'next/cache';
-import { sendEmail } from "../mail/sendEmail";
-import { headers } from 'next/headers';
-import { filterColumns } from "@/lib/filter-columns";
-import { differenceInDays, addYears, isBefore } from "date-fns";
-import { deleteFile, saveBuffer, saveFile, saveDRMFile } from "@/lib/file-stroage";
-import { decryptWithServerAction } from "@/components/drm/drmUtils";
-import { getServerSession } from "next-auth/next"
-import { authOptions } from "@/app/api/auth/[...nextauth]/route"
-
-
-// 템플릿 추가
-export async function addTemplate(
- templateData: FormData | Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
-): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> {
- try {
- // FormData인 경우 파일 추출 및 저장 처리
- if (templateData instanceof FormData) {
- const templateName = templateData.get("templateName") as string;
- // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환)
- const status = templateData.get("status") as "ACTIVE" | "INACTIVE";
- const file = templateData.get("file") as File;
-
- // 유효성 검사
- if (!templateName) {
- return { success: false, error: "템플릿 이름은 필수입니다." };
- }
-
- if (!file) {
- return { success: false, error: "파일은 필수입니다." };
- }
-
- const saveResult = await saveFile({file, directory:"basicContract/template" });
-
- if (!saveResult.success) {
- return { success: false, error: saveResult.error };
- }
-
- // DB에 저장할 데이터 구성
- const formattedData = {
- templateName,
- status,
- fileName: file.name,
- filePath: saveResult.publicPath!
- };
-
- // DB에 저장
- const { data, error } = await createBasicContractTemplate(formattedData);
-
- if (error) {
- // DB 저장 실패 시 파일 삭제
- await deleteFile(saveResult.publicPath!);
- return { success: false, error };
- }
-
- return { success: true, data: data || undefined };
-
- }
- // 기존 객체 형태인 경우 (호환성 유지)
- else {
- const formattedData = {
- ...templateData,
- status: templateData.status as "ACTIVE" | "INACTIVE",
- // validityPeriod가 없으면 기본값 12개월 사용
- validityPeriod: templateData.validityPeriod || 12,
- };
-
- const { data, error } = await createBasicContractTemplate(formattedData);
-
- if (error) {
- return { success: false, error };
- }
-
- return { success: true, data: data || undefined };
-
- }
- } catch (error) {
- console.error("Template add error:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."
- };
- }
-}
-// 기본 계약서 템플릿 목록 조회 (서버 액션)
-export async function getBasicContractTemplates(
- input: GetBasicContractTemplatesSchema
-) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- const { data, total } = await db.transaction(async (tx) => {
- const advancedWhere = filterColumns({
- table: basicContractTemplates,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- let globalWhere = undefined;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(basicContractTemplates.templateName, s),
- ilike(basicContractTemplates.fileName, s),
- ilike(basicContractTemplates.status, s)
- );
- }
-
- const whereCondition = and(advancedWhere, globalWhere);
-
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc
- ? desc(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- basicContractTemplates[
- item.id as keyof typeof basicContractTemplates
- ] as any
- )
- : asc(
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- basicContractTemplates[
- item.id as keyof typeof basicContractTemplates
- ] as any
- )
- )
- : [desc(basicContractTemplates.createdAt)];
-
- const dataResult = await selectBasicContractTemplates(tx, {
- where: whereCondition,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
-
- const totalCount = await countBasicContractTemplates(
- tx,
- whereCondition
- );
- return { data: dataResult, total: totalCount };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (error) {
- console.error("getBasicContractTemplates 에러:", error);
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)],
- {
- revalidate: 3600,
- tags: ["basic-contract-templates"],
- }
- )();
-}
-
-
-// 템플릿 생성 (서버 액션)
-export async function createBasicContractTemplate(input: CreateBasicContractTemplateSchema) {
- unstable_noStore();
-
- try {
- const newTemplate = await db.transaction(async (tx) => {
- const [row] = await insertBasicContractTemplate(tx, {
- templateName: input.templateName,
- revision: input.revision || 1,
- status: input.status || "ACTIVE",
-
- // 📝 null 처리 추가
- fileName: input.fileName || null,
- filePath: input.filePath || null,
- });
- return row;
- });
-
- return { data: newTemplate, error: null };
- } catch (error) {
- console.log(error);
- return { data: null, error: getErrorMessage(error) };
- }
-}
-
-//서명 계약서 저장, 김기만 프로님 추가 코드
-export const saveSignedContract = async (
- fileBuffer: ArrayBuffer,
- templateName: string,
- tableRowId: number
-): Promise<{ result: true } | { result: false; error: string }> => {
- try {
- const originalName = `${tableRowId}_${templateName}`;
-
- // ArrayBuffer를 File 객체로 변환
- const file = new File([fileBuffer], originalName);
-
- // ✅ 서명된 계약서 저장
- // 개발: /project/public/basicContract/signed/
- // 프로덕션: /nas_evcp/basicContract/signed/
- const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName});
-
- if (!saveResult.success) {
- return { result: false, error: saveResult.error! };
- }
-
- console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`);
-
- await db.transaction(async (tx) => {
- await tx
- .update(basicContract)
- .set({
- status: "COMPLETED",
- fileName: originalName,
- filePath: saveResult.publicPath, // 웹 접근 경로 저장
- })
- .where(eq(basicContract.id, tableRowId));
- });
- // 캐시 무효화
- revalidateTag("basic-contract-requests");
- revalidateTag("template-status-counts");
-
- return { result: true };
- } catch (err: unknown) {
- const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
- return { result: false, error: errorMessage };
- }
-};
-
-interface RemoveTemplatesProps {
- ids: number[];
-}
-
-
-interface TemplateFile {
- id: number;
- filePath: string;
-}
-
-export async function removeTemplates({
- ids
-}: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> {
- if (!ids || ids.length === 0) {
- return { error: "삭제할 템플릿이 선택되지 않았습니다." };
- }
-
- // unstable_noStore를 최상단에 배치
- unstable_noStore();
-
- try {
- // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리
- // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지)
- const templateFiles: TemplateFile[] = [];
-
- const result = await db.transaction(async (tx) => {
- // 각 템플릿의 파일 경로 가져오기
- for (const id of ids) {
- const { data: template, error } = await getBasicContractTemplateById(tx, id);
- if (template && template.filePath) {
- templateFiles.push({
- id: template.id,
- filePath: template.filePath
- });
- }
- }
-
- // DB에서 템플릿 삭제
- const { data, error } = await deleteBasicContractTemplates(tx, ids);
-
- if (error) {
- throw new Error(`템플릿 DB 삭제 실패: ${error}`);
- }
-
- return { data };
- });
-
- // 파일 시스템 삭제는 트랜잭션 성공 후 수행
- for (const template of templateFiles) {
- const deleted = await deleteFile(template.filePath);
-
- if (deleted) {
- console.log(`✅ 파일 삭제됨: ${template.filePath}`);
- } else {
- console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`);
- }
- }
-
-
- revalidateTag("basic-contract-templates");
- revalidateTag("template-status-counts");
-
-
-
- // 디버깅을 위한 로그
- console.log("캐시 무효화 완료:", ids);
-
- return { success: true };
- } catch (error) {
- console.error("템플릿 삭제 중 오류 발생:", error);
- return {
- error: error instanceof Error
- ? error.message
- : "템플릿 삭제 중 오류가 발생했습니다."
- };
- }
-}
-
-
-interface UpdateTemplateParams {
- id: number;
- formData: FormData;
-}
-
-const SCOPE_KEYS = [
- "shipBuildingApplicable",
- "windApplicable",
- "pcApplicable",
- "nbApplicable",
- "rcApplicable",
- "gyApplicable",
- "sysApplicable",
- "infraApplicable",
-] as const;
-
-function getBool(fd: FormData, key: string, defaultValue = false) {
- const v = fd.get(key);
- if (v === null) return defaultValue;
- return v === "true";
-}
-
-export async function updateTemplate({
- id,
- formData
-}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> {
- unstable_noStore();
-
- try {
- // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해)
- const existingTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, id),
- });
-
- if (!existingTemplate) {
- return { error: "템플릿을 찾을 수 없습니다." };
- }
-
- // 필수값
- const templateName = formData.get("templateName") as string | null;
- if (!templateName) {
- return { error: "템플릿 이름은 필수입니다." };
- }
-
- // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지
- const revisionStr = formData.get("revision")?.toString();
- const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision;
-
- // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인
- if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) {
- const duplicateCheck = await db.query.basicContractTemplates.findFirst({
- where: and(
- eq(basicContractTemplates.templateName, templateName),
- eq(basicContractTemplates.revision, revision),
- ne(basicContractTemplates.id, id) // 자기 자신은 제외
- ),
- });
-
- if (duplicateCheck) {
- return {
- error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.`
- };
- }
- }
-
- const legalReviewRequired = getBool(formData, "legalReviewRequired", false);
-
- // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정
- const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE";
-
- // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로
- const validityPeriodStr = formData.get("validityPeriod")?.toString();
- const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined;
-
- // Scope booleans
- const scopeData: Record<string, boolean> = {};
- for (const key of SCOPE_KEYS) {
- scopeData[key] = getBool(formData, key, false);
- }
-
- // 파일 처리
- const file = formData.get("file") as File | null;
- let fileName: string | undefined = undefined;
- let filePath: string | undefined = undefined;
-
- if (file) {
- // 1) 새 파일 저장 (DRM 해제 로직 적용)
- const saveResult = await saveDRMFile(
- file,
- decryptWithServerAction,
- 'basicContract/template'
- );
-
- if (!saveResult.success) {
- return { success: false, error: saveResult.error };
- }
- fileName = file.name;
- filePath = saveResult.publicPath;
-
- // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨)
- if (existingTemplate?.filePath) {
- const deleted = await deleteFile(existingTemplate.filePath);
- if (deleted) {
- console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`);
- } else {
- console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`);
- }
- }
- }
-
- // 업데이트할 데이터 구성
- const updateData: Record<string, any> = {
- templateName,
- revision,
- legalReviewRequired,
- status,
- updatedAt: new Date(),
- ...scopeData,
- };
-
- if (validityPeriod !== undefined) {
- updateData.validityPeriod = validityPeriod;
- }
- if (fileName && filePath) {
- updateData.fileName = fileName;
- updateData.filePath = filePath;
- }
-
- // DB 업데이트
- await db.transaction(async (tx) => {
- await tx
- .update(basicContractTemplates)
- .set(updateData)
- .where(eq(basicContractTemplates.id, id));
- });
-
- // 캐시 무효화
- revalidateTag("basic-contract-templates");
- revalidateTag("template-status-counts");
- revalidateTag("templates");
-
- return { success: true };
- } catch (error) {
- console.error("템플릿 업데이트 오류:", error);
- return {
- error: error instanceof Error
- ? error.message
- : "템플릿 업데이트 중 오류가 발생했습니다.",
- };
- }
-}
-
-interface RequestBasicContractInfoProps {
- vendorIds: number[];
- requestedBy: number;
- templateId: number;
-}
-
-
-export async function requestBasicContractInfo({
- vendorIds,
- requestedBy,
- templateId
-}: 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. 협력업체 정보 가져오기
- 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 {
- const isComplianceTemplate = template.templateName?.includes('준법');
- let selectedTemplateId = template.id;
- let selectedTemplate = template;
-
- if (isComplianceTemplate) {
- const vendorUser = await db.query.users.findFirst({
- where: and(
- eq(users.email, vendor.email),
- eq(users.domain, 'partners')
- )
- });
-
- const userLanguage = vendorUser?.language || 'en'; // 기본값은 영어
-
- if (userLanguage === 'ko') {
- // 한글 준법서약 템플릿 찾기
- const koreanTemplate = await db.query.basicContractTemplates.findFirst({
- where: and(
- sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
- sql`${basicContractTemplates.templateName} NOT LIKE '%영문%'`,
- eq(basicContractTemplates.status, 'ACTIVE')
- )
- });
-
- if (koreanTemplate) {
- selectedTemplateId = koreanTemplate.id;
- selectedTemplate = koreanTemplate;
- }
- } else {
- // 영문 준법서약 템플릿 찾기
- const englishTemplate = await db.query.basicContractTemplates.findFirst({
- where: and(
- sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
- sql`${basicContractTemplates.templateName} LIKE '%영문%'`,
- eq(basicContractTemplates.status, 'ACTIVE')
- )
- });
-
- if (englishTemplate) {
- selectedTemplateId = englishTemplate.id;
- selectedTemplate = englishTemplate;
- console.log(`✅ 영어 사용자 ${vendor.vendorName}에게 영문 준법서약 템플릿 전송`);
- }
- }
- }
-
- // 3-1. basic_contract 테이블에 레코드 추가
- const [newContract] = await db
- .insert(basicContract)
- .values({
- templateId: selectedTemplateId, // 언어별로 선택된 템플릿 ID 사용
- vendorId: vendor.id,
- requestedBy: requestedBy,
- status: "PENDING",
- fileName: selectedTemplate.fileName, // 선택된 템플릿 파일 이름 사용
- filePath: selectedTemplate.filePath, // 선택된 템플릿 파일 경로 사용
- })
- .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
- : "기본계약서 요청 처리 중 오류가 발생했습니다."
- };
- }
-}
-
-
-export async function getBasicContracts(input: GetBasciContractsSchema) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: basicContractTemplateStatsView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
-
- let globalWhere
- if (input.search) {
- const s = `%${input.search}%`
- globalWhere = or(ilike(basicContractTemplateStatsView.templateName, s),
- )
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
- }
-
- const finalWhere = and(
- // advancedWhere or your existing conditions
- advancedWhere,
- globalWhere // and()함수로 결합 or or() 등으로 결합
- )
-
-
- // 아니면 ilike, inArray, gte 등으로 where 절 구성
- const where = finalWhere
-
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id])
- )
- : [asc(basicContractTemplateStatsView.lastActivityDate)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectBasicContracts(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countBasicContracts(tx, where);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- console.log(err)
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input)], // 캐싱 키
- {
- revalidate: 3600,
- tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화
- }
- )();
-}
-
-
-export async function getBasicContractsByVendorId(
- input: GetBasciContractsVendorSchema,
- vendorId: number
-) {
- return unstable_cache(
- async () => {
- try {
- const offset = (input.page - 1) * input.perPage;
-
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: basicContractView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(basicContractView.templateName, s),
- ilike(basicContractView.vendorName, s),
- ilike(basicContractView.vendorCode, s),
- ilike(basicContractView.vendorEmail, s),
- ilike(basicContractView.status, s)
- );
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
- }
-
- // 벤더 ID 필터링 조건 추가
- const vendorCondition = eq(basicContractView.vendorId, vendorId);
-
- const finalWhere = and(
- // 항상 벤더 ID 조건을 포함
- vendorCondition,
- // 기존 조건들
- advancedWhere,
- globalWhere
- );
-
- const where = finalWhere;
-
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
- )
- : [asc(basicContractView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectBasicContractsVendor(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countBasicContractsVendor(tx, where);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트
- return { data: [], pageCount: 0 };
- }
- },
- [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
- {
- revalidate: 3600,
- tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
- }
- )();
-}
-
-
-export async function getBasicContractsByTemplateId(
- input: GetBasciContractsByIdSchema,
- templateId: number
-) {
- // return unstable_cache(
- // async () => {
- try {
-
- console.log(input.sort)
- const offset = (input.page - 1) * input.perPage;
-
- // const advancedTable = input.flags.includes("advancedTable");
- const advancedTable = true;
-
- // advancedTable 모드면 filterColumns()로 where 절 구성
- const advancedWhere = filterColumns({
- table: basicContractView,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
-
- let globalWhere;
- if (input.search) {
- const s = `%${input.search}%`;
- globalWhere = or(
- ilike(basicContractView.templateName, s),
- ilike(basicContractView.vendorName, s),
- ilike(basicContractView.vendorCode, s),
- ilike(basicContractView.vendorEmail, s),
- ilike(basicContractView.status, s)
- );
- // 필요시 여러 칼럼 OR조건 (status, priority, etc)
- }
-
- // 벤더 ID 필터링 조건 추가
- const templateCondition = eq(basicContractView.templateId, templateId);
-
- const finalWhere = and(
- // 항상 벤더 ID 조건을 포함
- templateCondition,
- // 기존 조건들
- advancedWhere,
- globalWhere
- );
-
- const where = finalWhere;
-
- const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
- )
- : [asc(basicContractView.createdAt)];
-
- // 트랜잭션 내부에서 Repository 호출
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectBasicContractsById(tx, {
- where,
- orderBy,
- offset,
- limit: input.perPage,
- });
-
- const total = await countBasicContractsById(tx, where);
- return { data, total };
- });
-
- const pageCount = Math.ceil(total / input.perPage);
-
- return { data, pageCount };
- } catch (err) {
- // 에러 발생 시 디폴트\
- console.log(err)
- return { data: [], pageCount: 0 };
- }
- // },
- // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
- // {
- // revalidate: 3600,
- // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
- // }
- // )();
-}
-
-export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
- try {
- return await findAllTemplates();
- } catch (err) {
- throw new Error("Failed to get templates");
- }
-}
-
-
-interface VendorTemplateStatus {
- vendorId: number;
- vendorName: string;
- templateId: number;
- templateName: string;
- status: string;
- createdAt: Date;
- isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상)
- isUpdated: boolean; // 템플릿이 업데이트되었는지
-}
-
-/**
- * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다.
- */
-// 계약 상태 확인 API 함수
-export async function checkContractRequestStatus(
- vendorIds: number[],
- templateIds: number[]
-) {
- try {
- // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인
- const requests = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- templateId: basicContract.templateId,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- updatedAt: basicContract.updatedAt,
- // completedAt 필드 추가 필요
- completedAt: basicContract.completedAt, // 계약 완료 날짜
- })
- .from(basicContract)
- .where(
- and(
- inArray(basicContract.vendorId, vendorIds),
- inArray(basicContract.templateId, templateIds)
- )
- )
- .orderBy(desc(basicContract.createdAt));
-
- // 협력업체 정보 가져오기
- const vendorData = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- })
- .from(vendors)
- .where(inArray(vendors.id, vendorIds));
-
- // 템플릿 정보 가져오기
- const templateData = await db
- .select({
- id: basicContractTemplates.id,
- templateName: basicContractTemplates.templateName,
- updatedAt: basicContractTemplates.updatedAt,
- validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월)
- })
- .from(basicContractTemplates)
- .where(inArray(basicContractTemplates.id, templateIds));
-
- // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑
- const vendorMap = new Map(vendorData.map(v => [v.id, v]));
- const templateMap = new Map(templateData.map(t => [t.id, t]));
-
- const uniqueRequests = new Map();
-
- // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용
- requests.forEach(req => {
- const key = `${req.vendorId}-${req.templateId}`;
- if (!uniqueRequests.has(key)) {
- uniqueRequests.set(key, req);
- }
- });
-
- // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의
- interface VendorTemplateStatus {
- vendorId: number;
- vendorName: string;
- templateId: number;
- templateName: string;
- status: string;
- createdAt: Date;
- completedAt?: Date;
- isExpired: boolean;
- isUpdated: boolean;
- isContractExpired: boolean;
- }
-
- // 명시적 타입 지정
- const statusData: VendorTemplateStatus[] = [];
-
- // 요청 만료 기준 - 30일
- const REQUEST_EXPIRATION_DAYS = 30;
-
- // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능)
- const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12;
-
- const now = new Date();
-
- // 모든 협력업체-템플릿 조합에 대해 상태 확인
- vendorIds.forEach(vendorId => {
- templateIds.forEach(templateId => {
- const key = `${vendorId}-${templateId}`;
- const request = uniqueRequests.get(key);
- const vendor = vendorMap.get(vendorId);
- const template = templateMap.get(templateId);
-
- if (!vendor || !template) return;
-
- let status = "NONE"; // 기본 상태: 요청 없음
- let createdAt = new Date();
- let completedAt = null;
- let isExpired = false;
- let isUpdated = false;
- let isContractExpired = false;
-
- if (request) {
- status = request.status;
- createdAt = request.createdAt;
- completedAt = request.completedAt;
-
- // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용)
- if (status === "PENDING") {
- isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS;
- }
-
- // 요청 이후 템플릿이 업데이트되었는지 확인
- if (template.updatedAt && request.createdAt) {
- isUpdated = template.updatedAt > request.createdAt;
- }
-
- // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우)
- if (status === "COMPLETED" && completedAt) {
- // 템플릿별 유효기간 또는 기본값 사용
- const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS;
-
- // 계약 만료일 계산 (완료일 + 유효기간)
- const expiryDate = addYears(completedAt, validityMonths / 12);
-
- // 현재 날짜가 만료일 이후인지 확인
- isContractExpired = isBefore(expiryDate, now);
- }
- }
-
- statusData.push({
- vendorId,
- vendorName: vendor.vendorName,
- templateId,
- templateName: template.templateName,
- status,
- createdAt,
- completedAt,
- isExpired,
- isUpdated,
- isContractExpired,
- });
- });
- });
-
- return { data: statusData };
- } catch (error) {
- console.error("계약 상태 확인 중 오류:", error);
- return {
- data: [],
- error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다."
- };
- }
-}
-
-
-/**
- * ID로 기본계약서 템플릿 조회
- */
-export async function getBasicContractTemplateByIdService(id: string) {
- try {
- const templateId = parseInt(id);
-
- if (isNaN(templateId)) {
- return null;
- }
-
- const templates = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .limit(1);
-
- if (templates.length === 0) {
- return null;
- }
-
- return templates[0];
- } catch (error) {
- console.error("템플릿 조회 오류:", error);
- return null;
- }
-}
-
-/**
- * 템플릿 파일 저장 서버 액션
- */
-export async function saveTemplateFile(templateId: number, formData: FormData) {
- try {
- const file = formData.get("file") as File;
-
- if (!file) {
- return { error: "파일이 필요합니다." };
- }
-
- // 기존 템플릿 정보 조회
- const existingTemplate = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .limit(1);
-
- if (existingTemplate.length === 0) {
- return { error: "템플릿을 찾을 수 없습니다." };
- }
-
- const template = existingTemplate[0];
-
- // 파일 저장 로직 (실제 파일 시스템에 저장)
- const { writeFile, mkdir } = await import("fs/promises");
- const { join } = await import("path");
-
- const bytes = await file.arrayBuffer();
- const buffer = Buffer.from(bytes);
-
- // 기존 파일 경로 사용 (덮어쓰기)
- const uploadPath = join(process.cwd(), "public", template.filePath.replace(/^\//, ""));
-
- // 디렉토리 확인 및 생성
- const dirPath = uploadPath.substring(0, uploadPath.lastIndexOf('/'));
- await mkdir(dirPath, { recursive: true });
-
- // 파일 저장
- await writeFile(uploadPath, buffer);
-
- // 데이터베이스 업데이트 (수정일시만 업데이트)
- await db
- .update(basicContractTemplates)
- .set({
- updatedAt: new Date(),
- })
- .where(eq(basicContractTemplates.id, templateId));
-
- // 캐시 무효화
- revalidatePath(`/evcp/basic-contract-template/${templateId}`);
- revalidateTag("basic-contract-templates");
-
- return { success: true, message: "템플릿이 성공적으로 저장되었습니다." };
-
- } catch (error) {
- console.error("템플릿 저장 오류:", error);
- return { error: "저장 중 오류가 발생했습니다." };
- }
-}
-
-/**
- * 템플릿 페이지 새로고침 서버 액션
- */
-export async function refreshTemplatePage(templateId: string) {
- revalidatePath(`/evcp/basic-contract-template/${templateId}`);
- revalidateTag("basic-contract-templates");
-}
-
-// 새 리비전 생성 함수
-export async function createBasicContractTemplateRevision(input: any) {
- unstable_noStore();
-
- try {
- // 기본 템플릿 존재 확인
- const baseTemplate = await db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, input.baseTemplateId))
- .limit(1);
-
- if (baseTemplate.length === 0) {
- return { data: null, error: "기본 템플릿을 찾을 수 없습니다." };
- }
-
- // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인
- const existingRevision = await db
- .select()
- .from(basicContractTemplates)
- .where(
- and(
- eq(basicContractTemplates.templateName, input.templateName),
- eq(basicContractTemplates.revision, input.revision)
- )
- )
- .limit(1);
-
- if (existingRevision.length > 0) {
- return {
- data: null,
- error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.`
- };
- }
-
- // 새 리비전이 기존 리비전들보다 큰 번호인지 확인
- const maxRevision = await db
- .select({ maxRev: basicContractTemplates.revision })
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.templateName, input.templateName))
- .orderBy(desc(basicContractTemplates.revision))
- .limit(1);
-
- if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) {
- return {
- data: null,
- error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.`
- };
- }
-
- const newRevision = await db.transaction(async (tx) => {
- const [row] = await insertBasicContractTemplate(tx, {
- templateName: input.templateName,
- revision: input.revision,
- legalReviewRequired: input.legalReviewRequired,
- status: "ACTIVE",
- fileName: input.fileName,
- filePath: input.filePath,
- validityPeriod: null,
- });
- return row;
- });
- //기존 템플릿의 이전 리비전은 비활성으로 변경
- await db.update(basicContractTemplates).set({
- status: "DISPOSED",
- }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision)));
- //캐시 무효화
- revalidateTag("basic-contract-templates");
-
- return { data: newRevision, error: null };
- } catch (error) {
- return { data: null, error: getErrorMessage(error) };
- }
-}
-
-
-
-
-// 1) 전체 basicContractTemplates 조회
-export async function getALLBasicContractTemplates() {
- return db
- .select()
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.status,"ACTIVE"))
- .orderBy(desc(basicContractTemplates.createdAt));
-}
-
-// 2) 등록된 templateName만 중복 없이 가져오기
-export async function getExistingTemplateNames(): Promise<string[]> {
- try {
- const templates = await db
- .select({
- templateName: basicContractTemplates.templateName
- })
- .from(basicContractTemplates)
- .where(
- and(
- eq(basicContractTemplates.status, 'ACTIVE'),
- // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용)
- not(like(basicContractTemplates.templateName, '% GTC'))
- )
- );
-
- return templates.map(t => t.templateName);
- } catch (error) {
- console.error('Failed to fetch existing template names:', error);
- throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.');
- }
-}
-
-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;
-}
-
-export async function getVendorAttachments(vendorId: number) {
- try {
- const attachments = await db
- .select()
- .from(vendorAttachments)
- .where(
- and(
- eq(vendorAttachments.vendorId, vendorId),
- eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT")
- )
- );
-
- console.log(attachments,"attachments")
-
- return {
- success: true,
- data: attachments
- };
- } catch (error) {
- console.error("Error fetching vendor attachments:", error);
- return {
- success: false,
- data: [],
- error: "Failed to fetch vendor attachments"
- };
- }
-}
-
-// 설문조사 템플릿 전체 데이터 타입
-export interface SurveyTemplateWithQuestions {
- id: number;
- name: string;
- description: string | null;
- version: string;
- questions: SurveyQuestion[];
-}
-
-export interface SurveyQuestion {
- id: number;
- questionNumber: string;
- questionText: string;
- questionType: string;
- isRequired: boolean;
- hasDetailText: boolean;
- hasFileUpload: boolean;
- parentQuestionId: number | null;
- conditionalValue: string | null;
- displayOrder: number;
- options: SurveyQuestionOption[];
-}
-
-export interface SurveyQuestionOption {
- id: number;
- optionValue: string;
- optionText: string;
- allowsOtherInput: boolean;
- displayOrder: number;
-}
-
-/**
- * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기
- */
-export async function getActiveSurveyTemplate(language: string = 'ko'): Promise<SurveyTemplateWithQuestions | null> {
- try {
- // 1. 활성화된 템플릿 가져오기 (언어에 따라 필터링)
- const templates = await db
- .select()
- .from(complianceSurveyTemplates)
- .where(eq(complianceSurveyTemplates.isActive, true))
- .orderBy(complianceSurveyTemplates.id);
-
- if (!templates || templates.length === 0) {
- console.log('활성화된 설문조사 템플릿이 없습니다.');
- return null;
- }
-
- // 언어에 따라 적절한 템플릿 선택
- let templateData;
- if (language === 'en') {
- // 영문 템플릿 찾기 (이름에 '영문' 또는 'English' 포함)
- templateData = templates.find(t =>
- t.name.includes('영문') ||
- t.name.toLowerCase().includes('english') ||
- t.name.toLowerCase().includes('en')
- );
- } else {
- // 한글 템플릿 찾기 (영문이 아닌 것)
- templateData = templates.find(t =>
- !t.name.includes('영문') &&
- !t.name.toLowerCase().includes('english') &&
- !t.name.toLowerCase().includes('en')
- );
- }
-
- // 적절한 템플릿을 찾지 못하면 첫 번째 템플릿 사용
- if (!templateData) {
- console.log(`언어 '${language}'에 맞는 템플릿을 찾지 못해 기본 템플릿을 사용합니다.`);
- templateData = templates[0];
- }
-
- console.log(`✅ 선택된 설문조사 템플릿: ${templateData.name} (언어: ${language})`);
-
- // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순)
- const questions = await db
- .select()
- .from(complianceQuestions)
- .where(eq(complianceQuestions.templateId, templateData.id))
- .orderBy(asc(complianceQuestions.displayOrder));
-
- // 3. 각 질문의 옵션들 가져오기
- const questionIds = questions.map(q => q.id);
- const allOptions = questionIds.length > 0
- ? await db
- .select()
- .from(complianceQuestionOptions)
- .where(inArray(complianceQuestionOptions.questionId, questionIds))
- .orderBy(
- complianceQuestionOptions.questionId,
- asc(complianceQuestionOptions.displayOrder)
- )
- : [];
-
-
- // 4. 질문별로 옵션들 그룹화
- const optionsByQuestionId = allOptions.reduce((acc, option) => {
- if (!acc[option.questionId]) {
- acc[option.questionId] = [];
- }
- acc[option.questionId].push({
- id: option.id,
- optionValue: option.optionValue,
- optionText: option.optionText,
- allowsOtherInput: option.allowsOtherInput,
- displayOrder: option.displayOrder,
- });
- return acc;
- }, {} as Record<number, SurveyQuestionOption[]>);
-
- // 5. 최종 데이터 구성
- const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
- id: question.id,
- questionNumber: question.questionNumber,
- questionText: question.questionText,
- questionType: question.questionType,
- isRequired: question.isRequired,
- hasDetailText: question.hasDetailText,
- hasFileUpload: question.hasFileUpload,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- displayOrder: question.displayOrder,
- options: optionsByQuestionId[question.id] || [],
- }));
-
- return {
- id: templateData.id,
- name: templateData.name,
- description: templateData.description,
- version: templateData.version,
- questions: questionsWithOptions,
- };
-
- } catch (error) {
- console.error('설문조사 템플릿 로드 실패:', error);
- return null;
- }
-}
-
-/**
- * 특정 템플릿 ID로 설문조사 템플릿 가져오기
- */
-export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> {
- try {
- const template = await db
- .select()
- .from(complianceSurveyTemplates)
- .where(eq(complianceSurveyTemplates.id, templateId))
- .limit(1);
-
- if (!template || template.length === 0) {
- return null;
- }
-
- const templateData = template[0];
-
- const questions = await db
- .select()
- .from(complianceQuestions)
- .where(eq(complianceQuestions.templateId, templateId))
- .orderBy(asc(complianceQuestions.displayOrder));
-
- const questionIds = questions.map(q => q.id);
- const allOptions = questionIds.length > 0
- ? await db
- .select()
- .from(complianceQuestionOptions)
- .where(
- complianceQuestionOptions.questionId.in ?
- complianceQuestionOptions.questionId.in(questionIds) :
- eq(complianceQuestionOptions.questionId, questionIds[0])
- )
- .orderBy(
- complianceQuestionOptions.questionId,
- asc(complianceQuestionOptions.displayOrder)
- )
- : [];
-
- const optionsByQuestionId = allOptions.reduce((acc, option) => {
- if (!acc[option.questionId]) {
- acc[option.questionId] = [];
- }
- acc[option.questionId].push({
- id: option.id,
- optionValue: option.optionValue,
- optionText: option.optionText,
- allowsOtherInput: option.allowsOtherInput,
- displayOrder: option.displayOrder,
- });
- return acc;
- }, {} as Record<number, SurveyQuestionOption[]>);
-
- const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
- id: question.id,
- questionNumber: question.questionNumber,
- questionText: question.questionText,
- questionType: question.questionType,
- isRequired: question.isRequired,
- hasDetailText: question.hasDetailText,
- hasFileUpload: question.hasFileUpload,
- parentQuestionId: question.parentQuestionId,
- conditionalValue: question.conditionalValue,
- displayOrder: question.displayOrder,
- options: optionsByQuestionId[question.id] || [],
- }));
-
- return {
- id: templateData.id,
- name: templateData.name,
- description: templateData.description,
- version: templateData.version,
- questions: questionsWithOptions,
- };
-
- } catch (error) {
- console.error('설문조사 템플릿 로드 실패:', error);
- return null;
- }
-}
-
-
-// 설문 답변 데이터 타입 정의
-export interface SurveyAnswerData {
- questionId: number;
- answerValue?: string;
- detailText?: string;
- otherText?: string;
- files?: File[];
-}
-
-// 설문조사 완료 요청 데이터 타입
-export interface CompleteSurveyRequest {
- contractId: number;
- templateId: number;
- answers: SurveyAnswerData[];
- progressStatus?: any; // 진행 상태 정보 (옵션)
-}
-
-// 서버 액션: 설문조사 완료 처리
-export async function completeSurvey(data: CompleteSurveyRequest) {
- try {
- console.log('🚀 설문조사 완료 처리 시작:', {
- contractId: data.contractId,
- templateId: data.templateId,
- answersCount: data.answers?.length || 0
- });
-
- // 입력 검증
- if (!data.contractId || !data.templateId || !data.answers?.length) {
- throw new Error('필수 데이터가 누락되었습니다.');
- }
-
- // 트랜잭션으로 처리
- const result = await db.transaction(async (tx) => {
- // 1. complianceResponses 테이블 upsert
- console.log('📋 complianceResponses 처리 중...');
-
- // 기존 응답 확인
- const existingResponse = await tx
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, data.contractId),
- eq(complianceResponses.templateId, data.templateId)
- )
- )
- .limit(1);
-
- let responseId: number;
-
- if (existingResponse.length > 0) {
- // 기존 응답 업데이트
- const updateData = {
- status: 'COMPLETED' as const,
- completedAt: new Date(),
- updatedAt: new Date()
- };
-
- await tx
- .update(complianceResponses)
- .set(updateData)
- .where(eq(complianceResponses.id, existingResponse[0].id));
-
- responseId = existingResponse[0].id;
- console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`);
- } else {
- // 새 응답 생성
- const newResponse: NewComplianceResponse = {
- basicContractId: data.contractId,
- templateId: data.templateId,
- status: 'COMPLETED',
- completedAt: new Date()
- };
-
- const insertResult = await tx
- .insert(complianceResponses)
- .values(newResponse)
- .returning({ id: complianceResponses.id });
-
- responseId = insertResult[0].id;
- console.log(`✅ 새 응답 생성 완료: ID ${responseId}`);
- }
-
- // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요)
- console.log('🗑️ 기존 답변들 삭제 중...');
-
- // 먼저 기존 답변에 연결된 파일들 삭제
- const existingAnswers = await tx
- .select({ id: complianceResponseAnswers.id })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseId));
-
- if (existingAnswers.length > 0) {
- const answerIds = existingAnswers.map(a => a.id);
-
- // 파일들 먼저 삭제
- for (const answerId of answerIds) {
- await tx
- .delete(complianceResponseFiles)
- .where(eq(complianceResponseFiles.answerId, answerId));
- }
-
- // 답변들 삭제
- await tx
- .delete(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseId));
- }
-
- // 3. 새로운 답변들 생성
- console.log('📝 새로운 답변들 생성 중...');
- const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = [];
-
- for (const answer of data.answers) {
- // 빈 답변은 스킵 (선택적 질문의 경우)
- if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) {
- continue;
- }
-
- const newAnswer: NewComplianceResponseAnswer = {
- responseId,
- questionId: answer.questionId,
- answerValue: answer.answerValue || null,
- detailText: answer.detailText || null,
- otherText: answer.otherText || null,
- // percentageValue는 필요시 추가 처리
- };
-
- const answerResult = await tx
- .insert(complianceResponseAnswers)
- .values(newAnswer)
- .returning({ id: complianceResponseAnswers.id });
-
- const answerId = answerResult[0].id;
-
- createdAnswers.push({
- questionId: answer.questionId,
- answerId,
- files: answer.files
- });
-
- console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`);
- }
-
- // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요)
- console.log('📎 파일 업로드 처리 중...');
-
- for (const answerWithFiles of createdAnswers) {
- if (answerWithFiles.files && answerWithFiles.files.length > 0) {
- for (const file of answerWithFiles.files) {
- // TODO: 실제 파일 저장 로직 구현 필요
- // 현재는 파일 메타데이터만 저장
-
-
- // 파일 저장 경로 생성 (예시)
- const fileName = file.name;
- const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`;
-
- const fileUpload = await saveFile({file,filePath })
-
- const newFile: NewComplianceResponseFile = {
- answerId: answerWithFiles.answerId,
- fileName,
- filePath,
- fileSize: file.size,
- mimeType: file.type || 'application/octet-stream'
- };
-
- await tx
- .insert(complianceResponseFiles)
- .values(newFile);
-
- console.log(`📎 파일 메타데이터 저장: ${fileName}`);
- }
- }
- }
-
- return {
- responseId,
- answersCount: createdAnswers.length,
- success: true
- };
- });
-
- console.log('🎉 설문조사 완료 처리 성공:', result);
-
-
- return {
- success: true,
- message: '설문조사가 성공적으로 완료되었습니다.',
- data: result
- };
-
- } catch (error) {
- console.error('❌ 설문조사 완료 처리 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.',
- data: null
- };
- }
-}
-
-// 설문조사 응답 조회 서버 액션
-export async function getSurveyResponse(contractId: number, templateId: number) {
- try {
- const response = await db
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, contractId),
- eq(complianceResponses.templateId, templateId)
- )
- )
- .limit(1);
-
- if (response.length === 0) {
- return { success: true, data: null };
- }
-
- // 답변들과 파일들도 함께 조회
- const answers = await db
- .select({
- id: complianceResponseAnswers.id,
- questionId: complianceResponseAnswers.questionId,
- answerValue: complianceResponseAnswers.answerValue,
- detailText: complianceResponseAnswers.detailText,
- otherText: complianceResponseAnswers.otherText,
- percentageValue: complianceResponseAnswers.percentageValue,
- })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, response[0].id));
-
- // 각 답변의 파일들 조회
- const answersWithFiles = await Promise.all(
- answers.map(async (answer) => {
- const files = await db
- .select()
- .from(complianceResponseFiles)
- .where(eq(complianceResponseFiles.answerId, answer.id));
-
- return {
- ...answer,
- files
- };
- })
- );
-
- return {
- success: true,
- data: {
- response: response[0],
- answers: answersWithFiles
- }
- };
-
- } catch (error) {
- console.error('❌ 설문조사 응답 조회 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.',
- data: null
- };
- }
-}
-
-// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직)
-export async function uploadSurveyFile(file: File, contractId: number, answerId: number) {
- try {
- // TODO: 실제 파일 저장 구현
- // 예: AWS S3, 로컬 파일시스템, 등등
-
- // 현재는 예시 구현
- const fileName = `${Date.now()}-${file.name}`;
- const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`;
-
- // 실제로는 여기서 파일을 물리적으로 저장해야 함
- // const savedPath = await saveFileToStorage(file, filePath);
-
- return {
- success: true,
- filePath,
- fileName: file.name,
- fileSize: file.size,
- mimeType: file.type
- };
-
- } catch (error) {
- console.error('❌ 파일 업로드 실패:', error);
-
- return {
- success: false,
- message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
- };
- }
-}
-
-
-// 기존 응답 조회를 위한 타입
-export interface ExistingResponse {
- responseId: number;
- status: string;
- completedAt: string | null;
- answers: {
- questionId: number;
- answerValue: string | null;
- detailText: string | null;
- otherText: string | null;
- files: Array<{
- id: number;
- fileName: string;
- filePath: string;
- fileSize: number;
- }>;
- }[];
-}
-
-// 기존 응답 조회 서버 액션
-export async function getExistingSurveyResponse(
- contractId: number,
- templateId: number
-): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> {
- try {
- // 1. 해당 계약서의 응답 조회
- const response = await db
- .select()
- .from(complianceResponses)
- .where(
- and(
- eq(complianceResponses.basicContractId, contractId),
- eq(complianceResponses.templateId, templateId)
- )
- )
- .limit(1);
-
- if (!response || response.length === 0) {
- return { success: true, data: null };
- }
-
- const responseData = response[0];
-
- // 2. 해당 응답의 모든 답변 조회
- const answers = await db
- .select({
- questionId: complianceResponseAnswers.questionId,
- answerValue: complianceResponseAnswers.answerValue,
- detailText: complianceResponseAnswers.detailText,
- otherText: complianceResponseAnswers.otherText,
- answerId: complianceResponseAnswers.id,
- })
- .from(complianceResponseAnswers)
- .where(eq(complianceResponseAnswers.responseId, responseData.id));
-
- // 3. 각 답변의 파일들 조회
- const answerIds = answers.map(a => a.answerId);
- const files = answerIds.length > 0
- ? await db
- .select()
- .from(complianceResponseFiles)
- .where(inArray(complianceResponseFiles.answerId, answerIds))
- : [];
-
- // 4. 답변별 파일 그룹화
- const filesByAnswerId = files.reduce((acc, file) => {
- if (!acc[file.answerId]) {
- acc[file.answerId] = [];
- }
- acc[file.answerId].push({
- id: file.id,
- fileName: file.fileName,
- filePath: file.filePath,
- fileSize: file.fileSize || 0,
- });
- return acc;
- }, {} as Record<number, Array<{id: number; fileName: string; filePath: string; fileSize: number}>>);
-
- // 5. 최종 데이터 구성
- const answersWithFiles = answers.map(answer => ({
- questionId: answer.questionId,
- answerValue: answer.answerValue,
- detailText: answer.detailText,
- otherText: answer.otherText,
- files: filesByAnswerId[answer.answerId] || [],
- }));
-
- return {
- success: true,
- data: {
- responseId: responseData.id,
- status: responseData.status,
- completedAt: responseData.completedAt?.toISOString() || null,
- answers: answersWithFiles,
- },
- };
-
- } catch (error) {
- console.error('기존 설문 응답 조회 실패:', error);
- return {
- success: false,
- data: null,
- message: '기존 응답을 불러오는데 실패했습니다.'
- };
- }
-}
-
-export type GtcVendorData = {
- vendorDocument: {
- id: number;
- name: string;
- description: string | null;
- version: string;
- reviewStatus: string;
- vendorId: number;
- baseDocumentId: number;
- vendorName: string;
- vendorCode: string;
- };
- clauses: Array<{
- id: number;
- baseClauseId: number;
- vendorDocumentId: number;
- parentId: number | null;
- depth: number;
- sortOrder: string;
- fullPath: string | null;
- reviewStatus: string;
- negotiationNote: string | null;
- isExcluded: boolean;
-
- // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값)
- effectiveItemNumber: string;
- effectiveCategory: string | null;
- effectiveSubtitle: string;
- effectiveContent: string | null;
-
- // 기본 조항 정보
- baseItemNumber: string;
- baseCategory: string | null;
- baseSubtitle: string;
- baseContent: string | null;
-
- // 수정 여부
- hasModifications: boolean;
- isNumberModified: boolean;
- isCategoryModified: boolean;
- isSubtitleModified: boolean;
- isContentModified: boolean;
-
- // 코멘트 관련
- hasComment: boolean;
- pendingComment: string | null;
- }>;
-};
-
-/**
- * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다.
- * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용)
- * @returns GTC 벤더 데이터 또는 null
- */
-export async function getVendorGtcData(contractId?: number): Promise<GtcVendorData | null> {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.companyId) {
- throw new Error("회사 정보가 없습니다.");
- }
-
- console.log(contractId, "contractId");
-
- const companyId = session.user.companyId;
- const vendorId = companyId; // companyId를 vendorId로 사용
-
- // 1. 계약 정보 가져오기
- const existingContract = await db.query.basicContract.findFirst({
- where: eq(basicContract.id, contractId),
- });
-
- if (!existingContract) {
- throw new Error("계약을 찾을 수 없습니다.");
- }
-
- // 2. 계약 템플릿 정보 가져오기
- const existingContractTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음
- });
-
- if (!existingContractTemplate) {
- throw new Error("계약 템플릿을 찾을 수 없습니다.");
- }
-
- // 3. General 타입인지 확인
- const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general');
-
- let targetBaseDocumentId: number;
-
- if (isGeneral) {
- // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용
- const standardGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'standard'),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
- });
-
- if (!standardGtcDoc) {
- throw new Error("표준 GTC 문서를 찾을 수 없습니다.");
- }
-
- targetBaseDocumentId = standardGtcDoc.id;
- console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`);
-
- } else {
- // General이 아닌 경우: 프로젝트별 GTC 문서 사용
- const projectCode = existingContractTemplate.templateName.split(" ")[0];
-
- if (!projectCode) {
- throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다.");
- }
-
- // 프로젝트 찾기
- const existingProject = await db.query.projects.findFirst({
- where: eq(projects.code, projectCode),
- });
-
- if (!existingProject) {
- throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`);
- }
-
- // 해당 프로젝트의 GTC 문서 찾기
- const projectGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'project'),
- eq(gtcDocuments.projectId, existingProject.id),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
- });
-
- if (!projectGtcDoc) {
- console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`);
-
- // 프로젝트별 GTC가 없으면 표준 GTC 사용
- const standardGtcDoc = await db.query.gtcDocuments.findFirst({
- where: and(
- eq(gtcDocuments.type, 'standard'),
- eq(gtcDocuments.isActive, true)
- ),
- orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)]
- });
-
- if (!standardGtcDoc) {
- throw new Error("표준 GTC 문서도 찾을 수 없습니다.");
- }
-
- targetBaseDocumentId = standardGtcDoc.id;
- } else {
- targetBaseDocumentId = projectGtcDoc.id;
- console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`);
- }
- }
-
- // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌)
- const vendorDocResult = await db
- .select({
- id: gtcVendorDocuments.id,
- name: gtcVendorDocuments.name,
- description: gtcVendorDocuments.description,
- version: gtcVendorDocuments.version,
- reviewStatus: gtcVendorDocuments.reviewStatus,
- vendorId: gtcVendorDocuments.vendorId,
- baseDocumentId: gtcVendorDocuments.baseDocumentId,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- })
- .from(gtcVendorDocuments)
- .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id))
- .where(
- and(
- eq(gtcVendorDocuments.vendorId, vendorId),
- eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId),
- eq(gtcVendorDocuments.isActive, true)
- )
- )
- .limit(1);
-
- // 벤더 문서가 없으면 기본 정보로 생성
- const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : {
- id: null, // 벤더 문서가 아직 생성되지 않음
- name: `GTC 검토 (벤더 ID: ${vendorId})`,
- description: "기본 GTC 협의",
- version: "1.0",
- reviewStatus: "pending",
- vendorId: vendorId,
- baseDocumentId: targetBaseDocumentId,
- vendorName: "Unknown Vendor",
- vendorCode: "UNKNOWN"
- };
-
- if (vendorDocResult.length === 0) {
- console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`);
- }
-
- // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께)
- const clausesResult = await db
- .select({
- // 기본 조항 정보 (메인)
- baseClauseId: gtcClauses.id,
- baseItemNumber: gtcClauses.itemNumber,
- baseCategory: gtcClauses.category,
- baseSubtitle: gtcClauses.subtitle,
- baseContent: gtcClauses.content,
- baseParentId: gtcClauses.parentId,
- baseDepth: gtcClauses.depth,
- baseSortOrder: gtcClauses.sortOrder,
- baseFullPath: gtcClauses.fullPath,
-
- // 벤더 조항 정보 (있는 경우만)
- vendorClauseId: gtcVendorClauses.id,
- vendorDocumentId: gtcVendorClauses.vendorDocumentId,
- reviewStatus: gtcVendorClauses.reviewStatus,
- negotiationNote: gtcVendorClauses.negotiationNote,
- isExcluded: gtcVendorClauses.isExcluded,
-
- // 수정된 값들 (있는 경우만)
- modifiedItemNumber: gtcVendorClauses.modifiedItemNumber,
- modifiedCategory: gtcVendorClauses.modifiedCategory,
- modifiedSubtitle: gtcVendorClauses.modifiedSubtitle,
- modifiedContent: gtcVendorClauses.modifiedContent,
-
- // 수정 여부
- isNumberModified: gtcVendorClauses.isNumberModified,
- isCategoryModified: gtcVendorClauses.isCategoryModified,
- isSubtitleModified: gtcVendorClauses.isSubtitleModified,
- isContentModified: gtcVendorClauses.isContentModified,
- })
- .from(gtcClauses)
- .leftJoin(gtcVendorClauses, and(
- eq(gtcVendorClauses.baseClauseId, gtcClauses.id),
- vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`,
- eq(gtcVendorClauses.isActive, true)
- ))
- .where(
- and(
- eq(gtcClauses.documentId, targetBaseDocumentId),
- eq(gtcClauses.isActive, true)
- )
- )
- .orderBy(gtcClauses.sortOrder);
-
- let negotiationHistoryMap = new Map();
-
- if (vendorDocument.id) {
- const vendorClauseIds = clausesResult
- .filter(c => c.vendorClauseId)
- .map(c => c.vendorClauseId);
-
- if (vendorClauseIds.length > 0) {
- const histories = await db
- .select({
- vendorClauseId: gtcNegotiationHistory.vendorClauseId,
- action: gtcNegotiationHistory.action,
- previousStatus: gtcNegotiationHistory.previousStatus,
- newStatus: gtcNegotiationHistory.newStatus,
- comment: gtcNegotiationHistory.comment,
- actorType: gtcNegotiationHistory.actorType,
- actorId: gtcNegotiationHistory.actorId,
- actorName: gtcNegotiationHistory.actorName,
- actorEmail: gtcNegotiationHistory.actorEmail,
- createdAt: gtcNegotiationHistory.createdAt,
- changedFields: gtcNegotiationHistory.changedFields,
- })
- .from(gtcNegotiationHistory)
- .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id))
- .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds))
- .orderBy(desc(gtcNegotiationHistory.createdAt));
-
- // 벤더 조항별로 이력 그룹화
- histories.forEach(history => {
- if (!negotiationHistoryMap.has(history.vendorClauseId)) {
- negotiationHistoryMap.set(history.vendorClauseId, []);
- }
- negotiationHistoryMap.get(history.vendorClauseId).push(history);
- });
- }
- }
-
-
-
- // 6. 데이터 변환 및 추가 정보 계산
- const clauses = clausesResult.map(clause => {
- const hasVendorData = !!clause.vendorClauseId;
- const negotiationHistory = hasVendorData ?
- (negotiationHistoryMap.get(clause.vendorClauseId) || []) : [];
-
- // 코멘트가 있는 이력들만 필터링
- const commentHistory = negotiationHistory.filter(h => h.comment);
- const latestComment = commentHistory[0]?.comment || null;
- const hasComment = commentHistory.length > 0;
-
- return {
- id: clause.baseClauseId,
- vendorClauseId: clause.vendorClauseId,
- vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null,
-
- // 기본 조항의 계층 구조 정보 사용
- parentId: clause.baseParentId,
- depth: clause.baseDepth,
- sortOrder: clause.baseSortOrder,
- fullPath: clause.baseFullPath,
-
- // 상태 정보 (벤더 데이터가 있는 경우만)
- reviewStatus: clause.reviewStatus || 'pending',
- negotiationNote: clause.negotiationNote,
- isExcluded: clause.isExcluded || false,
-
- // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값)
- effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber,
- effectiveCategory: clause.modifiedCategory || clause.baseCategory,
- effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle,
- effectiveContent: clause.modifiedContent || clause.baseContent,
-
- // 기본 조항 정보
- baseItemNumber: clause.baseItemNumber,
- baseCategory: clause.baseCategory,
- baseSubtitle: clause.baseSubtitle,
- baseContent: clause.baseContent,
-
- // 수정 여부
- // hasModifications,
- isNumberModified: clause.isNumberModified || false,
- isCategoryModified: clause.isCategoryModified || false,
- isSubtitleModified: clause.isSubtitleModified || false,
- isContentModified: clause.isContentModified || false,
-
- hasComment,
- latestComment,
- commentHistory, // 전체 코멘트 이력
- negotiationHistory, // 전체 협의 이력
- };
- });
-
- return {
- vendorDocument,
- clauses,
- };
-
- } catch (error) {
- console.error('GTC 벤더 데이터 가져오기 실패:', error);
- throw error;
- }
-}
-
-
-interface ClauseUpdateData {
- itemNumber: string;
- category: string | null;
- subtitle: string;
- content: string | null;
- comment: string;
-}
-
-interface VendorDocument {
- id: number | null;
- vendorId: number;
- baseDocumentId: number;
- name: string;
- description: string;
- version: string;
-}
-
-export async function updateVendorClause(
- baseClauseId: number,
- vendorClauseId: number | null,
- clauseData: any,
- vendorDocument: any
-): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.companyId) {
- return { success: false, error: "회사 정보가 없습니다." };
- }
-
- const companyId = session.user.companyId;
- const vendorId = companyId;
- const userId = Number(session.user.id);
-
- // 1. 기본 조항 정보 가져오기
- const baseClause = await db.query.gtcClauses.findFirst({
- where: eq(gtcClauses.id, baseClauseId),
- });
-
- if (!baseClause) {
- return { success: false, error: "기본 조항을 찾을 수 없습니다." };
- }
-
- // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우)
- let previousComment = null;
- if (vendorClauseId) {
- const previousData = await db
- .select({ comment: gtcVendorClauses.negotiationNote })
- .from(gtcVendorClauses)
- .where(eq(gtcVendorClauses.id, vendorClauseId))
- .limit(1);
-
- previousComment = previousData?.[0]?.comment || null;
- }
-
- // 3. 벤더 문서 ID 확보 (없으면 생성)
- let finalVendorDocumentId = vendorDocument?.id;
-
- if (!finalVendorDocumentId && vendorDocument) {
- const newVendorDoc = await db.insert(gtcVendorDocuments).values({
- vendorId: vendorId,
- baseDocumentId: vendorDocument.baseDocumentId,
- name: vendorDocument.name,
- description: vendorDocument.description,
- version: vendorDocument.version,
- reviewStatus: 'reviewing',
- createdById: userId,
- updatedById: userId,
- }).returning({ id: gtcVendorDocuments.id });
-
- if (newVendorDoc.length === 0) {
- return { success: false, error: "벤더 문서 생성에 실패했습니다." };
- }
-
- finalVendorDocumentId = newVendorDoc[0].id;
- console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`);
- }
-
- if (!finalVendorDocumentId) {
- return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." };
- }
-
- // 4. 수정 여부 확인
- const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber;
- const isCategoryModified = clauseData.category !== baseClause.category;
- const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle;
- const isContentModified = clauseData.content !== baseClause.content;
-
- const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified;
- const hasComment = !!(clauseData.comment?.trim());
-
- // 5. 벤더 조항 데이터 준비
- const vendorClauseData = {
- vendorDocumentId: finalVendorDocumentId,
- baseClauseId: baseClauseId,
- parentId: baseClause.parentId,
- depth: baseClause.depth,
- sortOrder: baseClause.sortOrder,
- fullPath: baseClause.fullPath,
-
- modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null,
- modifiedCategory: isCategoryModified ? clauseData.category : null,
- modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null,
- modifiedContent: isContentModified ? clauseData.content : null,
-
- isNumberModified,
- isCategoryModified,
- isSubtitleModified,
- isContentModified,
-
- reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft',
- negotiationNote: clauseData.comment?.trim() || null,
- editReason: clauseData.comment?.trim() || null,
-
- updatedAt: new Date(),
- updatedById: userId,
- };
-
- let finalVendorClauseId = vendorClauseId;
-
- // 6. 벤더 조항 생성 또는 업데이트
- if (vendorClauseId) {
- await db
- .update(gtcVendorClauses)
- .set(vendorClauseData)
- .where(eq(gtcVendorClauses.id, vendorClauseId));
-
- console.log(`벤더 조항 업데이트: ${vendorClauseId}`);
- } else {
- const newVendorClause = await db.insert(gtcVendorClauses).values({
- ...vendorClauseData,
- createdById: userId,
- }).returning({ id: gtcVendorClauses.id });
-
- if (newVendorClause.length === 0) {
- return { success: false, error: "벤더 조항 생성에 실패했습니다." };
- }
-
- finalVendorClauseId = newVendorClause[0].id;
- console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`);
- }
-
- // 7. 협의 이력에 기록 (코멘트가 변경된 경우만)
- if (clauseData.comment !== previousComment) {
- await db.insert(gtcNegotiationHistory).values({
- vendorClauseId: finalVendorClauseId,
- action: previousComment ? "modified" : "commented",
- comment: clauseData.comment || null,
- previousStatus: null,
- newStatus: 'reviewing',
- actorType: "vendor",
- actorId: userId,
- actorName: session.user.name,
- actorEmail: session.user.email,
- changedFields: {
- comment: {
- from: previousComment,
- to: clauseData.comment || null
- }
- }
- });
- }
-
- return {
- success: true,
- vendorClauseId: finalVendorClauseId,
- vendorDocumentId: finalVendorDocumentId
- };
-
- } catch (error) {
- console.error('벤더 조항 업데이트 실패:', error);
- return {
- success: false,
- error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
- };
- }
-}
-// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경
-export async function updateVendorClauseComment(
- clauseId: number,
- comment: string
-): Promise<{ success: boolean; error?: string }> {
- console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.');
-
- // 기본 조항 정보 가져오기
- const baseClause = await db.query.gtcClauses.findFirst({
- where: eq(gtcClauses.id, clauseId),
- });
-
- if (!baseClause) {
- return { success: false, error: "기본 조항을 찾을 수 없습니다." };
- }
-
- // 기존 벤더 조항 찾기
- const session = await getServerSession(authOptions);
- const vendorId = session?.user?.companyId;
-
- const existingVendorClause = await db.query.gtcVendorClauses.findFirst({
- where: and(
- eq(gtcVendorClauses.baseClauseId, clauseId),
- eq(gtcVendorClauses.isActive, true)
- ),
- with: {
- vendorDocument: true
- }
- });
-
- const clauseData: ClauseUpdateData = {
- itemNumber: baseClause.itemNumber,
- category: baseClause.category,
- subtitle: baseClause.subtitle,
- content: baseClause.content,
- comment: comment,
- };
-
- const result = await updateVendorClause(
- clauseId,
- existingVendorClause?.id || null,
- clauseData,
- existingVendorClause?.vendorDocument || undefined
- );
-
- return {
- success: result.success,
- error: result.error
- };
-}
-
-
-/**
- * 벤더 조항 코멘트들의 상태 체크
- */
-export async function checkVendorClausesCommentStatus(
- vendorDocumentId: number
-): Promise<{ hasComments: boolean; commentCount: number }> {
- try {
- const clausesWithComments = await db
- .select({
- id: gtcVendorClauses.id,
- negotiationNote: gtcVendorClauses.negotiationNote
- })
- .from(gtcVendorClauses)
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
- eq(gtcVendorClauses.isActive, true)
- )
- );
-
- const commentCount = clausesWithComments.filter(
- clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0
- ).length;
-
- return {
- hasComments: commentCount > 0,
- commentCount,
- };
-
- } catch (error) {
- console.error('벤더 조항 코멘트 상태 체크 실패:', error);
- return { hasComments: false, commentCount: 0 };
- }
-}
-
-/**
- * 특정 템플릿의 기본 정보를 조회하는 서버 액션
- * @param templateId - 조회할 템플릿의 ID
- * @returns 템플릿 기본 정보 또는 null
- */
-export async function getBasicContractTemplateInfo(templateId: number) {
- try {
- const templateInfo = await db
- .select({
- templateId: basicContractTemplates.id,
- templateName: basicContractTemplates.templateName,
- revision: basicContractTemplates.revision,
- status: basicContractTemplates.status,
- legalReviewRequired: basicContractTemplates.legalReviewRequired,
- validityPeriod: basicContractTemplates.validityPeriod,
- fileName: basicContractTemplates.fileName,
- filePath: basicContractTemplates.filePath,
- createdAt: basicContractTemplates.createdAt,
- updatedAt: basicContractTemplates.updatedAt,
- createdBy: basicContractTemplates.createdBy,
- updatedBy: basicContractTemplates.updatedBy,
- disposedAt: basicContractTemplates.disposedAt,
- restoredAt: basicContractTemplates.restoredAt,
- })
- .from(basicContractTemplates)
- .where(eq(basicContractTemplates.id, templateId))
- .then((res) => res[0] || null)
-
- return templateInfo
- } catch (error) {
- console.error("Error fetching template info:", error)
- return null
- }
-}
-
-
-
-/**
- * 카테고리 자동 분류 함수
- */
-function getCategoryFromTemplateName(templateName: string | null): string {
- if (!templateName) return "기타"
-
- const templateNameLower = templateName.toLowerCase()
-
- if (templateNameLower.includes("준법")) {
- return "CP"
- } else if (templateNameLower.includes("gtc")) {
- return "GTC"
- }
-
- return "기타"
-}
-
-/**
- * 법무검토 요청 서버 액션
- */
-export async function requestLegalReviewAction(
- contractIds: number[],
- reviewNote?: string
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select({
- id: basicContractView.id,
- vendorId: basicContractView.vendorId,
- vendorCode: basicContractView.vendorCode,
- vendorName: basicContractView.vendorName,
- templateName: basicContractView.templateName,
- legalReviewRequired: basicContractView.legalReviewRequired,
- legalReviewRequestedAt: basicContractView.legalReviewRequestedAt,
- })
- .from(basicContractView)
- .where(inArray(basicContractView.id, contractIds))
-
- if (contracts.length === 0) {
- return {
- success: false,
- message: "선택된 계약서를 찾을 수 없습니다."
- }
- }
-
- // 법무검토 요청 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract =>
- contract.legalReviewRequired && !contract.legalReviewRequestedAt
- )
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "법무검토 요청 가능한 계약서가 없습니다."
- }
- }
-
- const currentDate = new Date()
- const reviewer = session.user.name || session.user.email || "알 수 없음"
-
- // 트랜잭션으로 처리
- const results = await db.transaction(async (tx) => {
- const legalWorkResults = []
- const contractUpdateResults = []
-
- // 각 계약서에 대해 legalWorks 레코드 생성
- for (const contract of eligibleContracts) {
- const category = getCategoryFromTemplateName(contract.templateName)
-
- // legalWorks에 레코드 삽입
- const legalWorkResult = await tx.insert(legalWorks).values({
- basicContractId: contract.id, // 레퍼런스 ID
- category: category,
- status: "신규등록",
- vendorId: contract.vendorId,
- vendorCode: contract.vendorCode,
- vendorName: contract.vendorName || "업체명 없음",
- isUrgent: false,
- consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식
- reviewer: reviewer,
- hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정
- createdAt: currentDate,
- updatedAt: currentDate,
- }).returning({ id: legalWorks.id })
-
- legalWorkResults.push(legalWorkResult[0])
-
- // basicContract 테이블의 legalReviewRequestedAt 업데이트
- const contractUpdateResult = await tx
- .update(basicContract)
- .set({
- legalReviewRequestedAt: currentDate,
- updatedAt: currentDate,
- })
- .where(eq(basicContract.id, contract.id))
- .returning({ id: basicContract.id })
-
- contractUpdateResults.push(contractUpdateResult[0])
- }
-
- return {
- legalWorks: legalWorkResults,
- contractUpdates: contractUpdateResults
- }
- })
-
-
- console.log("법무검토 요청 완료:", {
- requestedBy: reviewer,
- contractIds: eligibleContracts.map(c => c.id),
- legalWorkIds: results.legalWorks.map(r => r.id),
- reviewNote,
- })
-
- return {
- success: true,
- message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`,
- data: {
- processedCount: eligibleContracts.length,
- totalRequested: contractIds.length,
- legalWorkIds: results.legalWorks.map(r => r.id),
- }
- }
-
- } catch (error) {
- console.error("법무검토 요청 중 오류:", error)
-
- return {
- success: false,
- message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-export async function resendContractsAction(contractIds: number[]) {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
- if (!session?.user?.id) {
- throw new Error('인증이 필요합니다.')
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select({
- id: basicContract.id,
- vendorId: basicContract.vendorId,
- fileName: basicContract.fileName,
- deadline: basicContract.deadline,
- status: basicContract.status,
- createdAt: basicContract.createdAt,
- })
- .from(basicContract)
- .where(inArray(basicContract.id, contractIds))
-
- if (contracts.length === 0) {
- throw new Error('발송할 계약서를 찾을 수 없습니다.')
- }
-
- // 각 계약서에 대해 이메일 발송
- const emailPromises = contracts.map(async (contract) => {
- // 벤더 정보 조회
- const vendor = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- country: vendors.country,
- email: vendors.email,
- })
- .from(vendors)
- .where(eq(vendors.id, contract.vendorId!))
- .limit(1)
-
- if (!vendor[0]) {
- console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`)
- return null
- }
-
- // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처)
- const contacts = await db
- .select({
- contactName: vendorContacts.contactName,
- contactEmail: vendorContacts.contactEmail,
- isPrimary: vendorContacts.isPrimary,
- })
- .from(vendorContacts)
- .where(eq(vendorContacts.vendorId, vendor[0].id))
- .orderBy(vendorContacts.isPrimary)
-
- // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일)
- const primaryContact = contacts.find(c => c.isPrimary)
- const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email
- const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName
-
- if (!recipientEmail) {
- console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`)
- return null
- }
-
- // 언어 결정 (한국 = 한글, 그 외 = 영어)
- const isKorean = vendor[0].country === 'KR'
- const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en'
- const subject = isKorean
- ? '[eVCP] 계약서 서명 요청 리마인더'
- : '[eVCP] Contract Signature Reminder'
-
- // 마감일 포맷팅
- const deadlineDate = new Date(contract.deadline)
- const formattedDeadline = isKorean
- ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일`
- : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
-
- // 남은 일수 계산
- const today = new Date()
- today.setHours(0, 0, 0, 0)
- const deadline = new Date(contract.deadline)
- deadline.setHours(0, 0, 0, 0)
- const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
-
- // 이메일 발송
- await sendEmail({
- from: session.user.email,
- to: recipientEmail,
- subject,
- template,
- context: {
- recipientName,
- vendorName: vendor[0].vendorName,
- vendorCode: vendor[0].vendorCode,
- contractFileName: contract.fileName,
- deadline: formattedDeadline,
- daysRemaining,
- senderName: session.user.name || session.user.email,
- senderEmail: session.user.email,
- // 계약서 링크 (실제 환경에 맞게 수정 필요)
- contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`,
- },
- })
-
- console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`)
- return { contractId: contract.id, email: recipientEmail }
- })
-
- const results = await Promise.allSettled(emailPromises)
-
- // 성공/실패 카운트
- const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length
- const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length
-
- if (failed > 0) {
- console.warn(`${failed}건의 이메일 발송 실패`)
- }
-
- return {
- success: true,
- message: `${successful}건의 리마인더 이메일을 발송했습니다.`,
- successful,
- failed,
- }
-
- } catch (error) {
- console.error('계약서 재발송 중 오류:', error)
- throw new Error('계약서 재발송 중 오류가 발생했습니다.')
- }
-}
-
-
-export async function processBuyerSignatureAction(
- contractId: number,
- signedFileData: ArrayBuffer,
- fileName: string
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회 및 상태 확인
- const contract = await db
- .select()
- .from(basicContractView)
- .where(eq(basicContractView.id, contractId))
- .limit(1)
-
- if (contract.length === 0) {
- return {
- success: false,
- message: "계약서를 찾을 수 없습니다."
- }
- }
-
- const contractData = contract[0]
-
- // 최종승인 가능 상태 확인
- if (contractData.completedAt !== null) {
- return {
- success: false,
- message: "이미 완료된 계약서입니다."
- }
- }
-
- if (!contractData.signedFilePath) {
- return {
- success: false,
- message: "협력업체 서명이 완료되지 않았습니다."
- }
- }
-
- if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
- return {
- success: false,
- message: "법무검토가 완료되지 않았습니다."
- }
- }
-
- // 파일 저장 로직 (기존 파일 덮어쓰기)
- const saveResult = await saveBuffer({
- buffer: signedFileData,
- fileName: fileName,
- directory: "basicContract/signed"
- });
-
- if (!saveResult.success) {
- return {
- success: false,
- message: `파일 저장 중 오류가 발생했습니다: ${saveResult.error}`
- }
- }
-
- const currentDate = new Date()
-
- // 계약서 상태 업데이트
- const updatedContract = await db
- .update(basicContract)
- .set({
- buyerSignedAt: currentDate,
- completedAt: currentDate,
- status: "COMPLETED",
- updatedAt: currentDate,
- filePath: saveResult.publicPath, // 웹 접근 가능한 경로로 업데이트
- })
- .where(eq(basicContract.id, contractId))
- .returning()
-
- // 캐시 재검증
- revalidatePath("/contracts")
-
- console.log("구매자 서명 및 최종승인 완료:", {
- contractId,
- buyerSigner: session.user.name || session.user.email,
- completedAt: currentDate,
- })
-
- return {
- success: true,
- message: "계약서 최종승인이 완료되었습니다.",
- data: {
- contractId,
- completedAt: currentDate,
- }
- }
-
- } catch (error) {
- console.error("구매자 서명 처리 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-/**
- * 일괄 최종승인 (서명 다이얼로그 호출용)
- */
-export async function prepareFinalApprovalAction(
- contractIds: number[]
-): Promise<{ success: boolean; message: string; contracts?: any[] }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select()
- .from(basicContractView)
- .where(inArray(basicContractView.id, contractIds))
-
- if (contracts.length === 0) {
- return {
- success: false,
- message: "선택된 계약서를 찾을 수 없습니다."
- }
- }
-
- // 최종승인 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract => {
- if (contract.completedAt !== null || !contract.signedFilePath) {
- return false
- }
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
- return true
- })
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "최종승인 가능한 계약서가 없습니다."
- }
- }
-
- // 서명 다이얼로그에서 사용할 수 있는 형태로 변환
- const contractsForSigning = eligibleContracts.map(contract => ({
- id: contract.id,
- templateName: contract.templateName,
- signedFilePath: contract.signedFilePath,
- signedFileName: contract.signedFileName,
- vendorName: contract.vendorName,
- vendorCode: contract.vendorCode,
- requestedByName: "구매팀", // 최종승인자 표시
- createdAt: contract.createdAt,
- // 다른 필요한 필드들...
- }))
-
- return {
- success: true,
- message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`,
- contracts: contractsForSigning
- }
-
- } catch (error) {
- console.error("최종승인 준비 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 준비 중 오류가 발생했습니다.",
- }
- }
-}
-
-/**
- * 서명 없이 승인만 처리 (간단한 승인 방식)
- */
-export async function quickFinalApprovalAction(
- contractIds: number[]
-): Promise<{ success: boolean; message: string; data?: any }> {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return {
- success: false,
- message: "로그인이 필요합니다."
- }
- }
-
- // 계약서 정보 조회
- const contracts = await db
- .select()
- .from(basicContract)
- .where(inArray(basicContract.id, contractIds))
-
- // 승인 가능한 계약서 필터링
- const eligibleContracts = contracts.filter(contract => {
- if (contract.completedAt !== null || !contract.signedFilePath) {
- return false
- }
- if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
- return false
- }
- return true
- })
-
- if (eligibleContracts.length === 0) {
- return {
- success: false,
- message: "최종승인 가능한 계약서가 없습니다."
- }
- }
-
- const currentDate = new Date()
- const approver = session.user.name || session.user.email || "알 수 없음"
-
- // 일괄 업데이트
- const updatedContracts = await db
- .update(basicContract)
- .set({
- buyerSignedAt: currentDate,
- completedAt: currentDate,
- status: "COMPLETED",
- updatedAt: currentDate,
- })
- .where(inArray(basicContract.id, eligibleContracts.map(c => c.id)))
- .returning({ id: basicContract.id })
-
- // 캐시 재검증
- revalidatePath("/contracts")
-
- console.log("일괄 최종승인 완료:", {
- approver,
- contractIds: updatedContracts.map(c => c.id),
- completedAt: currentDate,
- })
-
- return {
- success: true,
- message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`,
- data: {
- processedCount: updatedContracts.length,
- contractIds: updatedContracts.map(c => c.id),
- }
- }
-
- } catch (error) {
- console.error("일괄 최종승인 중 오류:", error)
-
- return {
- success: false,
- message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
- }
- }
-}
-
-
-export async function getVendorSignatureFile() {
- try {
- // 세션에서 사용자 정보 가져오기
- const session = await getServerSession(authOptions)
-
- if (!session?.user?.companyId) {
- throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.")
- }
-
- // 조건에 맞는 vendor attachment 찾기
- const signatureAttachment = await db.query.vendorAttachments.findFirst({
- where: and(
- eq(vendorAttachments.vendorId, session.user.companyId),
- eq(vendorAttachments.attachmentType, "SIGNATURE")
- )
- })
-
- if (!signatureAttachment) {
- return {
- success: false,
- error: "서명 파일을 찾을 수 없습니다."
- }
- }
-
- // 파일 읽기
- let filePath: string;
- const nasPath = process.env.NAS_PATH || "/evcp_nas"
-
-
- if (process.env.NODE_ENV === 'production') {
- // ✅ 프로덕션: NAS 경로 사용
- filePath = path.join(nasPath, signatureAttachment.filePath);
-
- } else {
- // 개발: public 폴더
- filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath);
- }
-
- const fileBuffer = await readFile(filePath)
-
- // Base64로 인코딩
- const base64File = fileBuffer.toString('base64')
-
- return {
- success: true,
- data: {
- id: signatureAttachment.id,
- fileName: signatureAttachment.fileName,
- fileType: signatureAttachment.fileType,
- base64: base64File,
- // 웹에서 사용할 수 있는 data URL 형식도 제공
- dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}`
- }
- }
-
- } catch (error) {
- console.error("서명 파일 조회 중 오류:", error)
- console.log("서명 파일 조회 중 오류:", error)
-
- return {
- success: false,
- error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다."
- }
- }
-}
-
-
-
-
-// templateName에서 project code 추출
-function extractProjectCodeFromTemplateName(templateName: string): string | null {
- if (!templateName.includes('GTC')) return null;
- if (templateName.toLowerCase().includes('general')) return null;
-
- // GTC 앞의 문자열을 추출
- const gtcIndex = templateName.indexOf('GTC');
- if (gtcIndex > 0) {
- const beforeGTC = templateName.substring(0, gtcIndex).trim();
- // 마지막 단어를 project code로 간주
- const words = beforeGTC.split(/\s+/);
- return words[words.length - 1];
- }
-
- return null;
-}
-
-// 단일 contract에 대한 GTC 정보 확인
-async function checkGTCCommentsForContract(
- templateName: string,
- vendorId: number,
- basicContractId?: number
-): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> {
- try {
- const projectCode = extractProjectCodeFromTemplateName(templateName);
- let gtcDocumentId: number | null = null;
-
- console.log(projectCode,"projectCode")
-
- // 1. GTC Document ID 찾기
- if (projectCode && projectCode.trim() !== '') {
- // Project GTC인 경우
- const project = await db
- .select({ id: projects.id })
- .from(projects)
- .where(eq(projects.code, projectCode.trim()))
- .limit(1)
-
- if (project.length > 0) {
- const projectGtcDoc = await db
- .select({ id: gtcDocuments.id })
- .from(gtcDocuments)
- .where(
- and(
- eq(gtcDocuments.projectId, project[0].id),
- eq(gtcDocuments.isActive, true)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
- .limit(1)
-
- if (projectGtcDoc.length > 0) {
- gtcDocumentId = projectGtcDoc[0].id
- }
- }
- } else {
- // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우)
- console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`);
- const standardGtcDoc = await db
- .select({ id: gtcDocuments.id })
- .from(gtcDocuments)
- .where(
- and(
- eq(gtcDocuments.type, "standard"),
- eq(gtcDocuments.isActive, true),
- isNull(gtcDocuments.projectId)
- )
- )
- .orderBy(desc(gtcDocuments.revision))
- .limit(1)
-
- console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`);
-
- if (standardGtcDoc.length > 0) {
- gtcDocumentId = standardGtcDoc[0].id
- console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`);
- } else {
- console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`);
- }
- }
-
- console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`)
-
- // GTC Document를 찾지 못한 경우
- if (basicContractId) {
- console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`);
- const { agreementComments } = await import("@/db/schema");
- const newComments = await db
- .select({ id: agreementComments.id })
- .from(agreementComments)
- .where(
- and(
- eq(agreementComments.basicContractId, basicContractId),
- eq(agreementComments.isDeleted, false)
- )
- )
- .limit(1);
-
- console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`);
-
- if (newComments.length > 0) {
- console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`);
- return {
- gtcDocumentId, // null일 수 있음
- hasComments: true
- };
- }
-
- console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`);
- }
-
- // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음)
- if (!gtcDocumentId) {
- console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`);
- return { gtcDocumentId: null, hasComments: false };
- }
-
- // 2. 코멘트 존재 여부 확인
- // gtcDocumentId로 해당 벤더의 vendor documents 찾기
- const vendorDocuments = await db
- .select({ id: gtcVendorDocuments.id })
- .from(gtcVendorDocuments)
- .where(
- and(
- eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId),
- eq(gtcVendorDocuments.vendorId, vendorId),
- eq(gtcVendorDocuments.isActive, true)
- )
- )
- .limit(1)
-
- if (vendorDocuments.length === 0) {
- return { gtcDocumentId, hasComments: false };
- }
-
- // vendor document에 연결된 clauses에서 negotiation history 확인
- const commentsExist = await db
- .select({ count: gtcNegotiationHistory.id })
- .from(gtcNegotiationHistory)
- .innerJoin(
- gtcVendorClauses,
- eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id)
- )
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id),
- eq(gtcVendorClauses.isActive, true),
- isNotNull(gtcNegotiationHistory.comment),
- ne(gtcNegotiationHistory.comment, '')
- )
- )
- .limit(1)
-
- return {
- gtcDocumentId,
- hasComments: commentsExist.length > 0
- };
-
- } catch (error) {
- console.error('Error checking GTC comments for contract:', error);
- return { gtcDocumentId: null, hasComments: false };
- }
-}
-
-// // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
-// export async function checkGTCCommentsForContracts(
-// contracts: BasicContractView[]
-// ): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
-// const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
-
-// // GTC가 포함된 contract만 필터링
-// const gtcContracts = contracts.filter(contract =>
-// contract.templateName?.includes('GTC')
-// );
-
-// if (gtcContracts.length === 0) {
-// return gtcData;
-// }
-
-// // Promise.all을 사용해서 병렬 처리
-// const checkPromises = gtcContracts.map(async (contract) => {
-// try {
-// const result = await checkGTCCommentsForContract(
-// contract.templateName!,
-// contract.vendorId!
-// );
-
-// return {
-// contractId: contract.id,
-// gtcDocumentId: result.gtcDocumentId,
-// hasComments: result.hasComments
-// };
-// } catch (error) {
-// console.error(`Error checking GTC for contract ${contract.id}:`, error);
-// return {
-// contractId: contract.id,
-// gtcDocumentId: null,
-// hasComments: false
-// };
-// }
-// });
-
-// const results = await Promise.all(checkPromises);
-
-// // 결과를 Record 형태로 변환
-// results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
-// gtcData[contractId] = { gtcDocumentId, hasComments };
-// });
-
-// return gtcData;
-// }
-
-
-
-export async function updateVendorDocumentStatus(
- formData: FormData | {
- status: string;
- vendorDocumentId: number;
- documentId: number;
- vendorId: number;
- }
-) {
- try {
- // 세션 확인
- const session = await getServerSession(authOptions)
- if (!session?.user) {
- return { success: false, error: "인증되지 않은 사용자입니다." }
- }
-
- // 데이터 파싱
- const rawData = formData instanceof FormData
- ? {
- status: formData.get("status") as string,
- vendorDocumentId: Number(formData.get("vendorDocumentId")),
- documentId: Number(formData.get("documentId")),
- vendorId: Number(formData.get("vendorId")),
- }
- : formData
-
- // 유효성 검사
- const validatedData = updateStatusSchema.safeParse(rawData)
- if (!validatedData.success) {
- return { success: false, error: "유효하지 않은 데이터입니다." }
- }
-
- const { status, vendorDocumentId, documentId, vendorId } = validatedData.data
-
- // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인
- if (status === "complete") {
- // 승인되지 않은 조항 확인
- const pendingClauses = await db
- .select({ id: gtcVendorClauses.id })
- .from(gtcVendorClauses)
- .where(
- and(
- eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
- eq(gtcVendorClauses.isActive, true),
- not(eq(gtcVendorClauses.reviewStatus, "approved")),
- not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외
- )
- )
- .limit(1)
-
- if (pendingClauses.length > 0) {
- return {
- success: false,
- error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다."
- }
- }
- }
-
- // 업데이트 실행
- await db
- .update(gtcVendorDocuments)
- .set({
- reviewStatus: status,
- updatedAt: new Date(),
- updatedById: Number(session.user.id),
- // 완료 처리 시 협의 종료일 설정
- ...(status === "complete" ? {
- negotiationEndDate: new Date(),
- approvalDate: new Date()
- } : {})
- })
- .where(eq(gtcVendorDocuments.id, vendorDocumentId))
-
- // 캐시 무효화
- // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`)
-
- return { success: true }
- } catch (error) {
- console.error("Error updating vendor document status:", error)
- return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
- }
-}
-
-
-interface SaveDocumentParams {
- documentId: number
- pdfBuffer: Uint8Array
- originalFileName: string
- vendor: {
- vendorName: string
- }
- userId: number
-}
-
-export async function saveGtcDocumentAction({
- documentId,
- pdfBuffer,
- originalFileName,
- vendor
-}: SaveDocumentParams) {
- try {
- console.log("📄 GTC 문서 저장 시작:", {
- documentId,
- documentIdType: typeof documentId,
- vendorName: vendor.vendorName,
- originalFileName,
- bufferSize: pdfBuffer.length
- })
-
- // documentId 유효성 검사
- if (!documentId || isNaN(Number(documentId))) {
- throw new Error(`유효하지 않은 문서 ID: ${documentId}`)
- }
-
- // 기본계약 존재 여부 확인
- const existingContract = await db.query.basicContract.findFirst({
- where: eq(basicContract.id, documentId),
- })
-
- if (!existingContract) {
- throw new Error(`기본계약을 찾을 수 없습니다. ID: ${documentId}`)
- }
-
- console.log("📋 기존 계약 정보:", {
- contractId: existingContract.id,
- templateId: existingContract.templateId,
- vendorId: existingContract.vendorId,
- currentStatus: existingContract.status,
- currentFileName: existingContract.fileName,
- currentFilePath: existingContract.filePath
- })
-
- const session = await getServerSession(authOptions)
-
- if (!session?.user) {
- return { success: false, error: "인증되지 않은 사용자입니다." }
- }
- const userId = Number(session.user.id);
-
- // 1. PDF 파일명 생성
- const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거
- const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf`
-
- // 2. 파일 저장 (공용 파일 저장 함수 사용)
- const saveResult = await saveBuffer({
- buffer: Buffer.from(pdfBuffer),
- fileName,
- directory: 'basicContract',
- originalName: fileName,
- userId: userId.toString()
- })
-
- if (!saveResult.success) {
- throw new Error(saveResult.error || '파일 저장 실패')
- }
-
- // 3. 데이터베이스 업데이트 - 트랜잭션으로 처리하고 결과 확인
- const updateResult = await db.update(basicContract)
- .set({
- fileName: saveResult.fileName!,
- filePath: saveResult.publicPath!,
- status: 'PENDING',
- // 기존 서명 관련 timestamp들 리셋
- vendorSignedAt: null,
- buyerSignedAt: null,
- legalReviewRequestedAt: null,
- legalReviewCompletedAt: null,
- updatedAt: new Date()
- })
- .where(eq(basicContract.id, documentId))
- .returning({
- id: basicContract.id,
- fileName: basicContract.fileName,
- filePath: basicContract.filePath
- })
-
- // DB 업데이트 성공 여부 확인
- if (!updateResult || updateResult.length === 0) {
- throw new Error(`기본계약 ID ${documentId}를 찾을 수 없거나 업데이트에 실패했습니다.`)
- }
-
- console.log("✅ GTC 문서 저장 완료:", {
- documentId,
- updatedRecord: updateResult[0],
- fileName: saveResult.fileName,
- filePath: saveResult.publicPath,
- fileSize: saveResult.fileSize
- })
-
- // 캐시 무효화
- revalidateTag("basic-contract-requests")
- revalidateTag("basic-contracts")
- revalidatePath("/partners/basic-contract")
-
- return {
- success: true,
- fileName: saveResult.fileName,
- filePath: saveResult.publicPath,
- fileSize: saveResult.fileSize,
- documentId: updateResult[0].id
- }
-
- } catch (error) {
- console.error("❌ GTC 문서 저장 실패:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다'
- }
- }
+"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { getErrorMessage } from "@/lib/handle-error";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count,like } from "drizzle-orm";
+import { v4 as uuidv4 } from "uuid";
+import {
+ basicContract,
+ BasicContractTemplate,
+ basicContractTemplates,
+ basicContractView,
+ complianceQuestionOptions,
+ complianceQuestions,
+ complianceResponseAnswers,
+ complianceResponseFiles,
+ complianceResponses,
+ complianceSurveyTemplates,
+ vendorAttachments, basicContractTemplateStatsView,
+ type BasicContractTemplate as DBBasicContractTemplate,
+ type NewComplianceResponse,
+ type NewComplianceResponseAnswer,
+ type NewComplianceResponseFile,
+ gtcVendorDocuments,
+ gtcVendorClauses,
+ gtcClauses,
+ gtcDocuments,
+ vendors,
+ vendorContacts,
+ gtcNegotiationHistory,
+ type GtcVendorClause,
+ type GtcClause,
+ projects,
+ legalWorks,
+ BasicContractView, users
+} from "@/db/schema";
+import path from "path";
+
+import {
+ GetBasicContractTemplatesSchema,
+ CreateBasicContractTemplateSchema,
+ GetBasciContractsSchema,
+ GetBasciContractsVendorSchema,
+ GetBasciContractsByIdSchema,
+ updateStatusSchema,
+} from "./validations";
+import { readFile } from "fs/promises"
+
+import {
+ insertBasicContractTemplate,
+ selectBasicContractTemplates,
+ countBasicContractTemplates,
+ deleteBasicContractTemplates,
+ getBasicContractTemplateById,
+ selectBasicContracts,
+ countBasicContracts,
+ findAllTemplates,
+ countBasicContractsById,
+ selectBasicContractsById,
+ selectBasicContractsVendor,
+ countBasicContractsVendor
+} from "./repository";
+import { revalidatePath } from 'next/cache';
+import { sendEmail } from "../mail/sendEmail";
+import { headers } from 'next/headers';
+import { filterColumns } from "@/lib/filter-columns";
+import { differenceInDays, addYears, isBefore } from "date-fns";
+import { deleteFile, saveBuffer, saveFile, saveDRMFile } from "@/lib/file-stroage";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { getServerSession } from "next-auth/next"
+import { authOptions } from "@/app/api/auth/[...nextauth]/route"
+
+
+// 템플릿 추가
+export async function addTemplate(
+ templateData: FormData | Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> {
+ try {
+ // FormData인 경우 파일 추출 및 저장 처리
+ if (templateData instanceof FormData) {
+ const templateName = templateData.get("templateName") as string;
+ // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환)
+ const status = templateData.get("status") as "ACTIVE" | "INACTIVE";
+ const file = templateData.get("file") as File;
+
+ // 유효성 검사
+ if (!templateName) {
+ return { success: false, error: "템플릿 이름은 필수입니다." };
+ }
+
+ if (!file) {
+ return { success: false, error: "파일은 필수입니다." };
+ }
+
+ const saveResult = await saveFile({file, directory:"basicContract/template" });
+
+ if (!saveResult.success) {
+ return { success: false, error: saveResult.error };
+ }
+
+ // DB에 저장할 데이터 구성
+ const formattedData = {
+ templateName,
+ status,
+ fileName: file.name,
+ filePath: saveResult.publicPath!
+ };
+
+ // DB에 저장
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ // DB 저장 실패 시 파일 삭제
+ await deleteFile(saveResult.publicPath!);
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ // 기존 객체 형태인 경우 (호환성 유지)
+ else {
+ const formattedData = {
+ ...templateData,
+ status: templateData.status as "ACTIVE" | "INACTIVE",
+ // validityPeriod가 없으면 기본값 12개월 사용
+ validityPeriod: templateData.validityPeriod || 12,
+ };
+
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ } catch (error) {
+ console.error("Template add error:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."
+ };
+ }
+}
+// 기본 계약서 템플릿 목록 조회 (서버 액션)
+export async function getBasicContractTemplates(
+ input: GetBasicContractTemplatesSchema
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const { data, total } = await db.transaction(async (tx) => {
+ const advancedWhere = filterColumns({
+ table: basicContractTemplates,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractTemplates.templateName, s),
+ ilike(basicContractTemplates.fileName, s),
+ ilike(basicContractTemplates.status, s)
+ );
+ }
+
+ const whereCondition = and(advancedWhere, globalWhere);
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ : asc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ )
+ : [desc(basicContractTemplates.createdAt)];
+
+ const dataResult = await selectBasicContractTemplates(tx, {
+ where: whereCondition,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+
+ const totalCount = await countBasicContractTemplates(
+ tx,
+ whereCondition
+ );
+ return { data: dataResult, total: totalCount };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (error) {
+ console.error("getBasicContractTemplates 에러:", error);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["basic-contract-templates"],
+ }
+ )();
+}
+
+
+// 템플릿 생성 (서버 액션)
+export async function createBasicContractTemplate(input: CreateBasicContractTemplateSchema) {
+ unstable_noStore();
+
+ try {
+ const newTemplate = await db.transaction(async (tx) => {
+ const [row] = await insertBasicContractTemplate(tx, {
+ templateName: input.templateName,
+ revision: input.revision || 1,
+ status: input.status || "ACTIVE",
+
+ // 📝 null 처리 추가
+ fileName: input.fileName || null,
+ filePath: input.filePath || null,
+ });
+ return row;
+ });
+
+ return { data: newTemplate, error: null };
+ } catch (error) {
+ console.log(error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+//서명 계약서 저장, 김기만 프로님 추가 코드
+export const saveSignedContract = async (
+ fileBuffer: ArrayBuffer,
+ templateName: string,
+ tableRowId: number
+): Promise<{ result: true } | { result: false; error: string }> => {
+ try {
+ const originalName = `${tableRowId}_${templateName}`;
+
+ // ArrayBuffer를 File 객체로 변환
+ const file = new File([fileBuffer], originalName);
+
+ // ✅ 서명된 계약서 저장
+ // 개발: /project/public/basicContract/signed/
+ // 프로덕션: /nas_evcp/basicContract/signed/
+ const saveResult = await saveFile({file,directory: "basicContract/signed" ,originalName:originalName});
+
+ if (!saveResult.success) {
+ return { result: false, error: saveResult.error! };
+ }
+
+ console.log(`✅ 서명된 계약서 저장됨: ${saveResult.filePath}`);
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContract)
+ .set({
+ status: "COMPLETED",
+ fileName: originalName,
+ filePath: saveResult.publicPath, // 웹 접근 경로 저장
+ })
+ .where(eq(basicContract.id, tableRowId));
+ });
+ // 캐시 무효화
+ revalidateTag("basic-contract-requests");
+ revalidateTag("template-status-counts");
+
+ return { result: true };
+ } catch (err: unknown) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
+ return { result: false, error: errorMessage };
+ }
+};
+
+interface RemoveTemplatesProps {
+ ids: number[];
+}
+
+
+interface TemplateFile {
+ id: number;
+ filePath: string;
+}
+
+export async function removeTemplates({
+ ids
+}: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> {
+ if (!ids || ids.length === 0) {
+ return { error: "삭제할 템플릿이 선택되지 않았습니다." };
+ }
+
+ // unstable_noStore를 최상단에 배치
+ unstable_noStore();
+
+ try {
+ // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리
+ // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지)
+ const templateFiles: TemplateFile[] = [];
+
+ const result = await db.transaction(async (tx) => {
+ // 각 템플릿의 파일 경로 가져오기
+ for (const id of ids) {
+ const { data: template, error } = await getBasicContractTemplateById(tx, id);
+ if (template && template.filePath) {
+ templateFiles.push({
+ id: template.id,
+ filePath: template.filePath
+ });
+ }
+ }
+
+ // DB에서 템플릿 삭제
+ const { data, error } = await deleteBasicContractTemplates(tx, ids);
+
+ if (error) {
+ throw new Error(`템플릿 DB 삭제 실패: ${error}`);
+ }
+
+ return { data };
+ });
+
+ // 파일 시스템 삭제는 트랜잭션 성공 후 수행
+ for (const template of templateFiles) {
+ const deleted = await deleteFile(template.filePath);
+
+ if (deleted) {
+ console.log(`✅ 파일 삭제됨: ${template.filePath}`);
+ } else {
+ console.log(`⚠️ 파일 삭제 실패: ${template.filePath}`);
+ }
+ }
+
+
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+
+
+
+ // 디버깅을 위한 로그
+ console.log("캐시 무효화 완료:", ids);
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+interface UpdateTemplateParams {
+ id: number;
+ formData: FormData;
+}
+
+const SCOPE_KEYS = [
+ "shipBuildingApplicable",
+ "windApplicable",
+ "pcApplicable",
+ "nbApplicable",
+ "rcApplicable",
+ "gyApplicable",
+ "sysApplicable",
+ "infraApplicable",
+] as const;
+
+function getBool(fd: FormData, key: string, defaultValue = false) {
+ const v = fd.get(key);
+ if (v === null) return defaultValue;
+ return v === "true";
+}
+
+export async function updateTemplate({
+ id,
+ formData
+}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ try {
+ // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해)
+ const existingTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, id),
+ });
+
+ if (!existingTemplate) {
+ return { error: "템플릿을 찾을 수 없습니다." };
+ }
+
+ // 필수값
+ const templateName = formData.get("templateName") as string | null;
+ if (!templateName) {
+ return { error: "템플릿 이름은 필수입니다." };
+ }
+
+ // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지
+ const revisionStr = formData.get("revision")?.toString();
+ const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision;
+
+ // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인
+ if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) {
+ const duplicateCheck = await db.query.basicContractTemplates.findFirst({
+ where: and(
+ eq(basicContractTemplates.templateName, templateName),
+ eq(basicContractTemplates.revision, revision),
+ ne(basicContractTemplates.id, id) // 자기 자신은 제외
+ ),
+ });
+
+ if (duplicateCheck) {
+ return {
+ error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.`
+ };
+ }
+ }
+
+ const legalReviewRequired = getBool(formData, "legalReviewRequired", false);
+
+ // status는 프런트에서 ACTIVE만 넣고 있으나, 없으면 기존값 유지 or 기본값 설정
+ const status = (formData.get("status") as "ACTIVE" | "INACTIVE" | null) ?? "ACTIVE";
+
+ // validityPeriod가 이제 필요없다면 제거하시고, 사용한다면 파싱 그대로
+ const validityPeriodStr = formData.get("validityPeriod")?.toString();
+ const validityPeriod = validityPeriodStr ? Number(validityPeriodStr) : undefined;
+
+ // Scope booleans
+ const scopeData: Record<string, boolean> = {};
+ for (const key of SCOPE_KEYS) {
+ scopeData[key] = getBool(formData, key, false);
+ }
+
+ // 파일 처리
+ const file = formData.get("file") as File | null;
+ let fileName: string | undefined = undefined;
+ let filePath: string | undefined = undefined;
+
+ if (file) {
+ // 1) 새 파일 저장 (DRM 해제 로직 적용)
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ 'basicContract/template'
+ );
+
+ if (!saveResult.success) {
+ return { success: false, error: saveResult.error };
+ }
+ fileName = file.name;
+ filePath = saveResult.publicPath;
+
+ // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨)
+ if (existingTemplate?.filePath) {
+ const deleted = await deleteFile(existingTemplate.filePath);
+ if (deleted) {
+ console.log(`✅ 기존 파일 삭제됨: ${existingTemplate.filePath}`);
+ } else {
+ console.log(`⚠️ 기존 파일 삭제 실패: ${existingTemplate.filePath}`);
+ }
+ }
+ }
+
+ // 업데이트할 데이터 구성
+ const updateData: Record<string, any> = {
+ templateName,
+ revision,
+ legalReviewRequired,
+ status,
+ updatedAt: new Date(),
+ ...scopeData,
+ };
+
+ if (validityPeriod !== undefined) {
+ updateData.validityPeriod = validityPeriod;
+ }
+ if (fileName && filePath) {
+ updateData.fileName = fileName;
+ updateData.filePath = filePath;
+ }
+
+ // DB 업데이트
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContractTemplates)
+ .set(updateData)
+ .where(eq(basicContractTemplates.id, id));
+ });
+
+ // 캐시 무효화
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+ revalidateTag("templates");
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 업데이트 오류:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+interface RequestBasicContractInfoProps {
+ vendorIds: number[];
+ requestedBy: number;
+ templateId: number;
+}
+
+
+export async function requestBasicContractInfo({
+ vendorIds,
+ requestedBy,
+ templateId
+}: 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. 협력업체 정보 가져오기
+ 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 {
+ const isComplianceTemplate = template.templateName?.includes('준법');
+ let selectedTemplateId = template.id;
+ let selectedTemplate = template;
+
+ if (isComplianceTemplate) {
+ const vendorUser = await db.query.users.findFirst({
+ where: and(
+ eq(users.email, vendor.email),
+ eq(users.domain, 'partners')
+ )
+ });
+
+ const userLanguage = vendorUser?.language || 'en'; // 기본값은 영어
+
+ if (userLanguage === 'ko') {
+ // 한글 준법서약 템플릿 찾기
+ const koreanTemplate = await db.query.basicContractTemplates.findFirst({
+ where: and(
+ sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
+ sql`${basicContractTemplates.templateName} NOT LIKE '%영문%'`,
+ eq(basicContractTemplates.status, 'ACTIVE')
+ )
+ });
+
+ if (koreanTemplate) {
+ selectedTemplateId = koreanTemplate.id;
+ selectedTemplate = koreanTemplate;
+ }
+ } else {
+ // 영문 준법서약 템플릿 찾기
+ const englishTemplate = await db.query.basicContractTemplates.findFirst({
+ where: and(
+ sql`${basicContractTemplates.templateName} LIKE '%준법%'`,
+ sql`${basicContractTemplates.templateName} LIKE '%영문%'`,
+ eq(basicContractTemplates.status, 'ACTIVE')
+ )
+ });
+
+ if (englishTemplate) {
+ selectedTemplateId = englishTemplate.id;
+ selectedTemplate = englishTemplate;
+ console.log(`✅ 영어 사용자 ${vendor.vendorName}에게 영문 준법서약 템플릿 전송`);
+ }
+ }
+ }
+
+ // 3-1. basic_contract 테이블에 레코드 추가
+ const [newContract] = await db
+ .insert(basicContract)
+ .values({
+ templateId: selectedTemplateId, // 언어별로 선택된 템플릿 ID 사용
+ vendorId: vendor.id,
+ requestedBy: requestedBy,
+ status: "PENDING",
+ fileName: selectedTemplate.fileName, // 선택된 템플릿 파일 이름 사용
+ filePath: selectedTemplate.filePath, // 선택된 템플릿 파일 경로 사용
+ })
+ .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
+ : "기본계약서 요청 처리 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+export async function getBasicContracts(input: GetBasciContractsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractTemplateStatsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(basicContractTemplateStatsView.templateName, s),
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractTemplateStatsView[item.id]) : asc(basicContractTemplateStatsView[item.id])
+ )
+ : [asc(basicContractTemplateStatsView.lastActivityDate)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContracts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContracts(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.log(err)
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["basicContractTemplateStatsView"], // revalidateTag("basicContractTemplateStatsView") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getBasicContractsByVendorId(
+ input: GetBasciContractsVendorSchema,
+ vendorId: number
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s),
+ ilike(basicContractView.vendorCode, s),
+ ilike(basicContractView.vendorEmail, s),
+ ilike(basicContractView.status, s)
+ );
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ // 벤더 ID 필터링 조건 추가
+ const vendorCondition = eq(basicContractView.vendorId, vendorId);
+
+ const finalWhere = and(
+ // 항상 벤더 ID 조건을 포함
+ vendorCondition,
+ // 기존 조건들
+ advancedWhere,
+ globalWhere
+ );
+
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContractsVendor(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContractsVendor(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ {
+ revalidate: 3600,
+ tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getBasicContractsByTemplateId(
+ input: GetBasciContractsByIdSchema,
+ templateId: number
+) {
+ // return unstable_cache(
+ // async () => {
+ try {
+
+ console.log(input.sort)
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s),
+ ilike(basicContractView.vendorCode, s),
+ ilike(basicContractView.vendorEmail, s),
+ ilike(basicContractView.status, s)
+ );
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ // 벤더 ID 필터링 조건 추가
+ const templateCondition = eq(basicContractView.templateId, templateId);
+
+ const finalWhere = and(
+ // 항상 벤더 ID 조건을 포함
+ templateCondition,
+ // 기존 조건들
+ advancedWhere,
+ globalWhere
+ );
+
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContractsById(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContractsById(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트\
+ console.log(err)
+ return { data: [], pageCount: 0 };
+ }
+ // },
+ // [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ // {
+ // revalidate: 3600,
+ // tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ // }
+ // )();
+}
+
+export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
+ try {
+ return await findAllTemplates();
+ } catch (err) {
+ throw new Error("Failed to get templates");
+ }
+}
+
+
+interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상)
+ isUpdated: boolean; // 템플릿이 업데이트되었는지
+}
+
+/**
+ * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다.
+ */
+// 계약 상태 확인 API 함수
+export async function checkContractRequestStatus(
+ vendorIds: number[],
+ templateIds: number[]
+) {
+ try {
+ // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인
+ const requests = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ updatedAt: basicContract.updatedAt,
+ // completedAt 필드 추가 필요
+ completedAt: basicContract.completedAt, // 계약 완료 날짜
+ })
+ .from(basicContract)
+ .where(
+ and(
+ inArray(basicContract.vendorId, vendorIds),
+ inArray(basicContract.templateId, templateIds)
+ )
+ )
+ .orderBy(desc(basicContract.createdAt));
+
+ // 협력업체 정보 가져오기
+ const vendorData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ // 템플릿 정보 가져오기
+ const templateData = await db
+ .select({
+ id: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ updatedAt: basicContractTemplates.updatedAt,
+ validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월)
+ })
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, templateIds));
+
+ // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑
+ const vendorMap = new Map(vendorData.map(v => [v.id, v]));
+ const templateMap = new Map(templateData.map(t => [t.id, t]));
+
+ const uniqueRequests = new Map();
+
+ // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용
+ requests.forEach(req => {
+ const key = `${req.vendorId}-${req.templateId}`;
+ if (!uniqueRequests.has(key)) {
+ uniqueRequests.set(key, req);
+ }
+ });
+
+ // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의
+ interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ completedAt?: Date;
+ isExpired: boolean;
+ isUpdated: boolean;
+ isContractExpired: boolean;
+ }
+
+ // 명시적 타입 지정
+ const statusData: VendorTemplateStatus[] = [];
+
+ // 요청 만료 기준 - 30일
+ const REQUEST_EXPIRATION_DAYS = 30;
+
+ // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능)
+ const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12;
+
+ const now = new Date();
+
+ // 모든 협력업체-템플릿 조합에 대해 상태 확인
+ vendorIds.forEach(vendorId => {
+ templateIds.forEach(templateId => {
+ const key = `${vendorId}-${templateId}`;
+ const request = uniqueRequests.get(key);
+ const vendor = vendorMap.get(vendorId);
+ const template = templateMap.get(templateId);
+
+ if (!vendor || !template) return;
+
+ let status = "NONE"; // 기본 상태: 요청 없음
+ let createdAt = new Date();
+ let completedAt = null;
+ let isExpired = false;
+ let isUpdated = false;
+ let isContractExpired = false;
+
+ if (request) {
+ status = request.status;
+ createdAt = request.createdAt;
+ completedAt = request.completedAt;
+
+ // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용)
+ if (status === "PENDING") {
+ isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS;
+ }
+
+ // 요청 이후 템플릿이 업데이트되었는지 확인
+ if (template.updatedAt && request.createdAt) {
+ isUpdated = template.updatedAt > request.createdAt;
+ }
+
+ // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우)
+ if (status === "COMPLETED" && completedAt) {
+ // 템플릿별 유효기간 또는 기본값 사용
+ const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS;
+
+ // 계약 만료일 계산 (완료일 + 유효기간)
+ const expiryDate = addYears(completedAt, validityMonths / 12);
+
+ // 현재 날짜가 만료일 이후인지 확인
+ isContractExpired = isBefore(expiryDate, now);
+ }
+ }
+
+ statusData.push({
+ vendorId,
+ vendorName: vendor.vendorName,
+ templateId,
+ templateName: template.templateName,
+ status,
+ createdAt,
+ completedAt,
+ isExpired,
+ isUpdated,
+ isContractExpired,
+ });
+ });
+ });
+
+ return { data: statusData };
+ } catch (error) {
+ console.error("계약 상태 확인 중 오류:", error);
+ return {
+ data: [],
+ error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+/**
+ * ID로 기본계약서 템플릿 조회
+ */
+export async function getBasicContractTemplateByIdService(id: string) {
+ try {
+ const templateId = parseInt(id);
+
+ if (isNaN(templateId)) {
+ return null;
+ }
+
+ const templates = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, templateId))
+ .limit(1);
+
+ if (templates.length === 0) {
+ return null;
+ }
+
+ return templates[0];
+ } catch (error) {
+ console.error("템플릿 조회 오류:", error);
+ return null;
+ }
+}
+
+/**
+ * 템플릿 파일 저장 서버 액션
+ */
+export async function saveTemplateFile(templateId: number, formData: FormData) {
+ try {
+ const file = formData.get("file") as File;
+
+ if (!file) {
+ return { error: "파일이 필요합니다." };
+ }
+
+ // 기존 템플릿 정보 조회
+ const existingTemplate = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, templateId))
+ .limit(1);
+
+ if (existingTemplate.length === 0) {
+ return { error: "템플릿을 찾을 수 없습니다." };
+ }
+
+ const template = existingTemplate[0];
+
+ // 파일 저장 로직 (실제 파일 시스템에 저장)
+ const { writeFile, mkdir } = await import("fs/promises");
+ const { join } = await import("path");
+
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // 기존 파일 경로 사용 (덮어쓰기)
+ const uploadPath = join(process.cwd(), "public", template.filePath.replace(/^\//, ""));
+
+ // 디렉토리 확인 및 생성
+ const dirPath = uploadPath.substring(0, uploadPath.lastIndexOf('/'));
+ await mkdir(dirPath, { recursive: true });
+
+ // 파일 저장
+ await writeFile(uploadPath, buffer);
+
+ // 데이터베이스 업데이트 (수정일시만 업데이트)
+ await db
+ .update(basicContractTemplates)
+ .set({
+ updatedAt: new Date(),
+ })
+ .where(eq(basicContractTemplates.id, templateId));
+
+ // 캐시 무효화
+ revalidatePath(`/evcp/basic-contract-template/${templateId}`);
+ revalidateTag("basic-contract-templates");
+
+ return { success: true, message: "템플릿이 성공적으로 저장되었습니다." };
+
+ } catch (error) {
+ console.error("템플릿 저장 오류:", error);
+ return { error: "저장 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 템플릿 페이지 새로고침 서버 액션
+ */
+export async function refreshTemplatePage(templateId: string) {
+ revalidatePath(`/evcp/basic-contract-template/${templateId}`);
+ revalidateTag("basic-contract-templates");
+}
+
+// 새 리비전 생성 함수
+export async function createBasicContractTemplateRevision(input: any) {
+ unstable_noStore();
+
+ try {
+ // 기본 템플릿 존재 확인
+ const baseTemplate = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, input.baseTemplateId))
+ .limit(1);
+
+ if (baseTemplate.length === 0) {
+ return { data: null, error: "기본 템플릿을 찾을 수 없습니다." };
+ }
+
+ // 같은 템플릿 이름에 해당 리비전이 이미 존재하는지 확인
+ const existingRevision = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ eq(basicContractTemplates.templateName, input.templateName),
+ eq(basicContractTemplates.revision, input.revision)
+ )
+ )
+ .limit(1);
+
+ if (existingRevision.length > 0) {
+ return {
+ data: null,
+ error: `${input.templateName} v${input.revision} 리비전이 이미 존재합니다.`
+ };
+ }
+
+ // 새 리비전이 기존 리비전들보다 큰 번호인지 확인
+ const maxRevision = await db
+ .select({ maxRev: basicContractTemplates.revision })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.templateName, input.templateName))
+ .orderBy(desc(basicContractTemplates.revision))
+ .limit(1);
+
+ if (maxRevision.length > 0 && input.revision <= maxRevision[0].maxRev) {
+ return {
+ data: null,
+ error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision[0].maxRev})보다 커야 합니다.`
+ };
+ }
+
+ const newRevision = await db.transaction(async (tx) => {
+ const [row] = await insertBasicContractTemplate(tx, {
+ templateName: input.templateName,
+ revision: input.revision,
+ legalReviewRequired: input.legalReviewRequired,
+ status: "ACTIVE",
+ fileName: input.fileName,
+ filePath: input.filePath,
+ validityPeriod: null,
+ });
+ return row;
+ });
+ //기존 템플릿의 이전 리비전은 비활성으로 변경
+ await db.update(basicContractTemplates).set({
+ status: "DISPOSED",
+ }).where(and(eq(basicContractTemplates.templateName, input.templateName),ne(basicContractTemplates.revision, input.revision)));
+ //캐시 무효화
+ revalidateTag("basic-contract-templates");
+
+ return { data: newRevision, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+
+
+
+// 1) 전체 basicContractTemplates 조회
+export async function getALLBasicContractTemplates() {
+ return db
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.status,"ACTIVE"))
+ .orderBy(desc(basicContractTemplates.createdAt));
+}
+
+// 2) 등록된 templateName만 중복 없이 가져오기
+export async function getExistingTemplateNames(): Promise<string[]> {
+ try {
+ const templates = await db
+ .select({
+ templateName: basicContractTemplates.templateName
+ })
+ .from(basicContractTemplates)
+ .where(
+ and(
+ eq(basicContractTemplates.status, 'ACTIVE'),
+ // GTC가 아닌 것들만 중복 체크 (GTC는 프로젝트별로 여러 개 허용)
+ not(like(basicContractTemplates.templateName, '% GTC'))
+ )
+ );
+
+ return templates.map(t => t.templateName);
+ } catch (error) {
+ console.error('Failed to fetch existing template names:', error);
+ throw new Error('기존 템플릿 이름을 가져오는데 실패했습니다.');
+ }
+}
+
+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;
+}
+
+export async function getVendorAttachments(vendorId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(vendorAttachments)
+ .where(
+ and(
+ eq(vendorAttachments.vendorId, vendorId),
+ eq(vendorAttachments.attachmentType, "NDA_ATTACHMENT")
+ )
+ );
+
+ console.log(attachments,"attachments")
+
+ return {
+ success: true,
+ data: attachments
+ };
+ } catch (error) {
+ console.error("Error fetching vendor attachments:", error);
+ return {
+ success: false,
+ data: [],
+ error: "Failed to fetch vendor attachments"
+ };
+ }
+}
+
+// 설문조사 템플릿 전체 데이터 타입
+export interface SurveyTemplateWithQuestions {
+ id: number;
+ name: string;
+ description: string | null;
+ version: string;
+ questions: SurveyQuestion[];
+}
+
+export interface SurveyQuestion {
+ id: number;
+ questionNumber: string;
+ questionText: string;
+ questionType: string;
+ isRequired: boolean;
+ hasDetailText: boolean;
+ hasFileUpload: boolean;
+ parentQuestionId: number | null;
+ conditionalValue: string | null;
+ displayOrder: number;
+ options: SurveyQuestionOption[];
+}
+
+export interface SurveyQuestionOption {
+ id: number;
+ optionValue: string;
+ optionText: string;
+ allowsOtherInput: boolean;
+ displayOrder: number;
+}
+
+/**
+ * 활성화된 첫 번째 설문조사 템플릿과 관련 데이터를 모두 가져오기
+ */
+export async function getActiveSurveyTemplate(language: string = 'ko'): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ // 1. 활성화된 템플릿 가져오기 (언어에 따라 필터링)
+ const templates = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.isActive, true))
+ .orderBy(complianceSurveyTemplates.id);
+
+ if (!templates || templates.length === 0) {
+ console.log('활성화된 설문조사 템플릿이 없습니다.');
+ return null;
+ }
+
+ // 언어에 따라 적절한 템플릿 선택
+ let templateData;
+ if (language === 'en') {
+ // 영문 템플릿 찾기 (이름에 '영문' 또는 'English' 포함)
+ templateData = templates.find(t =>
+ t.name.includes('영문') ||
+ t.name.toLowerCase().includes('english') ||
+ t.name.toLowerCase().includes('en')
+ );
+ } else {
+ // 한글 템플릿 찾기 (영문이 아닌 것)
+ templateData = templates.find(t =>
+ !t.name.includes('영문') &&
+ !t.name.toLowerCase().includes('english') &&
+ !t.name.toLowerCase().includes('en')
+ );
+ }
+
+ // 적절한 템플릿을 찾지 못하면 첫 번째 템플릿 사용
+ if (!templateData) {
+ console.log(`언어 '${language}'에 맞는 템플릿을 찾지 못해 기본 템플릿을 사용합니다.`);
+ templateData = templates[0];
+ }
+
+ console.log(`✅ 선택된 설문조사 템플릿: ${templateData.name} (언어: ${language})`);
+
+ // 2. 해당 템플릿의 모든 질문 가져오기 (displayOrder 순)
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateData.id))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ // 3. 각 질문의 옵션들 가져오기
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(inArray(complianceQuestionOptions.questionId, questionIds))
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+
+ // 4. 질문별로 옵션들 그룹화
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ // 5. 최종 데이터 구성
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+/**
+ * 특정 템플릿 ID로 설문조사 템플릿 가져오기
+ */
+export async function getSurveyTemplateById(templateId: number): Promise<SurveyTemplateWithQuestions | null> {
+ try {
+ const template = await db
+ .select()
+ .from(complianceSurveyTemplates)
+ .where(eq(complianceSurveyTemplates.id, templateId))
+ .limit(1);
+
+ if (!template || template.length === 0) {
+ return null;
+ }
+
+ const templateData = template[0];
+
+ const questions = await db
+ .select()
+ .from(complianceQuestions)
+ .where(eq(complianceQuestions.templateId, templateId))
+ .orderBy(asc(complianceQuestions.displayOrder));
+
+ const questionIds = questions.map(q => q.id);
+ const allOptions = questionIds.length > 0
+ ? await db
+ .select()
+ .from(complianceQuestionOptions)
+ .where(
+ complianceQuestionOptions.questionId.in ?
+ complianceQuestionOptions.questionId.in(questionIds) :
+ eq(complianceQuestionOptions.questionId, questionIds[0])
+ )
+ .orderBy(
+ complianceQuestionOptions.questionId,
+ asc(complianceQuestionOptions.displayOrder)
+ )
+ : [];
+
+ const optionsByQuestionId = allOptions.reduce((acc, option) => {
+ if (!acc[option.questionId]) {
+ acc[option.questionId] = [];
+ }
+ acc[option.questionId].push({
+ id: option.id,
+ optionValue: option.optionValue,
+ optionText: option.optionText,
+ allowsOtherInput: option.allowsOtherInput,
+ displayOrder: option.displayOrder,
+ });
+ return acc;
+ }, {} as Record<number, SurveyQuestionOption[]>);
+
+ const questionsWithOptions: SurveyQuestion[] = questions.map(question => ({
+ id: question.id,
+ questionNumber: question.questionNumber,
+ questionText: question.questionText,
+ questionType: question.questionType,
+ isRequired: question.isRequired,
+ hasDetailText: question.hasDetailText,
+ hasFileUpload: question.hasFileUpload,
+ parentQuestionId: question.parentQuestionId,
+ conditionalValue: question.conditionalValue,
+ displayOrder: question.displayOrder,
+ options: optionsByQuestionId[question.id] || [],
+ }));
+
+ return {
+ id: templateData.id,
+ name: templateData.name,
+ description: templateData.description,
+ version: templateData.version,
+ questions: questionsWithOptions,
+ };
+
+ } catch (error) {
+ console.error('설문조사 템플릿 로드 실패:', error);
+ return null;
+ }
+}
+
+
+// 설문 답변 데이터 타입 정의
+export interface SurveyAnswerData {
+ questionId: number;
+ answerValue?: string;
+ detailText?: string;
+ otherText?: string;
+ files?: File[];
+}
+
+// 설문조사 완료 요청 데이터 타입
+export interface CompleteSurveyRequest {
+ contractId: number;
+ templateId: number;
+ answers: SurveyAnswerData[];
+ progressStatus?: any; // 진행 상태 정보 (옵션)
+}
+
+// 서버 액션: 설문조사 완료 처리
+export async function completeSurvey(data: CompleteSurveyRequest) {
+ try {
+ console.log('🚀 설문조사 완료 처리 시작:', {
+ contractId: data.contractId,
+ templateId: data.templateId,
+ answersCount: data.answers?.length || 0
+ });
+
+ // 입력 검증
+ if (!data.contractId || !data.templateId || !data.answers?.length) {
+ throw new Error('필수 데이터가 누락되었습니다.');
+ }
+
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. complianceResponses 테이블 upsert
+ console.log('📋 complianceResponses 처리 중...');
+
+ // 기존 응답 확인
+ const existingResponse = await tx
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, data.contractId),
+ eq(complianceResponses.templateId, data.templateId)
+ )
+ )
+ .limit(1);
+
+ let responseId: number;
+
+ if (existingResponse.length > 0) {
+ // 기존 응답 업데이트
+ const updateData = {
+ status: 'COMPLETED' as const,
+ completedAt: new Date(),
+ updatedAt: new Date()
+ };
+
+ await tx
+ .update(complianceResponses)
+ .set(updateData)
+ .where(eq(complianceResponses.id, existingResponse[0].id));
+
+ responseId = existingResponse[0].id;
+ console.log(`✅ 기존 응답 업데이트 완료: ID ${responseId}`);
+ } else {
+ // 새 응답 생성
+ const newResponse: NewComplianceResponse = {
+ basicContractId: data.contractId,
+ templateId: data.templateId,
+ status: 'COMPLETED',
+ completedAt: new Date()
+ };
+
+ const insertResult = await tx
+ .insert(complianceResponses)
+ .values(newResponse)
+ .returning({ id: complianceResponses.id });
+
+ responseId = insertResult[0].id;
+ console.log(`✅ 새 응답 생성 완료: ID ${responseId}`);
+ }
+
+ // 2. 기존 답변들 삭제 (파일도 함께 삭제됨 - CASCADE 설정 필요)
+ console.log('🗑️ 기존 답변들 삭제 중...');
+
+ // 먼저 기존 답변에 연결된 파일들 삭제
+ const existingAnswers = await tx
+ .select({ id: complianceResponseAnswers.id })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+
+ if (existingAnswers.length > 0) {
+ const answerIds = existingAnswers.map(a => a.id);
+
+ // 파일들 먼저 삭제
+ for (const answerId of answerIds) {
+ await tx
+ .delete(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answerId));
+ }
+
+ // 답변들 삭제
+ await tx
+ .delete(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseId));
+ }
+
+ // 3. 새로운 답변들 생성
+ console.log('📝 새로운 답변들 생성 중...');
+ const createdAnswers: { questionId: number; answerId: number; files?: File[] }[] = [];
+
+ for (const answer of data.answers) {
+ // 빈 답변은 스킵 (선택적 질문의 경우)
+ if (!answer.answerValue && !answer.detailText && (!answer.files || answer.files.length === 0)) {
+ continue;
+ }
+
+ const newAnswer: NewComplianceResponseAnswer = {
+ responseId,
+ questionId: answer.questionId,
+ answerValue: answer.answerValue || null,
+ detailText: answer.detailText || null,
+ otherText: answer.otherText || null,
+ // percentageValue는 필요시 추가 처리
+ };
+
+ const answerResult = await tx
+ .insert(complianceResponseAnswers)
+ .values(newAnswer)
+ .returning({ id: complianceResponseAnswers.id });
+
+ const answerId = answerResult[0].id;
+
+ createdAnswers.push({
+ questionId: answer.questionId,
+ answerId,
+ files: answer.files
+ });
+
+ console.log(`✅ 답변 생성: 질문 ${answer.questionId} -> 답변 ID ${answerId}`);
+ }
+
+ // 4. 파일 업로드 처리 (실제 파일 저장은 별도 로직 필요)
+ console.log('📎 파일 업로드 처리 중...');
+
+ for (const answerWithFiles of createdAnswers) {
+ if (answerWithFiles.files && answerWithFiles.files.length > 0) {
+ for (const file of answerWithFiles.files) {
+ // TODO: 실제 파일 저장 로직 구현 필요
+ // 현재는 파일 메타데이터만 저장
+
+
+ // 파일 저장 경로 생성 (예시)
+ const fileName = file.name;
+ const filePath = `/uploads/compliance/${data.contractId}/${responseId}/${answerWithFiles.answerId}/${fileName}`;
+
+ const fileUpload = await saveFile({file,filePath })
+
+ const newFile: NewComplianceResponseFile = {
+ answerId: answerWithFiles.answerId,
+ fileName,
+ filePath,
+ fileSize: file.size,
+ mimeType: file.type || 'application/octet-stream'
+ };
+
+ await tx
+ .insert(complianceResponseFiles)
+ .values(newFile);
+
+ console.log(`📎 파일 메타데이터 저장: ${fileName}`);
+ }
+ }
+ }
+
+ return {
+ responseId,
+ answersCount: createdAnswers.length,
+ success: true
+ };
+ });
+
+ console.log('🎉 설문조사 완료 처리 성공:', result);
+
+
+ return {
+ success: true,
+ message: '설문조사가 성공적으로 완료되었습니다.',
+ data: result
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 완료 처리 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 저장에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 설문조사 응답 조회 서버 액션
+export async function getSurveyResponse(contractId: number, templateId: number) {
+ try {
+ const response = await db
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, contractId),
+ eq(complianceResponses.templateId, templateId)
+ )
+ )
+ .limit(1);
+
+ if (response.length === 0) {
+ return { success: true, data: null };
+ }
+
+ // 답변들과 파일들도 함께 조회
+ const answers = await db
+ .select({
+ id: complianceResponseAnswers.id,
+ questionId: complianceResponseAnswers.questionId,
+ answerValue: complianceResponseAnswers.answerValue,
+ detailText: complianceResponseAnswers.detailText,
+ otherText: complianceResponseAnswers.otherText,
+ percentageValue: complianceResponseAnswers.percentageValue,
+ })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, response[0].id));
+
+ // 각 답변의 파일들 조회
+ const answersWithFiles = await Promise.all(
+ answers.map(async (answer) => {
+ const files = await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(eq(complianceResponseFiles.answerId, answer.id));
+
+ return {
+ ...answer,
+ files
+ };
+ })
+ );
+
+ return {
+ success: true,
+ data: {
+ response: response[0],
+ answers: answersWithFiles
+ }
+ };
+
+ } catch (error) {
+ console.error('❌ 설문조사 응답 조회 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '설문조사 응답 조회에 실패했습니다.',
+ data: null
+ };
+ }
+}
+
+// 파일 업로드를 위한 별도 서버 액션 (실제 파일 저장 로직)
+export async function uploadSurveyFile(file: File, contractId: number, answerId: number) {
+ try {
+ // TODO: 실제 파일 저장 구현
+ // 예: AWS S3, 로컬 파일시스템, 등등
+
+ // 현재는 예시 구현
+ const fileName = `${Date.now()}-${file.name}`;
+ const filePath = `/uploads/compliance/${contractId}/${answerId}/${fileName}`;
+
+ // 실제로는 여기서 파일을 물리적으로 저장해야 함
+ // const savedPath = await saveFileToStorage(file, filePath);
+
+ return {
+ success: true,
+ filePath,
+ fileName: file.name,
+ fileSize: file.size,
+ mimeType: file.type
+ };
+
+ } catch (error) {
+ console.error('❌ 파일 업로드 실패:', error);
+
+ return {
+ success: false,
+ message: error instanceof Error ? error.message : '파일 업로드에 실패했습니다.'
+ };
+ }
+}
+
+
+// 기존 응답 조회를 위한 타입
+export interface ExistingResponse {
+ responseId: number;
+ status: string;
+ completedAt: string | null;
+ answers: {
+ questionId: number;
+ answerValue: string | null;
+ detailText: string | null;
+ otherText: string | null;
+ files: Array<{
+ id: number;
+ fileName: string;
+ filePath: string;
+ fileSize: number;
+ }>;
+ }[];
+}
+
+// 기존 응답 조회 서버 액션
+export async function getExistingSurveyResponse(
+ contractId: number,
+ templateId: number
+): Promise<{ success: boolean; data: ExistingResponse | null; message?: string }> {
+ try {
+ // 1. 해당 계약서의 응답 조회
+ const response = await db
+ .select()
+ .from(complianceResponses)
+ .where(
+ and(
+ eq(complianceResponses.basicContractId, contractId),
+ eq(complianceResponses.templateId, templateId)
+ )
+ )
+ .limit(1);
+
+ if (!response || response.length === 0) {
+ return { success: true, data: null };
+ }
+
+ const responseData = response[0];
+
+ // 2. 해당 응답의 모든 답변 조회
+ const answers = await db
+ .select({
+ questionId: complianceResponseAnswers.questionId,
+ answerValue: complianceResponseAnswers.answerValue,
+ detailText: complianceResponseAnswers.detailText,
+ otherText: complianceResponseAnswers.otherText,
+ answerId: complianceResponseAnswers.id,
+ })
+ .from(complianceResponseAnswers)
+ .where(eq(complianceResponseAnswers.responseId, responseData.id));
+
+ // 3. 각 답변의 파일들 조회
+ const answerIds = answers.map(a => a.answerId);
+ const files = answerIds.length > 0
+ ? await db
+ .select()
+ .from(complianceResponseFiles)
+ .where(inArray(complianceResponseFiles.answerId, answerIds))
+ : [];
+
+ // 4. 답변별 파일 그룹화
+ const filesByAnswerId = files.reduce((acc, file) => {
+ if (!acc[file.answerId]) {
+ acc[file.answerId] = [];
+ }
+ acc[file.answerId].push({
+ id: file.id,
+ fileName: file.fileName,
+ filePath: file.filePath,
+ fileSize: file.fileSize || 0,
+ });
+ return acc;
+ }, {} as Record<number, Array<{id: number; fileName: string; filePath: string; fileSize: number}>>);
+
+ // 5. 최종 데이터 구성
+ const answersWithFiles = answers.map(answer => ({
+ questionId: answer.questionId,
+ answerValue: answer.answerValue,
+ detailText: answer.detailText,
+ otherText: answer.otherText,
+ files: filesByAnswerId[answer.answerId] || [],
+ }));
+
+ return {
+ success: true,
+ data: {
+ responseId: responseData.id,
+ status: responseData.status,
+ completedAt: responseData.completedAt?.toISOString() || null,
+ answers: answersWithFiles,
+ },
+ };
+
+ } catch (error) {
+ console.error('기존 설문 응답 조회 실패:', error);
+ return {
+ success: false,
+ data: null,
+ message: '기존 응답을 불러오는데 실패했습니다.'
+ };
+ }
+}
+
+export type GtcVendorData = {
+ vendorDocument: {
+ id: number;
+ name: string;
+ description: string | null;
+ version: string;
+ reviewStatus: string;
+ vendorId: number;
+ baseDocumentId: number;
+ vendorName: string;
+ vendorCode: string;
+ };
+ clauses: Array<{
+ id: number;
+ baseClauseId: number;
+ vendorDocumentId: number;
+ parentId: number | null;
+ depth: number;
+ sortOrder: string;
+ fullPath: string | null;
+ reviewStatus: string;
+ negotiationNote: string | null;
+ isExcluded: boolean;
+
+ // 실제 표시될 값들 (수정된 값이 우선, 없으면 기본값)
+ effectiveItemNumber: string;
+ effectiveCategory: string | null;
+ effectiveSubtitle: string;
+ effectiveContent: string | null;
+
+ // 기본 조항 정보
+ baseItemNumber: string;
+ baseCategory: string | null;
+ baseSubtitle: string;
+ baseContent: string | null;
+
+ // 수정 여부
+ hasModifications: boolean;
+ isNumberModified: boolean;
+ isCategoryModified: boolean;
+ isSubtitleModified: boolean;
+ isContentModified: boolean;
+
+ // 코멘트 관련
+ hasComment: boolean;
+ pendingComment: string | null;
+ }>;
+};
+
+/**
+ * 현재 사용자(벤더)의 GTC 데이터를 가져옵니다.
+ * @param contractId 기본 GTC 문서 ID (선택사항, 없으면 가장 최신 문서 사용)
+ * @returns GTC 벤더 데이터 또는 null
+ */
+export async function getVendorGtcData(contractId?: number): Promise<GtcVendorData | null> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.companyId) {
+ throw new Error("회사 정보가 없습니다.");
+ }
+
+ console.log(contractId, "contractId");
+
+ const companyId = session.user.companyId;
+ const vendorId = companyId; // companyId를 vendorId로 사용
+
+ // 1. 계약 정보 가져오기
+ const existingContract = await db.query.basicContract.findFirst({
+ where: eq(basicContract.id, contractId),
+ });
+
+ if (!existingContract) {
+ throw new Error("계약을 찾을 수 없습니다.");
+ }
+
+ // 2. 계약 템플릿 정보 가져오기
+ const existingContractTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, existingContract.templateId), // id가 아니라 templateId여야 할 것 같음
+ });
+
+ if (!existingContractTemplate) {
+ throw new Error("계약 템플릿을 찾을 수 없습니다.");
+ }
+
+ // 3. General 타입인지 확인
+ const isGeneral = existingContractTemplate.templateName.toLowerCase().includes('general');
+
+ let targetBaseDocumentId: number;
+
+ if (isGeneral) {
+ // General인 경우: type이 'standard'인 활성 상태의 첫 번째 문서 사용
+ const standardGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'standard'),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
+ });
+
+ if (!standardGtcDoc) {
+ throw new Error("표준 GTC 문서를 찾을 수 없습니다.");
+ }
+
+ targetBaseDocumentId = standardGtcDoc.id;
+ console.log(`표준 GTC 문서 사용: ${targetBaseDocumentId}`);
+
+ } else {
+ // General이 아닌 경우: 프로젝트별 GTC 문서 사용
+ const projectCode = existingContractTemplate.templateName.split(" ")[0];
+
+ if (!projectCode) {
+ throw new Error("템플릿 이름에서 프로젝트 코드를 찾을 수 없습니다.");
+ }
+
+ // 프로젝트 찾기
+ const existingProject = await db.query.projects.findFirst({
+ where: eq(projects.code, projectCode),
+ });
+
+ if (!existingProject) {
+ throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`);
+ }
+
+ // 해당 프로젝트의 GTC 문서 찾기
+ const projectGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'project'),
+ eq(gtcDocuments.projectId, existingProject.id),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)] // 최신 리비전 우선
+ });
+
+ if (!projectGtcDoc) {
+ console.warn(`프로젝트 ${projectCode}에 대한 GTC 문서가 없습니다. 표준 GTC 문서를 사용합니다.`);
+
+ // 프로젝트별 GTC가 없으면 표준 GTC 사용
+ const standardGtcDoc = await db.query.gtcDocuments.findFirst({
+ where: and(
+ eq(gtcDocuments.type, 'standard'),
+ eq(gtcDocuments.isActive, true)
+ ),
+ orderBy: [desc(gtcDocuments.revision), desc(gtcDocuments.createdAt)]
+ });
+
+ if (!standardGtcDoc) {
+ throw new Error("표준 GTC 문서도 찾을 수 없습니다.");
+ }
+
+ targetBaseDocumentId = standardGtcDoc.id;
+ } else {
+ targetBaseDocumentId = projectGtcDoc.id;
+ console.log(`프로젝트 GTC 문서 사용: ${targetBaseDocumentId} (프로젝트: ${projectCode})`);
+ }
+ }
+
+ // 4. 벤더 문서 정보 가져오기 (없어도 기본 조항은 보여줌)
+ const vendorDocResult = await db
+ .select({
+ id: gtcVendorDocuments.id,
+ name: gtcVendorDocuments.name,
+ description: gtcVendorDocuments.description,
+ version: gtcVendorDocuments.version,
+ reviewStatus: gtcVendorDocuments.reviewStatus,
+ vendorId: gtcVendorDocuments.vendorId,
+ baseDocumentId: gtcVendorDocuments.baseDocumentId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(gtcVendorDocuments)
+ .leftJoin(vendors, eq(gtcVendorDocuments.vendorId, vendors.id))
+ .where(
+ and(
+ eq(gtcVendorDocuments.vendorId, vendorId),
+ eq(gtcVendorDocuments.baseDocumentId, targetBaseDocumentId),
+ eq(gtcVendorDocuments.isActive, true)
+ )
+ )
+ .limit(1);
+
+ // 벤더 문서가 없으면 기본 정보로 생성
+ const vendorDocument = vendorDocResult.length > 0 ? vendorDocResult[0] : {
+ id: null, // 벤더 문서가 아직 생성되지 않음
+ name: `GTC 검토 (벤더 ID: ${vendorId})`,
+ description: "기본 GTC 협의",
+ version: "1.0",
+ reviewStatus: "pending",
+ vendorId: vendorId,
+ baseDocumentId: targetBaseDocumentId,
+ vendorName: "Unknown Vendor",
+ vendorCode: "UNKNOWN"
+ };
+
+ if (vendorDocResult.length === 0) {
+ console.info(`벤더 ID ${vendorId}에 대한 GTC 벤더 문서가 없습니다. 기본 조항만 표시합니다. (baseDocumentId: ${targetBaseDocumentId})`);
+ }
+
+ // 5. 기본 조항들 가져오기 (벤더별 수정 사항과 함께)
+ const clausesResult = await db
+ .select({
+ // 기본 조항 정보 (메인)
+ baseClauseId: gtcClauses.id,
+ baseItemNumber: gtcClauses.itemNumber,
+ baseCategory: gtcClauses.category,
+ baseSubtitle: gtcClauses.subtitle,
+ baseContent: gtcClauses.content,
+ baseParentId: gtcClauses.parentId,
+ baseDepth: gtcClauses.depth,
+ baseSortOrder: gtcClauses.sortOrder,
+ baseFullPath: gtcClauses.fullPath,
+
+ // 벤더 조항 정보 (있는 경우만)
+ vendorClauseId: gtcVendorClauses.id,
+ vendorDocumentId: gtcVendorClauses.vendorDocumentId,
+ reviewStatus: gtcVendorClauses.reviewStatus,
+ negotiationNote: gtcVendorClauses.negotiationNote,
+ isExcluded: gtcVendorClauses.isExcluded,
+
+ // 수정된 값들 (있는 경우만)
+ modifiedItemNumber: gtcVendorClauses.modifiedItemNumber,
+ modifiedCategory: gtcVendorClauses.modifiedCategory,
+ modifiedSubtitle: gtcVendorClauses.modifiedSubtitle,
+ modifiedContent: gtcVendorClauses.modifiedContent,
+
+ // 수정 여부
+ isNumberModified: gtcVendorClauses.isNumberModified,
+ isCategoryModified: gtcVendorClauses.isCategoryModified,
+ isSubtitleModified: gtcVendorClauses.isSubtitleModified,
+ isContentModified: gtcVendorClauses.isContentModified,
+ })
+ .from(gtcClauses)
+ .leftJoin(gtcVendorClauses, and(
+ eq(gtcVendorClauses.baseClauseId, gtcClauses.id),
+ vendorDocument.id ? eq(gtcVendorClauses.vendorDocumentId, vendorDocument.id) : sql`false`,
+ eq(gtcVendorClauses.isActive, true)
+ ))
+ .where(
+ and(
+ eq(gtcClauses.documentId, targetBaseDocumentId),
+ eq(gtcClauses.isActive, true)
+ )
+ )
+ .orderBy(gtcClauses.sortOrder);
+
+ let negotiationHistoryMap = new Map();
+
+ if (vendorDocument.id) {
+ const vendorClauseIds = clausesResult
+ .filter(c => c.vendorClauseId)
+ .map(c => c.vendorClauseId);
+
+ if (vendorClauseIds.length > 0) {
+ const histories = await db
+ .select({
+ vendorClauseId: gtcNegotiationHistory.vendorClauseId,
+ action: gtcNegotiationHistory.action,
+ previousStatus: gtcNegotiationHistory.previousStatus,
+ newStatus: gtcNegotiationHistory.newStatus,
+ comment: gtcNegotiationHistory.comment,
+ actorType: gtcNegotiationHistory.actorType,
+ actorId: gtcNegotiationHistory.actorId,
+ actorName: gtcNegotiationHistory.actorName,
+ actorEmail: gtcNegotiationHistory.actorEmail,
+ createdAt: gtcNegotiationHistory.createdAt,
+ changedFields: gtcNegotiationHistory.changedFields,
+ })
+ .from(gtcNegotiationHistory)
+ .leftJoin(users, eq(gtcNegotiationHistory.actorId, users.id))
+ .where(inArray(gtcNegotiationHistory.vendorClauseId, vendorClauseIds))
+ .orderBy(desc(gtcNegotiationHistory.createdAt));
+
+ // 벤더 조항별로 이력 그룹화
+ histories.forEach(history => {
+ if (!negotiationHistoryMap.has(history.vendorClauseId)) {
+ negotiationHistoryMap.set(history.vendorClauseId, []);
+ }
+ negotiationHistoryMap.get(history.vendorClauseId).push(history);
+ });
+ }
+ }
+
+
+
+ // 6. 데이터 변환 및 추가 정보 계산
+ const clauses = clausesResult.map(clause => {
+ const hasVendorData = !!clause.vendorClauseId;
+ const negotiationHistory = hasVendorData ?
+ (negotiationHistoryMap.get(clause.vendorClauseId) || []) : [];
+
+ // 코멘트가 있는 이력들만 필터링
+ const commentHistory = negotiationHistory.filter(h => h.comment);
+ const latestComment = commentHistory[0]?.comment || null;
+ const hasComment = commentHistory.length > 0;
+
+ return {
+ id: clause.baseClauseId,
+ vendorClauseId: clause.vendorClauseId,
+ vendorDocumentId: hasVendorData ? clause.vendorDocumentId : null,
+
+ // 기본 조항의 계층 구조 정보 사용
+ parentId: clause.baseParentId,
+ depth: clause.baseDepth,
+ sortOrder: clause.baseSortOrder,
+ fullPath: clause.baseFullPath,
+
+ // 상태 정보 (벤더 데이터가 있는 경우만)
+ reviewStatus: clause.reviewStatus || 'pending',
+ negotiationNote: clause.negotiationNote,
+ isExcluded: clause.isExcluded || false,
+
+ // 실제 표시될 값들 (수정된 값이 있으면 그것을, 없으면 기본값)
+ effectiveItemNumber: clause.modifiedItemNumber || clause.baseItemNumber,
+ effectiveCategory: clause.modifiedCategory || clause.baseCategory,
+ effectiveSubtitle: clause.modifiedSubtitle || clause.baseSubtitle,
+ effectiveContent: clause.modifiedContent || clause.baseContent,
+
+ // 기본 조항 정보
+ baseItemNumber: clause.baseItemNumber,
+ baseCategory: clause.baseCategory,
+ baseSubtitle: clause.baseSubtitle,
+ baseContent: clause.baseContent,
+
+ // 수정 여부
+ // hasModifications,
+ isNumberModified: clause.isNumberModified || false,
+ isCategoryModified: clause.isCategoryModified || false,
+ isSubtitleModified: clause.isSubtitleModified || false,
+ isContentModified: clause.isContentModified || false,
+
+ hasComment,
+ latestComment,
+ commentHistory, // 전체 코멘트 이력
+ negotiationHistory, // 전체 협의 이력
+ };
+ });
+
+ return {
+ vendorDocument,
+ clauses,
+ };
+
+ } catch (error) {
+ console.error('GTC 벤더 데이터 가져오기 실패:', error);
+ throw error;
+ }
+}
+
+
+interface ClauseUpdateData {
+ itemNumber: string;
+ category: string | null;
+ subtitle: string;
+ content: string | null;
+ comment: string;
+}
+
+interface VendorDocument {
+ id: number | null;
+ vendorId: number;
+ baseDocumentId: number;
+ name: string;
+ description: string;
+ version: string;
+}
+
+export async function updateVendorClause(
+ baseClauseId: number,
+ vendorClauseId: number | null,
+ clauseData: any,
+ vendorDocument: any
+): Promise<{ success: boolean; error?: string; vendorClauseId?: number; vendorDocumentId?: number }> {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.companyId) {
+ return { success: false, error: "회사 정보가 없습니다." };
+ }
+
+ const companyId = session.user.companyId;
+ const vendorId = companyId;
+ const userId = Number(session.user.id);
+
+ // 1. 기본 조항 정보 가져오기
+ const baseClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, baseClauseId),
+ });
+
+ if (!baseClause) {
+ return { success: false, error: "기본 조항을 찾을 수 없습니다." };
+ }
+
+ // 2. 이전 코멘트 가져오기 (vendorClauseId가 있는 경우)
+ let previousComment = null;
+ if (vendorClauseId) {
+ const previousData = await db
+ .select({ comment: gtcVendorClauses.negotiationNote })
+ .from(gtcVendorClauses)
+ .where(eq(gtcVendorClauses.id, vendorClauseId))
+ .limit(1);
+
+ previousComment = previousData?.[0]?.comment || null;
+ }
+
+ // 3. 벤더 문서 ID 확보 (없으면 생성)
+ let finalVendorDocumentId = vendorDocument?.id;
+
+ if (!finalVendorDocumentId && vendorDocument) {
+ const newVendorDoc = await db.insert(gtcVendorDocuments).values({
+ vendorId: vendorId,
+ baseDocumentId: vendorDocument.baseDocumentId,
+ name: vendorDocument.name,
+ description: vendorDocument.description,
+ version: vendorDocument.version,
+ reviewStatus: 'reviewing',
+ createdById: userId,
+ updatedById: userId,
+ }).returning({ id: gtcVendorDocuments.id });
+
+ if (newVendorDoc.length === 0) {
+ return { success: false, error: "벤더 문서 생성에 실패했습니다." };
+ }
+
+ finalVendorDocumentId = newVendorDoc[0].id;
+ console.log(`새 벤더 문서 생성: ${finalVendorDocumentId}`);
+ }
+
+ if (!finalVendorDocumentId) {
+ return { success: false, error: "벤더 문서 ID를 확보할 수 없습니다." };
+ }
+
+ // 4. 수정 여부 확인
+ const isNumberModified = clauseData.itemNumber !== baseClause.itemNumber;
+ const isCategoryModified = clauseData.category !== baseClause.category;
+ const isSubtitleModified = clauseData.subtitle !== baseClause.subtitle;
+ const isContentModified = clauseData.content !== baseClause.content;
+
+ const hasAnyModifications = isNumberModified || isCategoryModified || isSubtitleModified || isContentModified;
+ const hasComment = !!(clauseData.comment?.trim());
+
+ // 5. 벤더 조항 데이터 준비
+ const vendorClauseData = {
+ vendorDocumentId: finalVendorDocumentId,
+ baseClauseId: baseClauseId,
+ parentId: baseClause.parentId,
+ depth: baseClause.depth,
+ sortOrder: baseClause.sortOrder,
+ fullPath: baseClause.fullPath,
+
+ modifiedItemNumber: isNumberModified ? clauseData.itemNumber : null,
+ modifiedCategory: isCategoryModified ? clauseData.category : null,
+ modifiedSubtitle: isSubtitleModified ? clauseData.subtitle : null,
+ modifiedContent: isContentModified ? clauseData.content : null,
+
+ isNumberModified,
+ isCategoryModified,
+ isSubtitleModified,
+ isContentModified,
+
+ reviewStatus: (hasAnyModifications || hasComment) ? 'reviewing' : 'draft',
+ negotiationNote: clauseData.comment?.trim() || null,
+ editReason: clauseData.comment?.trim() || null,
+
+ updatedAt: new Date(),
+ updatedById: userId,
+ };
+
+ let finalVendorClauseId = vendorClauseId;
+
+ // 6. 벤더 조항 생성 또는 업데이트
+ if (vendorClauseId) {
+ await db
+ .update(gtcVendorClauses)
+ .set(vendorClauseData)
+ .where(eq(gtcVendorClauses.id, vendorClauseId));
+
+ console.log(`벤더 조항 업데이트: ${vendorClauseId}`);
+ } else {
+ const newVendorClause = await db.insert(gtcVendorClauses).values({
+ ...vendorClauseData,
+ createdById: userId,
+ }).returning({ id: gtcVendorClauses.id });
+
+ if (newVendorClause.length === 0) {
+ return { success: false, error: "벤더 조항 생성에 실패했습니다." };
+ }
+
+ finalVendorClauseId = newVendorClause[0].id;
+ console.log(`새 벤더 조항 생성: ${finalVendorClauseId}`);
+ }
+
+ // 7. 협의 이력에 기록 (코멘트가 변경된 경우만)
+ if (clauseData.comment !== previousComment) {
+ await db.insert(gtcNegotiationHistory).values({
+ vendorClauseId: finalVendorClauseId,
+ action: previousComment ? "modified" : "commented",
+ comment: clauseData.comment || null,
+ previousStatus: null,
+ newStatus: 'reviewing',
+ actorType: "vendor",
+ actorId: userId,
+ actorName: session.user.name,
+ actorEmail: session.user.email,
+ changedFields: {
+ comment: {
+ from: previousComment,
+ to: clauseData.comment || null
+ }
+ }
+ });
+ }
+
+ return {
+ success: true,
+ vendorClauseId: finalVendorClauseId,
+ vendorDocumentId: finalVendorDocumentId
+ };
+
+ } catch (error) {
+ console.error('벤더 조항 업데이트 실패:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
+ };
+ }
+}
+// 기존 함수는 호환성을 위해 유지하되, 새 함수를 호출하도록 변경
+export async function updateVendorClauseComment(
+ clauseId: number,
+ comment: string
+): Promise<{ success: boolean; error?: string }> {
+ console.warn('updateVendorClauseComment is deprecated. Use updateVendorClause instead.');
+
+ // 기본 조항 정보 가져오기
+ const baseClause = await db.query.gtcClauses.findFirst({
+ where: eq(gtcClauses.id, clauseId),
+ });
+
+ if (!baseClause) {
+ return { success: false, error: "기본 조항을 찾을 수 없습니다." };
+ }
+
+ // 기존 벤더 조항 찾기
+ const session = await getServerSession(authOptions);
+ const vendorId = session?.user?.companyId;
+
+ const existingVendorClause = await db.query.gtcVendorClauses.findFirst({
+ where: and(
+ eq(gtcVendorClauses.baseClauseId, clauseId),
+ eq(gtcVendorClauses.isActive, true)
+ ),
+ with: {
+ vendorDocument: true
+ }
+ });
+
+ const clauseData: ClauseUpdateData = {
+ itemNumber: baseClause.itemNumber,
+ category: baseClause.category,
+ subtitle: baseClause.subtitle,
+ content: baseClause.content,
+ comment: comment,
+ };
+
+ const result = await updateVendorClause(
+ clauseId,
+ existingVendorClause?.id || null,
+ clauseData,
+ existingVendorClause?.vendorDocument || undefined
+ );
+
+ return {
+ success: result.success,
+ error: result.error
+ };
+}
+
+
+/**
+ * 벤더 조항 코멘트들의 상태 체크
+ */
+export async function checkVendorClausesCommentStatus(
+ vendorDocumentId: number
+): Promise<{ hasComments: boolean; commentCount: number }> {
+ try {
+ const clausesWithComments = await db
+ .select({
+ id: gtcVendorClauses.id,
+ negotiationNote: gtcVendorClauses.negotiationNote
+ })
+ .from(gtcVendorClauses)
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
+ eq(gtcVendorClauses.isActive, true)
+ )
+ );
+
+ const commentCount = clausesWithComments.filter(
+ clause => clause.negotiationNote && clause.negotiationNote.trim().length > 0
+ ).length;
+
+ return {
+ hasComments: commentCount > 0,
+ commentCount,
+ };
+
+ } catch (error) {
+ console.error('벤더 조항 코멘트 상태 체크 실패:', error);
+ return { hasComments: false, commentCount: 0 };
+ }
+}
+
+/**
+ * 특정 템플릿의 기본 정보를 조회하는 서버 액션
+ * @param templateId - 조회할 템플릿의 ID
+ * @returns 템플릿 기본 정보 또는 null
+ */
+export async function getBasicContractTemplateInfo(templateId: number) {
+ try {
+ const templateInfo = await db
+ .select({
+ templateId: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ revision: basicContractTemplates.revision,
+ status: basicContractTemplates.status,
+ legalReviewRequired: basicContractTemplates.legalReviewRequired,
+ validityPeriod: basicContractTemplates.validityPeriod,
+ fileName: basicContractTemplates.fileName,
+ filePath: basicContractTemplates.filePath,
+ createdAt: basicContractTemplates.createdAt,
+ updatedAt: basicContractTemplates.updatedAt,
+ createdBy: basicContractTemplates.createdBy,
+ updatedBy: basicContractTemplates.updatedBy,
+ disposedAt: basicContractTemplates.disposedAt,
+ restoredAt: basicContractTemplates.restoredAt,
+ })
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, templateId))
+ .then((res) => res[0] || null)
+
+ return templateInfo
+ } catch (error) {
+ console.error("Error fetching template info:", error)
+ return null
+ }
+}
+
+
+
+/**
+ * 카테고리 자동 분류 함수
+ */
+function getCategoryFromTemplateName(templateName: string | null): string {
+ if (!templateName) return "기타"
+
+ const templateNameLower = templateName.toLowerCase()
+
+ if (templateNameLower.includes("준법")) {
+ return "CP"
+ } else if (templateNameLower.includes("gtc")) {
+ return "GTC"
+ }
+
+ return "기타"
+}
+
+/**
+ * 법무검토 요청 서버 액션
+ */
+export async function requestLegalReviewAction(
+ contractIds: number[],
+ reviewNote?: string
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContractView.id,
+ vendorId: basicContractView.vendorId,
+ vendorCode: basicContractView.vendorCode,
+ vendorName: basicContractView.vendorName,
+ templateName: basicContractView.templateName,
+ legalReviewRequired: basicContractView.legalReviewRequired,
+ legalReviewRequestedAt: basicContractView.legalReviewRequestedAt,
+ })
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 법무검토 요청 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract =>
+ contract.legalReviewRequired && !contract.legalReviewRequestedAt
+ )
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "법무검토 요청 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+ const reviewer = session.user.name || session.user.email || "알 수 없음"
+
+ // 트랜잭션으로 처리
+ const results = await db.transaction(async (tx) => {
+ const legalWorkResults = []
+ const contractUpdateResults = []
+
+ // 각 계약서에 대해 legalWorks 레코드 생성
+ for (const contract of eligibleContracts) {
+ const category = getCategoryFromTemplateName(contract.templateName)
+
+ // legalWorks에 레코드 삽입
+ const legalWorkResult = await tx.insert(legalWorks).values({
+ basicContractId: contract.id, // 레퍼런스 ID
+ category: category,
+ status: "신규등록",
+ vendorId: contract.vendorId,
+ vendorCode: contract.vendorCode,
+ vendorName: contract.vendorName || "업체명 없음",
+ isUrgent: false,
+ consultationDate: currentDate.toISOString().split('T')[0], // YYYY-MM-DD 형식
+ reviewer: reviewer,
+ hasAttachment: false, // 기본값, 나중에 첨부파일 로직 추가 시 수정
+ createdAt: currentDate,
+ updatedAt: currentDate,
+ }).returning({ id: legalWorks.id })
+
+ legalWorkResults.push(legalWorkResult[0])
+
+ // basicContract 테이블의 legalReviewRequestedAt 업데이트
+ const contractUpdateResult = await tx
+ .update(basicContract)
+ .set({
+ legalReviewRequestedAt: currentDate,
+ updatedAt: currentDate,
+ })
+ .where(eq(basicContract.id, contract.id))
+ .returning({ id: basicContract.id })
+
+ contractUpdateResults.push(contractUpdateResult[0])
+ }
+
+ return {
+ legalWorks: legalWorkResults,
+ contractUpdates: contractUpdateResults
+ }
+ })
+
+
+ console.log("법무검토 요청 완료:", {
+ requestedBy: reviewer,
+ contractIds: eligibleContracts.map(c => c.id),
+ legalWorkIds: results.legalWorks.map(r => r.id),
+ reviewNote,
+ })
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 계약서에 대한 법무검토를 요청했습니다.`,
+ data: {
+ processedCount: eligibleContracts.length,
+ totalRequested: contractIds.length,
+ legalWorkIds: results.legalWorks.map(r => r.id),
+ }
+ }
+
+ } catch (error) {
+ console.error("법무검토 요청 중 오류:", error)
+
+ return {
+ success: false,
+ message: "법무검토 요청 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+export async function resendContractsAction(contractIds: number[]) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user?.id) {
+ throw new Error('인증이 필요합니다.')
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ fileName: basicContract.fileName,
+ deadline: basicContract.deadline,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ })
+ .from(basicContract)
+ .where(inArray(basicContract.id, contractIds))
+
+ if (contracts.length === 0) {
+ throw new Error('발송할 계약서를 찾을 수 없습니다.')
+ }
+
+ // 각 계약서에 대해 이메일 발송
+ const emailPromises = contracts.map(async (contract) => {
+ // 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ country: vendors.country,
+ email: vendors.email,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId!))
+ .limit(1)
+
+ if (!vendor[0]) {
+ console.error(`벤더를 찾을 수 없습니다: vendorId ${contract.vendorId}`)
+ return null
+ }
+
+ // 벤더 연락처 조회 (Primary 연락처 우선, 없으면 첫 번째 연락처)
+ const contacts = await db
+ .select({
+ contactName: vendorContacts.contactName,
+ contactEmail: vendorContacts.contactEmail,
+ isPrimary: vendorContacts.isPrimary,
+ })
+ .from(vendorContacts)
+ .where(eq(vendorContacts.vendorId, vendor[0].id))
+ .orderBy(vendorContacts.isPrimary)
+
+ // 이메일 수신자 결정 (Primary 연락처 > 첫 번째 연락처 > 벤더 기본 이메일)
+ const primaryContact = contacts.find(c => c.isPrimary)
+ const recipientEmail = primaryContact?.contactEmail || contacts[0]?.contactEmail || vendor[0].email
+ const recipientName = primaryContact?.contactName || contacts[0]?.contactName || vendor[0].vendorName
+
+ if (!recipientEmail) {
+ console.error(`이메일 주소를 찾을 수 없습니다: vendorId ${vendor[0].id}`)
+ return null
+ }
+
+ // 언어 결정 (한국 = 한글, 그 외 = 영어)
+ const isKorean = vendor[0].country === 'KR'
+ const template = isKorean ? 'contract-reminder-kr' : 'contract-reminder-en'
+ const subject = isKorean
+ ? '[eVCP] 계약서 서명 요청 리마인더'
+ : '[eVCP] Contract Signature Reminder'
+
+ // 마감일 포맷팅
+ const deadlineDate = new Date(contract.deadline)
+ const formattedDeadline = isKorean
+ ? `${deadlineDate.getFullYear()}년 ${deadlineDate.getMonth() + 1}월 ${deadlineDate.getDate()}일`
+ : deadlineDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
+
+ // 남은 일수 계산
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ const deadline = new Date(contract.deadline)
+ deadline.setHours(0, 0, 0, 0)
+ const daysRemaining = Math.ceil((deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
+
+ // 이메일 발송
+ await sendEmail({
+ from: session.user.email,
+ to: recipientEmail,
+ subject,
+ template,
+ context: {
+ recipientName,
+ vendorName: vendor[0].vendorName,
+ vendorCode: vendor[0].vendorCode,
+ contractFileName: contract.fileName,
+ deadline: formattedDeadline,
+ daysRemaining,
+ senderName: session.user.name || session.user.email,
+ senderEmail: session.user.email,
+ // 계약서 링크 (실제 환경에 맞게 수정 필요)
+ contractLink: `${process.env.NEXT_PUBLIC_APP_URL}/contracts/${contract.id}`,
+ },
+ })
+
+ console.log(`리마인더 이메일 발송 완료: ${recipientEmail} (계약서 ID: ${contract.id})`)
+ return { contractId: contract.id, email: recipientEmail }
+ })
+
+ const results = await Promise.allSettled(emailPromises)
+
+ // 성공/실패 카운트
+ const successful = results.filter(r => r.status === 'fulfilled' && r.value !== null).length
+ const failed = results.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && r.value === null)).length
+
+ if (failed > 0) {
+ console.warn(`${failed}건의 이메일 발송 실패`)
+ }
+
+ return {
+ success: true,
+ message: `${successful}건의 리마인더 이메일을 발송했습니다.`,
+ successful,
+ failed,
+ }
+
+ } catch (error) {
+ console.error('계약서 재발송 중 오류:', error)
+ throw new Error('계약서 재발송 중 오류가 발생했습니다.')
+ }
+}
+
+
+export async function processBuyerSignatureAction(
+ contractId: number,
+ signedFileData: ArrayBuffer,
+ fileName: string
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회 및 상태 확인
+ const contract = await db
+ .select()
+ .from(basicContractView)
+ .where(eq(basicContractView.id, contractId))
+ .limit(1)
+
+ if (contract.length === 0) {
+ return {
+ success: false,
+ message: "계약서를 찾을 수 없습니다."
+ }
+ }
+
+ const contractData = contract[0]
+
+ // 최종승인 가능 상태 확인
+ if (contractData.completedAt !== null) {
+ return {
+ success: false,
+ message: "이미 완료된 계약서입니다."
+ }
+ }
+
+ if (!contractData.signedFilePath) {
+ return {
+ success: false,
+ message: "협력업체 서명이 완료되지 않았습니다."
+ }
+ }
+
+ if (contractData.legalReviewRequestedAt && !contractData.legalReviewCompletedAt) {
+ return {
+ success: false,
+ message: "법무검토가 완료되지 않았습니다."
+ }
+ }
+
+ // 파일 저장 로직 (기존 파일 덮어쓰기)
+ const saveResult = await saveBuffer({
+ buffer: signedFileData,
+ fileName: fileName,
+ directory: "basicContract/signed"
+ });
+
+ if (!saveResult.success) {
+ return {
+ success: false,
+ message: `파일 저장 중 오류가 발생했습니다: ${saveResult.error}`
+ }
+ }
+
+ const currentDate = new Date()
+
+ // 계약서 상태 업데이트
+ const updatedContract = await db
+ .update(basicContract)
+ .set({
+ buyerSignedAt: currentDate,
+ completedAt: currentDate,
+ status: "COMPLETED",
+ updatedAt: currentDate,
+ filePath: saveResult.publicPath, // 웹 접근 가능한 경로로 업데이트
+ })
+ .where(eq(basicContract.id, contractId))
+ .returning()
+
+ // 캐시 재검증
+ revalidatePath("/contracts")
+
+ console.log("구매자 서명 및 최종승인 완료:", {
+ contractId,
+ buyerSigner: session.user.name || session.user.email,
+ completedAt: currentDate,
+ })
+
+ return {
+ success: true,
+ message: "계약서 최종승인이 완료되었습니다.",
+ data: {
+ contractId,
+ completedAt: currentDate,
+ }
+ }
+
+ } catch (error) {
+ console.error("구매자 서명 처리 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+/**
+ * 일괄 최종승인 (서명 다이얼로그 호출용)
+ */
+export async function prepareFinalApprovalAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string; contracts?: any[] }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select()
+ .from(basicContractView)
+ .where(inArray(basicContractView.id, contractIds))
+
+ if (contracts.length === 0) {
+ return {
+ success: false,
+ message: "선택된 계약서를 찾을 수 없습니다."
+ }
+ }
+
+ // 최종승인 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract => {
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false
+ }
+ return true
+ })
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "최종승인 가능한 계약서가 없습니다."
+ }
+ }
+
+ // 서명 다이얼로그에서 사용할 수 있는 형태로 변환
+ const contractsForSigning = eligibleContracts.map(contract => ({
+ id: contract.id,
+ templateName: contract.templateName,
+ signedFilePath: contract.signedFilePath,
+ signedFileName: contract.signedFileName,
+ vendorName: contract.vendorName,
+ vendorCode: contract.vendorCode,
+ requestedByName: "구매팀", // 최종승인자 표시
+ createdAt: contract.createdAt,
+ // 다른 필요한 필드들...
+ }))
+
+ return {
+ success: true,
+ message: `${eligibleContracts.length}건의 계약서를 최종승인할 준비가 되었습니다.`,
+ contracts: contractsForSigning
+ }
+
+ } catch (error) {
+ console.error("최종승인 준비 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 준비 중 오류가 발생했습니다.",
+ }
+ }
+}
+
+/**
+ * 서명 없이 승인만 처리 (간단한 승인 방식)
+ */
+export async function quickFinalApprovalAction(
+ contractIds: number[]
+): Promise<{ success: boolean; message: string; data?: any }> {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return {
+ success: false,
+ message: "로그인이 필요합니다."
+ }
+ }
+
+ // 계약서 정보 조회
+ const contracts = await db
+ .select()
+ .from(basicContract)
+ .where(inArray(basicContract.id, contractIds))
+
+ // 승인 가능한 계약서 필터링
+ const eligibleContracts = contracts.filter(contract => {
+ if (contract.completedAt !== null || !contract.signedFilePath) {
+ return false
+ }
+ if (contract.legalReviewRequestedAt && !contract.legalReviewCompletedAt) {
+ return false
+ }
+ return true
+ })
+
+ if (eligibleContracts.length === 0) {
+ return {
+ success: false,
+ message: "최종승인 가능한 계약서가 없습니다."
+ }
+ }
+
+ const currentDate = new Date()
+ const approver = session.user.name || session.user.email || "알 수 없음"
+
+ // 일괄 업데이트
+ const updatedContracts = await db
+ .update(basicContract)
+ .set({
+ buyerSignedAt: currentDate,
+ completedAt: currentDate,
+ status: "COMPLETED",
+ updatedAt: currentDate,
+ })
+ .where(inArray(basicContract.id, eligibleContracts.map(c => c.id)))
+ .returning({ id: basicContract.id })
+
+ // 캐시 재검증
+ revalidatePath("/contracts")
+
+ console.log("일괄 최종승인 완료:", {
+ approver,
+ contractIds: updatedContracts.map(c => c.id),
+ completedAt: currentDate,
+ })
+
+ return {
+ success: true,
+ message: `${updatedContracts.length}건의 계약서 최종승인이 완료되었습니다.`,
+ data: {
+ processedCount: updatedContracts.length,
+ contractIds: updatedContracts.map(c => c.id),
+ }
+ }
+
+ } catch (error) {
+ console.error("일괄 최종승인 중 오류:", error)
+
+ return {
+ success: false,
+ message: "최종승인 처리 중 오류가 발생했습니다. 다시 시도해 주세요.",
+ }
+ }
+}
+
+
+export async function getVendorSignatureFile() {
+ try {
+ // 세션에서 사용자 정보 가져오기
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user?.companyId) {
+ throw new Error("인증되지 않은 사용자이거나 회사 정보가 없습니다.")
+ }
+
+ // 조건에 맞는 vendor attachment 찾기
+ const signatureAttachment = await db.query.vendorAttachments.findFirst({
+ where: and(
+ eq(vendorAttachments.vendorId, session.user.companyId),
+ eq(vendorAttachments.attachmentType, "SIGNATURE")
+ )
+ })
+
+ if (!signatureAttachment) {
+ return {
+ success: false,
+ error: "서명 파일을 찾을 수 없습니다."
+ }
+ }
+
+ // 파일 읽기
+ let filePath: string;
+ const nasPath = process.env.NAS_PATH || "/evcp_nas"
+
+
+ if (process.env.NODE_ENV === 'production') {
+ // ✅ 프로덕션: NAS 경로 사용
+ filePath = path.join(nasPath, signatureAttachment.filePath);
+
+ } else {
+ // 개발: public 폴더
+ filePath = path.join(process.cwd(), 'public', signatureAttachment.filePath);
+ }
+
+ const fileBuffer = await readFile(filePath)
+
+ // Base64로 인코딩
+ const base64File = fileBuffer.toString('base64')
+
+ return {
+ success: true,
+ data: {
+ id: signatureAttachment.id,
+ fileName: signatureAttachment.fileName,
+ fileType: signatureAttachment.fileType,
+ base64: base64File,
+ // 웹에서 사용할 수 있는 data URL 형식도 제공
+ dataUrl: `data:${signatureAttachment.fileType || 'application/octet-stream'};base64,${base64File}`
+ }
+ }
+
+ } catch (error) {
+ console.error("서명 파일 조회 중 오류:", error)
+ console.log("서명 파일 조회 중 오류:", error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "파일을 읽는 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+
+
+// templateName에서 project code 추출
+function extractProjectCodeFromTemplateName(templateName: string): string | null {
+ if (!templateName.includes('GTC')) return null;
+ if (templateName.toLowerCase().includes('general')) return null;
+
+ // GTC 앞의 문자열을 추출
+ const gtcIndex = templateName.indexOf('GTC');
+ if (gtcIndex > 0) {
+ const beforeGTC = templateName.substring(0, gtcIndex).trim();
+ // 마지막 단어를 project code로 간주
+ const words = beforeGTC.split(/\s+/);
+ return words[words.length - 1];
+ }
+
+ return null;
+}
+
+// 단일 contract에 대한 GTC 정보 확인
+async function checkGTCCommentsForContract(
+ templateName: string,
+ vendorId: number,
+ basicContractId?: number
+): Promise<{ gtcDocumentId: number | null; hasComments: boolean }> {
+ try {
+ const projectCode = extractProjectCodeFromTemplateName(templateName);
+ let gtcDocumentId: number | null = null;
+
+ console.log(projectCode,"projectCode")
+
+ // 1. GTC Document ID 찾기
+ if (projectCode && projectCode.trim() !== '') {
+ // Project GTC인 경우
+ const project = await db
+ .select({ id: projects.id })
+ .from(projects)
+ .where(eq(projects.code, projectCode.trim()))
+ .limit(1)
+
+ if (project.length > 0) {
+ const projectGtcDoc = await db
+ .select({ id: gtcDocuments.id })
+ .from(gtcDocuments)
+ .where(
+ and(
+ eq(gtcDocuments.projectId, project[0].id),
+ eq(gtcDocuments.isActive, true)
+ )
+ )
+ .orderBy(desc(gtcDocuments.revision))
+ .limit(1)
+
+ if (projectGtcDoc.length > 0) {
+ gtcDocumentId = projectGtcDoc[0].id
+ }
+ }
+ } else {
+ // Standard GTC인 경우 (general 포함하거나 project code가 없는 경우)
+ console.log(`🔍 [checkGTCCommentsForContract] Standard GTC 조회 중...`);
+ const standardGtcDoc = await db
+ .select({ id: gtcDocuments.id })
+ .from(gtcDocuments)
+ .where(
+ and(
+ eq(gtcDocuments.type, "standard"),
+ eq(gtcDocuments.isActive, true),
+ isNull(gtcDocuments.projectId)
+ )
+ )
+ .orderBy(desc(gtcDocuments.revision))
+ .limit(1)
+
+ console.log(`📊 [checkGTCCommentsForContract] Standard GTC 조회 결과: ${standardGtcDoc.length}개`);
+
+ if (standardGtcDoc.length > 0) {
+ gtcDocumentId = standardGtcDoc[0].id
+ console.log(`✅ [checkGTCCommentsForContract] Standard GTC 찾음: ${gtcDocumentId}`);
+ } else {
+ console.log(`❌ [checkGTCCommentsForContract] Standard GTC 없음 - gtc_documents 테이블에 standard 타입의 활성 문서가 없습니다`);
+ }
+ }
+
+ console.log(`🎯 [checkGTCCommentsForContract] 최종 gtcDocumentId: ${gtcDocumentId}`)
+
+ // GTC Document를 찾지 못한 경우
+ if (basicContractId) {
+ console.log(`🔍 [checkGTCCommentsForContract] basicContractId: ${basicContractId} 로 코멘트 조회`);
+ const { agreementComments } = await import("@/db/schema");
+ const newComments = await db
+ .select({ id: agreementComments.id })
+ .from(agreementComments)
+ .where(
+ and(
+ eq(agreementComments.basicContractId, basicContractId),
+ eq(agreementComments.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ console.log(`📊 [checkGTCCommentsForContract] basicContractId ${basicContractId}: 코멘트 ${newComments.length}개 발견`);
+
+ if (newComments.length > 0) {
+ console.log(`✅ [checkGTCCommentsForContract] basicContractId ${basicContractId}: hasComments = true 반환`);
+ return {
+ gtcDocumentId, // null일 수 있음
+ hasComments: true
+ };
+ }
+
+ console.log(`⚠️ [checkGTCCommentsForContract] basicContractId ${basicContractId}: agreementComments 없음`);
+ }
+
+ // GTC Document를 찾지 못한 경우 (기존 방식도 체크할 수 없음)
+ if (!gtcDocumentId) {
+ console.log(`⚠️ [checkGTCCommentsForContract] gtcDocumentId null - 기존 방식 체크 불가`);
+ return { gtcDocumentId: null, hasComments: false };
+ }
+
+ // 2. 코멘트 존재 여부 확인
+ // gtcDocumentId로 해당 벤더의 vendor documents 찾기
+ const vendorDocuments = await db
+ .select({ id: gtcVendorDocuments.id })
+ .from(gtcVendorDocuments)
+ .where(
+ and(
+ eq(gtcVendorDocuments.baseDocumentId, gtcDocumentId),
+ eq(gtcVendorDocuments.vendorId, vendorId),
+ eq(gtcVendorDocuments.isActive, true)
+ )
+ )
+ .limit(1)
+
+ if (vendorDocuments.length === 0) {
+ return { gtcDocumentId, hasComments: false };
+ }
+
+ // vendor document에 연결된 clauses에서 negotiation history 확인
+ const commentsExist = await db
+ .select({ count: gtcNegotiationHistory.id })
+ .from(gtcNegotiationHistory)
+ .innerJoin(
+ gtcVendorClauses,
+ eq(gtcNegotiationHistory.vendorClauseId, gtcVendorClauses.id)
+ )
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocuments[0].id),
+ eq(gtcVendorClauses.isActive, true),
+ isNotNull(gtcNegotiationHistory.comment),
+ ne(gtcNegotiationHistory.comment, '')
+ )
+ )
+ .limit(1)
+
+ return {
+ gtcDocumentId,
+ hasComments: commentsExist.length > 0
+ };
+
+ } catch (error) {
+ console.error('Error checking GTC comments for contract:', error);
+ return { gtcDocumentId: null, hasComments: false };
+ }
+}
+
+// // 전체 contract 리스트에 대해 GTC document ID와 comment 정보 수집
+// export async function checkGTCCommentsForContracts(
+// contracts: BasicContractView[]
+// ): Promise<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>> {
+// const gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> = {};
+
+// // GTC가 포함된 contract만 필터링
+// const gtcContracts = contracts.filter(contract =>
+// contract.templateName?.includes('GTC')
+// );
+
+// if (gtcContracts.length === 0) {
+// return gtcData;
+// }
+
+// // Promise.all을 사용해서 병렬 처리
+// const checkPromises = gtcContracts.map(async (contract) => {
+// try {
+// const result = await checkGTCCommentsForContract(
+// contract.templateName!,
+// contract.vendorId!
+// );
+
+// return {
+// contractId: contract.id,
+// gtcDocumentId: result.gtcDocumentId,
+// hasComments: result.hasComments
+// };
+// } catch (error) {
+// console.error(`Error checking GTC for contract ${contract.id}:`, error);
+// return {
+// contractId: contract.id,
+// gtcDocumentId: null,
+// hasComments: false
+// };
+// }
+// });
+
+// const results = await Promise.all(checkPromises);
+
+// // 결과를 Record 형태로 변환
+// results.forEach(({ contractId, gtcDocumentId, hasComments }) => {
+// gtcData[contractId] = { gtcDocumentId, hasComments };
+// });
+
+// return gtcData;
+// }
+
+
+
+export async function updateVendorDocumentStatus(
+ formData: FormData | {
+ status: string;
+ vendorDocumentId: number;
+ documentId: number;
+ vendorId: number;
+ }
+) {
+ try {
+ // 세션 확인
+ const session = await getServerSession(authOptions)
+ if (!session?.user) {
+ return { success: false, error: "인증되지 않은 사용자입니다." }
+ }
+
+ // 데이터 파싱
+ const rawData = formData instanceof FormData
+ ? {
+ status: formData.get("status") as string,
+ vendorDocumentId: Number(formData.get("vendorDocumentId")),
+ documentId: Number(formData.get("documentId")),
+ vendorId: Number(formData.get("vendorId")),
+ }
+ : formData
+
+ // 유효성 검사
+ const validatedData = updateStatusSchema.safeParse(rawData)
+ if (!validatedData.success) {
+ return { success: false, error: "유효하지 않은 데이터입니다." }
+ }
+
+ const { status, vendorDocumentId, documentId, vendorId } = validatedData.data
+
+ // 완료 상태로 변경 시, 모든 조항이 approved 상태인지 확인
+ if (status === "complete") {
+ // 승인되지 않은 조항 확인
+ const pendingClauses = await db
+ .select({ id: gtcVendorClauses.id })
+ .from(gtcVendorClauses)
+ .where(
+ and(
+ eq(gtcVendorClauses.vendorDocumentId, vendorDocumentId),
+ eq(gtcVendorClauses.isActive, true),
+ not(eq(gtcVendorClauses.reviewStatus, "approved")),
+ not(eq(gtcVendorClauses.isExcluded, true)) // 제외된 조항은 검사에서 제외
+ )
+ )
+ .limit(1)
+
+ if (pendingClauses.length > 0) {
+ return {
+ success: false,
+ error: "모든 조항이 승인되어야 협의 완료 처리가 가능합니다."
+ }
+ }
+ }
+
+ // 업데이트 실행
+ await db
+ .update(gtcVendorDocuments)
+ .set({
+ reviewStatus: status,
+ updatedAt: new Date(),
+ updatedById: Number(session.user.id),
+ // 완료 처리 시 협의 종료일 설정
+ ...(status === "complete" ? {
+ negotiationEndDate: new Date(),
+ approvalDate: new Date()
+ } : {})
+ })
+ .where(eq(gtcVendorDocuments.id, vendorDocumentId))
+
+ // 캐시 무효화
+ // revalidatePath(`/evcp/gtc/${documentId}?vendorId=${vendorId}`)
+
+ return { success: true }
+ } catch (error) {
+ console.error("Error updating vendor document status:", error)
+ return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }
+ }
+}
+
+
+interface SaveDocumentParams {
+ documentId: number
+ pdfBuffer: Uint8Array
+ originalFileName: string
+ vendor: {
+ vendorName: string
+ }
+ userId: number
+}
+
+export async function saveGtcDocumentAction({
+ documentId,
+ pdfBuffer,
+ originalFileName,
+ vendor
+}: SaveDocumentParams) {
+ try {
+ console.log("📄 GTC 문서 저장 시작:", {
+ documentId,
+ documentIdType: typeof documentId,
+ vendorName: vendor.vendorName,
+ originalFileName,
+ bufferSize: pdfBuffer.length
+ })
+
+ // documentId 유효성 검사
+ if (!documentId || isNaN(Number(documentId))) {
+ throw new Error(`유효하지 않은 문서 ID: ${documentId}`)
+ }
+
+ // 기본계약 존재 여부 확인
+ const existingContract = await db.query.basicContract.findFirst({
+ where: eq(basicContract.id, documentId),
+ })
+
+ if (!existingContract) {
+ throw new Error(`기본계약을 찾을 수 없습니다. ID: ${documentId}`)
+ }
+
+ console.log("📋 기존 계약 정보:", {
+ contractId: existingContract.id,
+ templateId: existingContract.templateId,
+ vendorId: existingContract.vendorId,
+ currentStatus: existingContract.status,
+ currentFileName: existingContract.fileName,
+ currentFilePath: existingContract.filePath
+ })
+
+ const session = await getServerSession(authOptions)
+
+ if (!session?.user) {
+ return { success: false, error: "인증되지 않은 사용자입니다." }
+ }
+ const userId = Number(session.user.id);
+
+ // 1. PDF 파일명 생성
+ const baseName = originalFileName.replace(/\.[^/.]+$/, "") // 확장자 제거
+ const fileName = `GTC_${vendor.vendorName}_${baseName}_${new Date().toISOString().split('T')[0]}.pdf`
+
+ // 2. 파일 저장 (공용 파일 저장 함수 사용)
+ const saveResult = await saveBuffer({
+ buffer: Buffer.from(pdfBuffer),
+ fileName,
+ directory: 'basicContract',
+ originalName: fileName,
+ userId: userId.toString()
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || '파일 저장 실패')
+ }
+
+ // 3. 데이터베이스 업데이트 - 트랜잭션으로 처리하고 결과 확인
+ const updateResult = await db.update(basicContract)
+ .set({
+ fileName: saveResult.fileName!,
+ filePath: saveResult.publicPath!,
+ status: 'PENDING',
+ // 기존 서명 관련 timestamp들 리셋
+ vendorSignedAt: null,
+ buyerSignedAt: null,
+ legalReviewRequestedAt: null,
+ legalReviewCompletedAt: null,
+ updatedAt: new Date()
+ })
+ .where(eq(basicContract.id, documentId))
+ .returning({
+ id: basicContract.id,
+ fileName: basicContract.fileName,
+ filePath: basicContract.filePath
+ })
+
+ // DB 업데이트 성공 여부 확인
+ if (!updateResult || updateResult.length === 0) {
+ throw new Error(`기본계약 ID ${documentId}를 찾을 수 없거나 업데이트에 실패했습니다.`)
+ }
+
+ console.log("✅ GTC 문서 저장 완료:", {
+ documentId,
+ updatedRecord: updateResult[0],
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize
+ })
+
+ // 캐시 무효화
+ revalidateTag("basic-contract-requests")
+ revalidateTag("basic-contracts")
+ revalidatePath("/partners/basic-contract")
+
+ return {
+ success: true,
+ fileName: saveResult.fileName,
+ filePath: saveResult.publicPath,
+ fileSize: saveResult.fileSize,
+ documentId: updateResult[0].id
+ }
+
+ } catch (error) {
+ console.error("❌ GTC 문서 저장 실패:", error)
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '문서 저장 중 오류가 발생했습니다'
+ }
+ }
} \ No newline at end of file