summaryrefslogtreecommitdiff
path: root/lib/general-contract-template/service.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-12 08:01:02 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-12 08:01:02 +0000
commita9575387c3a765a1a65ebc179dae16a21af6eb25 (patch)
tree347f2b17b07039080fb2f116460004ba0b75a779 /lib/general-contract-template/service.ts
parent47e527f5f763658600696ee58451fb666e692f5a (diff)
(임수민) 일반 계약 템플릿 구현 및 basic contract 필터 수정
Diffstat (limited to 'lib/general-contract-template/service.ts')
-rw-r--r--lib/general-contract-template/service.ts626
1 files changed, 626 insertions, 0 deletions
diff --git a/lib/general-contract-template/service.ts b/lib/general-contract-template/service.ts
new file mode 100644
index 00000000..9b3eda68
--- /dev/null
+++ b/lib/general-contract-template/service.ts
@@ -0,0 +1,626 @@
+"use server";
+
+import { revalidateTag, revalidatePath, 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, or, eq, type SQL, sql } from "drizzle-orm";
+import { getServerSession } from "next-auth/next";
+import { authOptions } from "@/app/api/auth/[...nextauth]/route";
+
+import { GeneralContractTemplate, generalContractTemplates, users } from "@/db/schema";
+import { deleteFile, saveFile } from "@/lib/file-stroage";
+import {
+ selectContractTemplates,
+ selectContractTemplatesWithUsers,
+ countContractTemplates,
+ insertContractTemplate,
+ getContractTemplateById as getContractTemplateByIdFromRepo,
+ updateContractTemplate,
+ deleteContractTemplates,
+ findAllContractTemplates
+} from "./repository";
+import {
+ GetContractTemplatesSchema,
+ CreateContractTemplateSchema,
+ UpdateContractTemplateSchema,
+ DeleteContractTemplateSchema
+} from "./validations";
+
+// ----------------------------------------------------------------------------------------------------
+
+/* HELPER FUNCTION FOR GETTING CURRENT USER ID */
+async function getCurrentUserId(): Promise<number> {
+ try {
+ const session = await getServerSession(authOptions);
+ return session?.user?.id ? Number(session.user.id) : 3; // 기본값 3, 실제 환경에서는 적절한 기본값 설정
+ } catch (error) {
+ console.error('Error getting current user ID:', error);
+ return 3; // 기본값 3
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+// =================================================================================
+// Contract Template Functions
+// =================================================================================
+
+export async function getContractTemplates(
+ input: GetContractTemplatesSchema
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const { data, total } = await db.transaction(async (tx) => {
+ // 필터 조건 구성
+ let whereCondition: any = undefined;
+
+ if (input.search) {
+ const s = `%${input.search}%`;
+ whereCondition = or(
+ ilike(generalContractTemplates.contractTemplateName, s),
+ ilike(generalContractTemplates.contractTemplateType, s),
+ ilike(generalContractTemplates.fileName, s)
+ );
+ }
+
+ // 필터 추가 (기본적으로 모든 상태 표시)
+ let statusCondition: any = undefined;
+
+ if (input.filters && input.filters.length > 0) {
+ const statusFilter = input.filters.find(f => f.id === 'status');
+ if (statusFilter && statusFilter.value.length > 0) {
+ // statusFilter.value가 문자열이면 배열로 변환
+ const statusValues = Array.isArray(statusFilter.value)
+ ? statusFilter.value
+ : [statusFilter.value];
+
+ // "ALL"이 포함되어 있으면 상태 필터를 제거 (모든 상태 표시)
+ if (statusValues.includes('ALL')) {
+ statusCondition = undefined;
+ } else {
+ statusCondition = inArray(generalContractTemplates.status, statusValues);
+ }
+ }
+
+ // 다른 필터들 처리
+ const otherFilters = input.filters.filter(f => f.id !== 'status' && f.value.length > 0);
+ for (const filter of otherFilters) {
+ let filterCondition: any;
+
+ // 계약문서명은 부분 일치 검색 (ilike)
+ if (filter.id === 'contractTemplateName') {
+ const searchValue = `%${filter.value}%`;
+ filterCondition = ilike(generalContractTemplates.contractTemplateName, searchValue);
+ } else {
+ // 다른 필터들은 정확히 일치 검색 (inArray)
+ filterCondition = inArray(
+ generalContractTemplates[filter.id as keyof typeof generalContractTemplates] as any,
+ filter.value
+ );
+ }
+
+ whereCondition = whereCondition
+ ? and(whereCondition, filterCondition)
+ : filterCondition;
+ }
+ }
+
+ // 최종 where 조건
+ if (statusCondition) {
+ if (whereCondition) {
+ whereCondition = and(whereCondition, statusCondition);
+ } else {
+ whereCondition = statusCondition;
+ }
+ }
+
+
+ // 정렬 조건
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(generalContractTemplates[item.id as keyof typeof generalContractTemplates] as any)
+ : asc(generalContractTemplates[item.id as keyof typeof generalContractTemplates] as any)
+ )
+ : [desc(generalContractTemplates.createdAt)];
+
+ // 데이터 조회 (사용자 정보 포함)
+ const offset = (input.page - 1) * input.perPage;
+ const dataResult = await selectContractTemplatesWithUsers(tx, {
+ where: whereCondition,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 총 개수 조회
+ const totalCount = await countContractTemplates(tx, whereCondition);
+
+ return { data: dataResult, total: totalCount };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (error) {
+ console.error("getContractTemplates 에러:", error);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["general-contract-templates"],
+ }
+ )();
+}
+
+/**
+ * ID로 계약 템플릿 조회
+ */
+export async function getContractTemplateById(id: string) {
+ return unstable_cache(
+ async () => {
+ try {
+ const template = await db
+ .select()
+ .from(generalContractTemplates)
+ .where(eq(generalContractTemplates.id, parseInt(id)))
+ .limit(1);
+ return template[0] || null;
+ } catch (error) {
+ console.error("getContractTemplateById 에러:", error);
+ return null;
+ }
+ },
+ [id],
+ { revalidate: 3600, tags: ["general-contract-templates"] }
+ )();
+}
+
+/**
+ * 템플릿 이름 조회
+ */
+export async function getExistingTemplateNamesById(id: number): Promise<string> {
+ const rows = await db
+ .select({
+ contractTemplateName: generalContractTemplates.contractTemplateName,
+ })
+ .from(generalContractTemplates)
+ .where(and(eq(generalContractTemplates.status, "ACTIVE"), eq(generalContractTemplates.id, id)))
+ .limit(1);
+
+ return rows[0]?.contractTemplateName || "";
+}
+
+/**
+ * 템플릿 파일 저장 서버 액션
+ */
+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(generalContractTemplates)
+ .where(eq(generalContractTemplates.id, templateId))
+ .limit(1);
+
+ if (existingTemplate.length === 0) {
+ return { error: "템플릿을 찾을 수 없습니다." };
+ }
+
+ const template = existingTemplate[0];
+ if (!template.filePath) {
+ return { error: "파일 경로가 없습니다." };
+ }
+
+ // 파일 저장 로직 (실제 파일 시스템에 저장)
+ const { writeFile, mkdir } = await import("fs/promises");
+ const { join } = await import("path");
+
+ const bytes = await file.arrayBuffer();
+ const buffer = Buffer.from(bytes);
+
+ // 기존 파일 경로 사용 (덮어쓰기) - general-contract-templates 경로로 통일
+ 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);
+
+ // 캐시 무효화 (목록/상세 모두 고려)
+ revalidatePath(`/evcp/general-contract-template/${templateId}`);
+ revalidateTag("general-contract-templates");
+
+ return { success: true, message: "파일이 성공적으로 저장되었습니다." };
+ } catch (error) {
+ console.error("saveTemplateFile 에러:", error);
+ return { error: error instanceof Error ? error.message : "파일 저장 중 오류가 발생했습니다." };
+ }
+}
+
+// 새 리비전 생성 (basic-contract의 createBasicContractTemplateRevision 패턴 반영)
+export async function createGeneralContractTemplateRevision(input: {
+ baseTemplateId: number;
+ contractTemplateType: string;
+ contractTemplateName: string;
+ revision: number;
+ legalReviewRequired: boolean;
+ status: 'ACTIVE' | 'INACTIVE' | 'DISPOSED';
+ fileName: string;
+ filePath: string;
+}) {
+ unstable_noStore();
+
+ try {
+ // 기본 템플릿 존재 확인
+ const base = await db
+ .select()
+ .from(generalContractTemplates)
+ .where(eq(generalContractTemplates.id, input.baseTemplateId))
+ .limit(1);
+ if (base.length === 0) {
+ return { data: null, error: '기본 템플릿을 찾을 수 없습니다.' };
+ }
+
+ // 동일 이름/타입에 해당 리비전이 이미 존재하는지 검사
+ const exists = await db
+ .select({ rev: generalContractTemplates.revision })
+ .from(generalContractTemplates)
+ .where(
+ and(
+ eq(generalContractTemplates.contractTemplateName, input.contractTemplateName),
+ eq(generalContractTemplates.contractTemplateType, input.contractTemplateType),
+ eq(generalContractTemplates.revision, input.revision)
+ )
+ )
+ .limit(1);
+ if (exists.length > 0) {
+ return { data: null, error: `${input.contractTemplateName} v${input.revision} 리비전이 이미 존재합니다.` };
+ }
+
+ // 기존 리비전 확인 - baseTemplateId가 있으면 해당 템플릿의 최대 리비전을 찾음
+ let maxRevision = 0;
+ if (input.baseTemplateId) {
+ const max = await db
+ .select({ rev: generalContractTemplates.revision })
+ .from(generalContractTemplates)
+ .where(eq(generalContractTemplates.id, input.baseTemplateId))
+ .limit(1);
+ maxRevision = max[0]?.rev ?? 0;
+ } else {
+ // baseTemplateId가 없으면 이름과 타입으로 찾음
+ const max = await db
+ .select({ rev: generalContractTemplates.revision })
+ .from(generalContractTemplates)
+ .where(
+ and(
+ eq(generalContractTemplates.contractTemplateName, input.contractTemplateName),
+ eq(generalContractTemplates.contractTemplateType, input.contractTemplateType)
+ )
+ )
+ .orderBy(desc(generalContractTemplates.revision))
+ .limit(1);
+ maxRevision = max[0]?.rev ?? 0;
+ }
+
+ if (input.revision <= maxRevision) {
+ return { data: null, error: `새 리비전 번호는 현재 최대 리비전(v${maxRevision})보다 커야 합니다.` };
+ }
+
+ const currentUserId = await getCurrentUserId();
+
+ const newRow = await db.transaction(async (tx) => {
+ const [row] = await insertContractTemplate(tx, {
+ contractTemplateType: input.contractTemplateType,
+ contractTemplateName: input.contractTemplateName,
+ revision: input.revision,
+ status: input.status,
+ legalReviewRequired: input.legalReviewRequired,
+ fileName: input.fileName,
+ filePath: input.filePath,
+ createdAt: new Date(),
+ createdBy: currentUserId,
+ updatedAt: new Date(),
+ updatedBy: currentUserId,
+ });
+ return row;
+ });
+
+ revalidateTag('general-contract-templates');
+ return { data: newRow, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+// Contract Template 생성
+export async function createContractTemplate(input: CreateContractTemplateSchema) {
+ unstable_noStore();
+
+ try {
+ // 현재 로그인한 사용자 ID 가져오기
+ const currentUserId = await getCurrentUserId();
+
+ const newTemplate = await db.transaction(async (tx) => {
+ const [row] = await insertContractTemplate(tx, {
+ contractTemplateType: (input as any).contractTemplateType ?? (input as any).contractType,
+ contractTemplateName: (input as any).contractTemplateName ?? (input as any).contractName,
+ revision: input.revision || 1,
+ status: input.status || "ACTIVE",
+ legalReviewRequired: input.legalReviewRequired || false,
+ fileName: input.fileName || null,
+ filePath: input.filePath || null,
+ createdAt: new Date(),
+ createdBy: currentUserId,
+ updatedAt: new Date(),
+ updatedBy: currentUserId,
+ });
+ return row;
+ });
+
+ revalidateTag("general-contract-templates");
+ revalidatePath("/evcp/general-contract-template");
+ return { data: newTemplate, error: null };
+ } catch (error) {
+ console.log(error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+// Contract Template 수정
+// Basic Contract 방식과 동일한 통합 업데이트 함수
+export async function updateTemplate({
+ id,
+ formData
+}: {
+ id: number;
+ formData: FormData;
+}): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ try {
+ // 필수값 파싱
+ const contractTemplateType = formData.get("contractTemplateType") as string | null;
+ const contractTemplateName = formData.get("contractTemplateName") as string | null;
+ const legalReviewRequired = formData.get("legalReviewRequired") === "true";
+
+ // 리비전 처리: basic-contract와 동일하게 FormData에서 그대로 사용 (없으면 1)
+
+ if (!contractTemplateType || !contractTemplateName) {
+ return { error: "계약 종류와 문서명은 필수입니다." };
+ }
+
+ // 기존 템플릿 조회
+ const existing = await db
+ .select()
+ .from(generalContractTemplates)
+ .where(eq(generalContractTemplates.id, id))
+ .limit(1);
+
+ if (existing.length === 0) {
+ return { error: "템플릿을 찾을 수 없습니다." };
+ }
+
+ const prev = existing[0] as any;
+
+ // 모든 경우에 기존 레코드 업데이트 (새 리비전 생성하지 않음)
+
+ // 파일 처리
+ const file = formData.get("file") as File | null;
+ let fileName: string | undefined = undefined;
+ let filePath: string | undefined = undefined;
+
+ if (file) {
+ // 1) 새 파일 저장 (원본 파일명 유지 + 충돌 시 접미사)
+ const { mkdir, writeFile, access } = await import('fs/promises');
+ const { join, extname, basename } = await import('path');
+
+ const ext = extname(file.name);
+ const base = basename(file.name, ext)
+ .replace(/[<>:"'|?*\\\/]/g, '_')
+ .replace(/[\x00-\x1f]/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .substring(0, 200);
+
+ const dirAbs = join(process.cwd(), 'public', 'general-contract-templates');
+ await mkdir(dirAbs, { recursive: true });
+
+ let candidate = `${base}${ext}`;
+ let absPath = join(dirAbs, candidate);
+ let counter = 1;
+ while (true) {
+ try {
+ await access(absPath);
+ candidate = `${base} (${counter})${ext}`;
+ absPath = join(dirAbs, candidate);
+ counter += 1;
+ } catch {
+ break;
+ }
+ }
+
+ const bytes = await file.arrayBuffer();
+ await writeFile(absPath, Buffer.from(bytes));
+
+ fileName = candidate;
+ filePath = `/general-contract-templates/${candidate}`;
+
+ // 2) 기존 파일 삭제
+ if (prev.filePath) {
+ const deleted = await deleteFile(prev.filePath);
+ if (deleted) {
+ console.log(`✅ 기존 파일 삭제됨: ${prev.filePath}`);
+ } else {
+ console.log(`⚠️ 기존 파일 삭제 실패: ${prev.filePath}`);
+ }
+ }
+ }
+
+ // 모든 경우에 기존 레코드 업데이트 (revision은 위 정책에 따라 결정)
+ const currentUserId = await getCurrentUserId();
+
+ // 리비전 처리: FormData에 있으면 사용, 없으면 현재 리비전 + 1
+ const revisionFromForm = formData.get("revision")?.toString();
+ let nextRevision: number;
+
+ if (revisionFromForm) {
+ nextRevision = Number(revisionFromForm) || 1;
+ } else {
+ nextRevision = (prev.revision ?? 0) + 1; // FormData에 없으면 현재 리비전 + 1
+ }
+
+ const updateData: Record<string, any> = {
+ contractTemplateType,
+ contractTemplateName,
+ legalReviewRequired,
+ revision: nextRevision, // 최종 결정된 리비전 사용
+ updatedAt: new Date(),
+ updatedBy: currentUserId,
+ };
+
+ if (fileName && filePath) {
+ updateData.fileName = fileName;
+ updateData.filePath = filePath;
+ }
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(generalContractTemplates)
+ .set(updateData)
+ .where(eq(generalContractTemplates.id, id));
+ });
+
+ revalidateTag('general-contract-templates');
+ revalidatePath('/evcp/general-contract-template');
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 업데이트 오류:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 기존 함수는 호환성을 위해 유지
+export async function updateContractTemplateById(
+ id: number,
+ input: UpdateContractTemplateSchema
+) {
+ unstable_noStore();
+
+ try {
+ const currentUserId = await getCurrentUserId();
+ const updatedTemplate = await db.transaction(async (tx) => {
+ const [row] = await updateContractTemplate(tx, id, input, currentUserId);
+ return row;
+ });
+
+ revalidateTag("general-contract-templates");
+ revalidatePath("/evcp/general-contract-template");
+ return { data: updatedTemplate, error: null };
+ } catch (error) {
+ console.log(error);
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+// Contract Template 삭제
+export async function removeTemplates({
+ ids
+}: {
+ ids: number[];
+}): Promise<{ success?: boolean; error?: string }> {
+ if (!ids || ids.length === 0) {
+ return { error: "삭제할 템플릿이 선택되지 않았습니다." };
+ }
+
+ // unstable_noStore를 최상단에 배치
+ unstable_noStore();
+
+ try {
+ // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리
+ // withTransaction 대신 db.transaction 직접 사용 (createContractTemplate와 일관성 유지)
+ const templateFiles: { id: number; filePath: string }[] = [];
+
+ const result = await db.transaction(async (tx) => {
+ // 각 템플릿의 파일 경로 가져오기
+ for (const id of ids) {
+ const template = await getContractTemplateByIdFromRepo(tx, id);
+ if (template && template.filePath) {
+ templateFiles.push({
+ id: template.id,
+ filePath: template.filePath
+ });
+ }
+ }
+
+ // DB에서 템플릿 삭제
+ const { data, error } = await deleteContractTemplates(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("general-contract-templates");
+ revalidateTag("template-status-counts");
+
+ // 디버깅을 위한 로그
+ console.log("캐시 무효화 완료:", ids);
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+// 모든 활성 Contract Template 목록 (간단한 선택용)
+export async function getAllActiveContractTemplates() {
+ unstable_noStore();
+
+ try {
+ const templates = await db.transaction(async (tx) => {
+ return await findAllContractTemplates(tx);
+ });
+
+ return { data: templates, error: null };
+ } catch (error) {
+ console.log(error);
+ return { data: [], error: getErrorMessage(error) };
+ }
+}
+