summaryrefslogtreecommitdiff
path: root/lib/legal-review/service.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/legal-review/service.ts')
-rw-r--r--lib/legal-review/service.ts738
1 files changed, 738 insertions, 0 deletions
diff --git a/lib/legal-review/service.ts b/lib/legal-review/service.ts
new file mode 100644
index 00000000..bc55a1fc
--- /dev/null
+++ b/lib/legal-review/service.ts
@@ -0,0 +1,738 @@
+'use server'
+
+import { revalidatePath, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { legalWorks, legalWorkRequests, legalWorkResponses, legalWorkAttachments, vendors, legalWorksDetailView } from "@/db/schema";
+import { and, asc, count, desc, eq, ilike, or, SQL, inArray } from "drizzle-orm";
+import { CreateLegalWorkData, GetLegalWorksSchema, createLegalWorkSchema } from "./validations";
+import { filterColumns } from "@/lib/filter-columns";
+import { saveFile } from "../file-stroage";
+
+interface CreateLegalWorkResult {
+ success: boolean;
+ data?: {
+ id: number;
+ message: string;
+ };
+ error?: string;
+}
+
+
+
+export async function createLegalWork(
+ data: CreateLegalWorkData
+): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ // 1. 입력 데이터 검증
+ const validatedData = createLegalWorkSchema.parse(data);
+
+ // 2. 벤더 정보 조회
+ const vendor = await db
+ .select({
+ id: vendors.id,
+ vendorCode: vendors.vendorCode,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, validatedData.vendorId))
+ .limit(1);
+
+ if (!vendor.length) {
+ return {
+ success: false,
+ error: "선택한 벤더를 찾을 수 없습니다.",
+ };
+ }
+
+ const selectedVendor = vendor[0];
+
+ // 3. 트랜잭션으로 데이터 삽입
+ const result = await db.transaction(async (tx) => {
+ // 3-1. legal_works 테이블에 메인 데이터 삽입
+ const [legalWorkResult] = await tx
+ .insert(legalWorks)
+ .values({
+ category: validatedData.category,
+ status: "신규등록", // 초기 상태
+ vendorId: validatedData.vendorId,
+ vendorCode: selectedVendor.vendorCode,
+ vendorName: selectedVendor.vendorName,
+ isUrgent: validatedData.isUrgent,
+ requestDate: validatedData.requestDate,
+ consultationDate: new Date().toISOString().split('T')[0], // 오늘 날짜
+ hasAttachment: false, // 초기값
+ reviewer: validatedData.reviewer, // 추후 할당
+ legalResponder: null, // 추후 할당
+ })
+ .returning({ id: legalWorks.id });
+
+ const legalWorkId = legalWorkResult.id;
+
+
+
+ return { legalWorkId };
+ });
+
+ // 4. 캐시 재검증
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: result.legalWorkId,
+ message: "법무업무가 성공적으로 등록되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("createLegalWork 오류:", error);
+
+ // 데이터베이스 오류 처리
+ if (error instanceof Error) {
+ // 외래키 제약 조건 오류
+ if (error.message.includes('foreign key constraint')) {
+ return {
+ success: false,
+ error: "선택한 벤더가 유효하지 않습니다.",
+ };
+ }
+
+ // 중복 키 오류 등 기타 DB 오류
+ return {
+ success: false,
+ error: "데이터베이스 오류가 발생했습니다.",
+ };
+ }
+
+ return {
+ success: false,
+ error: "알 수 없는 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무업무 상태 업데이트 함수 (보너스)
+export async function updateLegalWorkStatus(
+ legalWorkId: number,
+ status: string,
+ reviewer?: string,
+ legalResponder?: string
+): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ const updateData: Partial<typeof legalWorks.$inferInsert> = {
+ status,
+ updatedAt: new Date(),
+ };
+
+ if (reviewer) updateData.reviewer = reviewer;
+ if (legalResponder) updateData.legalResponder = legalResponder;
+
+ await db
+ .update(legalWorks)
+ .set(updateData)
+ .where(eq(legalWorks.id, legalWorkId));
+
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: legalWorkId,
+ message: "상태가 성공적으로 업데이트되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("updateLegalWorkStatus 오류:", error);
+ return {
+ success: false,
+ error: "상태 업데이트 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+// 법무업무 삭제 함수 (보너스)
+export async function deleteLegalWork(legalWorkId: number): Promise<CreateLegalWorkResult> {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // 관련 요청 데이터 먼저 삭제
+ await tx
+ .delete(legalWorkRequests)
+ .where(eq(legalWorkRequests.legalWorkId, legalWorkId));
+
+ // 메인 법무업무 데이터 삭제
+ await tx
+ .delete(legalWorks)
+ .where(eq(legalWorks.id, legalWorkId));
+ });
+
+ revalidatePath("/legal-works");
+
+ return {
+ success: true,
+ data: {
+ id: legalWorkId,
+ message: "법무업무가 성공적으로 삭제되었습니다.",
+ },
+ };
+
+ } catch (error) {
+ console.error("deleteLegalWork 오류:", error);
+ return {
+ success: false,
+ error: "삭제 중 오류가 발생했습니다.",
+ };
+ }
+}
+
+
+export async function getLegalWorks(input: GetLegalWorksSchema) {
+ unstable_noStore(); // ✅ 1. 캐싱 방지 추가
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // ✅ 2. 안전한 필터 처리 (getEvaluationTargets와 동일)
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+
+ if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
+ console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
+
+ try {
+ advancedWhere = filterColumns({
+ table: legalWorksDetailView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+
+ console.log("필터 조건 생성 완료");
+ } catch (error) {
+ console.error("필터 조건 생성 오류:", error);
+ // ✅ 필터 오류 시에도 전체 데이터 반환
+ advancedWhere = undefined;
+ }
+ }
+
+ // ✅ 3. 안전한 글로벌 검색 처리
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const searchTerm = `%${input.search}%`;
+
+ const searchConditions: SQL<unknown>[] = [
+ ilike(legalWorksDetailView.vendorCode, searchTerm),
+ ilike(legalWorksDetailView.vendorName, searchTerm),
+ ilike(legalWorksDetailView.title, searchTerm),
+ ilike(legalWorksDetailView.requestContent, searchTerm),
+ ilike(legalWorksDetailView.reviewer, searchTerm),
+ ilike(legalWorksDetailView.legalResponder, searchTerm)
+ ].filter(Boolean);
+
+ if (searchConditions.length > 0) {
+ globalWhere = or(...searchConditions);
+ }
+ }
+
+ // ✅ 4. 안전한 WHERE 조건 결합
+ const whereConditions: SQL<unknown>[] = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // ✅ 5. 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(legalWorksDetailView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("총 데이터 수:", total);
+
+ // ✅ 6. 정렬 및 페이징 처리
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof legalWorksDetailView.$inferSelect;
+ return sort.desc
+ ? desc(legalWorksDetailView[column])
+ : asc(legalWorksDetailView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(legalWorksDetailView.createdAt));
+ }
+
+ const legalWorksData = await db
+ .select()
+ .from(legalWorksDetailView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("반환 데이터 수:", legalWorksData.length);
+
+ return { data: legalWorksData, pageCount, total };
+ } catch (err) {
+ console.error("getLegalWorks 오류:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+}
+// 특정 법무업무 상세 조회
+export async function getLegalWorkById(id: number) {
+ unstable_noStore();
+
+ try {
+ const result = await db
+ .select()
+ .from(legalWorksDetailView)
+ .where(eq(legalWorksDetailView.id , id))
+ .limit(1);
+
+ return result[0] || null;
+ } catch (error) {
+ console.error("getLegalWorkById 오류:", error);
+ return null;
+ }
+}
+
+// 법무업무 통계 (뷰 테이블 사용)
+export async function getLegalWorksStats() {
+ unstable_noStore();
+ try {
+ // 전체 통계
+ const totalStats = await db
+ .select({
+ total: count(),
+ category: legalWorksDetailView.category,
+ status: legalWorksDetailView.status,
+ isUrgent: legalWorksDetailView.isUrgent,
+ })
+ .from(legalWorksDetailView);
+
+ // 통계 데이터 가공
+ const stats = {
+ total: totalStats.length,
+ byCategory: {} as Record<string, number>,
+ byStatus: {} as Record<string, number>,
+ urgent: 0,
+ };
+
+ totalStats.forEach(stat => {
+ // 카테고리별 집계
+ if (stat.category) {
+ stats.byCategory[stat.category] = (stats.byCategory[stat.category] || 0) + 1;
+ }
+
+ // 상태별 집계
+ if (stat.status) {
+ stats.byStatus[stat.status] = (stats.byStatus[stat.status] || 0) + 1;
+ }
+
+ // 긴급 건수
+ if (stat.isUrgent) {
+ stats.urgent++;
+ }
+ });
+
+ return stats;
+ } catch (error) {
+ console.error("getLegalWorksStatsSimple 오류:", error);
+ return {
+ total: 0,
+ byCategory: {},
+ byStatus: {},
+ urgent: 0,
+ };
+ }
+}
+
+// 검토요청 폼 데이터 타입
+interface RequestReviewData {
+ // 기본 설정
+ dueDate: string
+ assignee?: string
+ notificationMethod: "email" | "internal" | "both"
+
+ // 법무업무 상세 정보
+ reviewDepartment: "준법문의" | "법무검토"
+ inquiryType?: "국내계약" | "국내자문" | "해외계약" | "해외자문"
+
+ // 공통 필드
+ title: string
+ requestContent: string
+
+ // 준법문의 전용 필드
+ isPublic?: boolean
+
+ // 법무검토 전용 필드들
+ contractProjectName?: string
+ contractType?: string
+ contractCounterparty?: string
+ counterpartyType?: "법인" | "개인"
+ contractPeriod?: string
+ contractAmount?: string
+ factualRelation?: string
+ projectNumber?: string
+ shipownerOrderer?: string
+ projectType?: string
+ governingLaw?: string
+}
+
+// 첨부파일 업로드 함수
+async function uploadAttachment(file: File, legalWorkId: number, userId?: string) {
+ try {
+ console.log(`📎 첨부파일 업로드 시작: ${file.name} (${file.size} bytes)`)
+
+ const result = await saveFile({
+ file,
+ directory: "legal-works",
+ originalName: file.name,
+ userId: userId || "system"
+ })
+
+ if (!result.success) {
+ throw new Error(result.error || "파일 업로드 실패")
+ }
+
+ console.log(`✅ 첨부파일 업로드 성공: ${result.fileName}`)
+
+ return {
+ fileName: result.fileName!,
+ originalFileName: result.originalName!,
+ filePath: result.publicPath!,
+ fileSize: result.fileSize!,
+ mimeType: file.type,
+ securityChecks: result.securityChecks
+ }
+ } catch (error) {
+ console.error(`❌ 첨부파일 업로드 실패: ${file.name}`, error)
+ throw error
+ }
+}
+
+
+export async function requestReview(
+ legalWorkId: number,
+ formData: RequestReviewData,
+ attachments: File[] = [],
+ userId?: string
+) {
+ try {
+ console.log(`🚀 검토요청 처리 시작 - 법무업무 #${legalWorkId}`)
+
+ // 트랜잭션 시작
+ const result = await db.transaction(async (tx) => {
+ // 1. legal_works 테이블 업데이트
+ const [updatedWork] = await tx
+ .update(legalWorks)
+ .set({
+ status: "검토요청",
+ expectedAnswerDate: formData.dueDate,
+ hasAttachment: attachments.length > 0,
+ updatedAt: new Date(),
+ })
+ .where(eq(legalWorks.id, legalWorkId))
+ .returning()
+
+ if (!updatedWork) {
+ throw new Error("법무업무를 찾을 수 없습니다.")
+ }
+
+ console.log(`📝 법무업무 상태 업데이트 완료: ${updatedWork.status}`)
+
+ // 2. legal_work_requests 테이블에 데이터 삽입
+ const [createdRequest] = await tx
+ .insert(legalWorkRequests)
+ .values({
+ legalWorkId: legalWorkId,
+ reviewDepartment: formData.reviewDepartment,
+ inquiryType: formData.inquiryType || null,
+ title: formData.title,
+ requestContent: formData.requestContent,
+
+ // 준법문의 관련 필드
+ isPublic: formData.reviewDepartment === "준법문의" ? (formData.isPublic || false) : null,
+
+ // 법무검토 관련 필드들
+ contractProjectName: formData.contractProjectName || null,
+ contractType: formData.contractType || null,
+ contractAmount: formData.contractAmount ? parseFloat(formData.contractAmount) : null,
+
+ // 국내계약 전용 필드들
+ contractCounterparty: formData.contractCounterparty || null,
+ counterpartyType: formData.counterpartyType || null,
+ contractPeriod: formData.contractPeriod || null,
+
+ // 자문 관련 필드
+ factualRelation: formData.factualRelation || null,
+
+ // 해외 관련 필드들
+ projectNumber: formData.projectNumber || null,
+ shipownerOrderer: formData.shipownerOrderer || null,
+ governingLaw: formData.governingLaw || null,
+ projectType: formData.projectType || null,
+ })
+ .returning()
+
+ console.log(`📋 검토요청 정보 저장 완료: ${createdRequest.reviewDepartment}`)
+
+ // 3. 첨부파일 처리
+ const uploadedFiles = []
+ const failedFiles = []
+
+ if (attachments.length > 0) {
+ console.log(`📎 첨부파일 처리 시작: ${attachments.length}개`)
+
+ for (const file of attachments) {
+ try {
+ const uploadResult = await uploadAttachment(file, legalWorkId, userId)
+
+ // DB에 첨부파일 정보 저장
+ const [attachmentRecord] = await tx
+ .insert(legalWorkAttachments)
+ .values({
+ legalWorkId: legalWorkId,
+ fileName: uploadResult.fileName,
+ originalFileName: uploadResult.originalFileName,
+ filePath: uploadResult.filePath,
+ fileSize: uploadResult.fileSize,
+ mimeType: uploadResult.mimeType,
+ attachmentType: 'request',
+ isAutoGenerated: false,
+ })
+ .returning()
+
+ uploadedFiles.push({
+ id: attachmentRecord.id,
+ name: uploadResult.originalFileName,
+ size: uploadResult.fileSize,
+ securityChecks: uploadResult.securityChecks
+ })
+
+ } catch (fileError) {
+ console.error(`❌ 파일 업로드 실패: ${file.name}`, fileError)
+ failedFiles.push({
+ name: file.name,
+ error: fileError instanceof Error ? fileError.message : "업로드 실패"
+ })
+ }
+ }
+
+ console.log(`✅ 파일 업로드 완료: 성공 ${uploadedFiles.length}개, 실패 ${failedFiles.length}개`)
+ }
+
+ return {
+ updatedWork,
+ createdRequest,
+ uploadedFiles,
+ failedFiles,
+ totalFiles: attachments.length,
+ }
+ })
+
+ // 페이지 재검증
+ revalidatePath("/legal-works")
+
+ // 성공 메시지 구성
+ let message = `검토요청이 성공적으로 발송되었습니다.`
+
+ if (result.totalFiles > 0) {
+ message += ` (첨부파일: 성공 ${result.uploadedFiles.length}개`
+ if (result.failedFiles.length > 0) {
+ message += `, 실패 ${result.failedFiles.length}개`
+ }
+ message += `)`
+ }
+
+ console.log(`🎉 검토요청 처리 완료 - 법무업무 #${legalWorkId}`)
+
+ return {
+ success: true,
+ data: {
+ message,
+ legalWorkId: legalWorkId,
+ requestId: result.createdRequest.id,
+ uploadedFiles: result.uploadedFiles,
+ failedFiles: result.failedFiles,
+ }
+ }
+
+ } catch (error) {
+ console.error(`💥 검토요청 처리 중 오류 - 법무업무 #${legalWorkId}:`, error)
+
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "검토요청 처리 중 오류가 발생했습니다."
+ }
+ }
+}
+
+
+// FormData를 사용하는 버전 (파일 업로드용)
+export async function requestReviewWithFiles(formData: FormData) {
+ try {
+ // 기본 데이터 추출
+ const legalWorkId = parseInt(formData.get("legalWorkId") as string)
+
+ const requestData: RequestReviewData = {
+ dueDate: formData.get("dueDate") as string,
+ assignee: formData.get("assignee") as string || undefined,
+ notificationMethod: formData.get("notificationMethod") as "email" | "internal" | "both",
+ reviewDepartment: formData.get("reviewDepartment") as "준법문의" | "법무검토",
+ inquiryType: formData.get("inquiryType") as "국내계약" | "국내자문" | "해외계약" | "해외자문" || undefined,
+ title: formData.get("title") as string,
+ requestContent: formData.get("requestContent") as string,
+ isPublic: formData.get("isPublic") === "true",
+
+ // 법무검토 관련 필드들
+ contractProjectName: formData.get("contractProjectName") as string || undefined,
+ contractType: formData.get("contractType") as string || undefined,
+ contractCounterparty: formData.get("contractCounterparty") as string || undefined,
+ counterpartyType: formData.get("counterpartyType") as "법인" | "개인" || undefined,
+ contractPeriod: formData.get("contractPeriod") as string || undefined,
+ contractAmount: formData.get("contractAmount") as string || undefined,
+ factualRelation: formData.get("factualRelation") as string || undefined,
+ projectNumber: formData.get("projectNumber") as string || undefined,
+ shipownerOrderer: formData.get("shipownerOrderer") as string || undefined,
+ projectType: formData.get("projectType") as string || undefined,
+ governingLaw: formData.get("governingLaw") as string || undefined,
+ }
+
+ // 첨부파일 추출
+ const attachments: File[] = []
+ for (const [key, value] of formData.entries()) {
+ if (key.startsWith("attachment_") && value instanceof File && value.size > 0) {
+ attachments.push(value)
+ }
+ }
+
+ return await requestReview(legalWorkId, requestData, attachments)
+
+ } catch (error) {
+ console.error("FormData 처리 중 오류:", error)
+ return {
+ success: false,
+ error: "요청 데이터 처리 중 오류가 발생했습니다."
+ }
+ }
+}
+
+// 검토요청 가능 여부 확인
+export async function canRequestReview(legalWorkId: number) {
+ try {
+ const [work] = await db
+ .select({ status: legalWorks.status })
+ .from(legalWorks)
+ .where(eq(legalWorks.id, legalWorkId))
+ .limit(1)
+
+ if (!work) {
+ return { canRequest: false, reason: "법무업무를 찾을 수 없습니다." }
+ }
+
+ if (work.status !== "신규등록") {
+ return {
+ canRequest: false,
+ reason: `현재 상태(${work.status})에서는 검토요청을 할 수 없습니다. 신규등록 상태에서만 가능합니다.`
+ }
+ }
+
+ return { canRequest: true }
+
+ } catch (error) {
+ console.error("검토요청 가능 여부 확인 중 오류:", error)
+ return {
+ canRequest: false,
+ reason: "상태 확인 중 오류가 발생했습니다."
+ }
+ }
+}
+
+// 삭제 요청 타입
+interface RemoveLegalWorksInput {
+ ids: number[]
+}
+
+// 응답 타입
+interface RemoveLegalWorksResponse {
+ error?: string
+ success?: boolean
+}
+
+/**
+ * 법무업무 삭제 서버 액션
+ */
+export async function removeLegalWorks({
+ ids,
+}: RemoveLegalWorksInput): Promise<RemoveLegalWorksResponse> {
+ try {
+ // 유효성 검사
+ if (!ids || ids.length === 0) {
+ return {
+ error: "삭제할 법무업무를 선택해주세요.",
+ }
+ }
+
+ // 삭제 가능한 상태인지 확인 (선택적)
+ const existingWorks = await db
+ .select({ id: legalWorks.id, status: legalWorks.status })
+ .from(legalWorks)
+ .where(inArray(legalWorks.id, ids))
+
+ // 삭제 불가능한 상태 체크 (예: 진행중인 업무는 삭제 불가)
+ const nonDeletableWorks = existingWorks.filter(
+ work => work.status === "검토중" || work.status === "담당자배정"
+ )
+
+ if (nonDeletableWorks.length > 0) {
+ return {
+ error: "진행중인 법무업무는 삭제할 수 없습니다.",
+ }
+ }
+
+ // 실제 삭제 실행
+ const result = await db
+ .delete(legalWorks)
+ .where(inArray(legalWorks.id, ids))
+
+ // 결과 확인
+ if (result.changes === 0) {
+ return {
+ error: "삭제할 법무업무를 찾을 수 없습니다.",
+ }
+ }
+
+ // 캐시 재검증
+ revalidatePath("/legal-works") // 실제 경로에 맞게 수정
+
+ return {
+ success: true,
+ }
+
+ } catch (error) {
+ console.error("법무업무 삭제 중 오류 발생:", error)
+
+ return {
+ error: "법무업무 삭제 중 오류가 발생했습니다. 다시 시도해주세요.",
+ }
+ }
+}
+
+/**
+ * 단일 법무업무 삭제 (선택적)
+ */
+export async function removeLegalWork(id: number): Promise<RemoveLegalWorksResponse> {
+ return removeLegalWorks({ ids: [id] })
+} \ No newline at end of file