diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-21 09:44:33 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-21 09:44:33 +0000 |
| commit | a2e0785c8749c4d3766ecf3b70edfb7c2fe4df20 (patch) | |
| tree | 4b03bbec838baf307b38e0c5692da8da7bde2f9b /lib/compliance/red-flag-resolution.ts | |
| parent | 204fbfb126daf057a4567f64cfb7ab03a5679e82 (diff) | |
(임수민) 준법 Red Flag 해제, 코멘트 수정
Diffstat (limited to 'lib/compliance/red-flag-resolution.ts')
| -rw-r--r-- | lib/compliance/red-flag-resolution.ts | 529 |
1 files changed, 260 insertions, 269 deletions
diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts index 184630f6..423f5a46 100644 --- a/lib/compliance/red-flag-resolution.ts +++ b/lib/compliance/red-flag-resolution.ts @@ -1,303 +1,294 @@ "use server" import db from "@/db/db" -import { eq, and } from "drizzle-orm" +import { and, eq, inArray } from "drizzle-orm" import { complianceResponses, redFlagManagers } from "@/db/schema/compliance" -import { users } from "@/db/schema" -import { basicContract } from "@/db/schema/basicContractDocumnet" +import { basicContract, basicContractTemplates } from "@/db/schema/basicContractDocumnet" import { vendors } from "@/db/schema/vendors" +import { users } from "@/db/schema" +import { getTriggeredRedFlagQuestions, type TriggeredRedFlagInfo } from "./red-flag-notifier" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { - submitApproval, - createSubmitApprovalRequest, - createApprovalLine, - type ApprovalLine -} from "@/lib/knox-api/approval/approval" -import { getTriggeredRedFlagQuestions } from "./red-flag-notifier" +import { ApprovalSubmissionSaga } from "@/lib/approval" +import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" +import type { ApprovalResult } from "@/lib/approval/types" import { revalidatePath } from "next/cache" +type ContractSummary = { + contractId: number + vendorName: string | null + vendorCode: string | null + templateName: string | null + createdAt: Date | null + triggeredFlags: TriggeredRedFlagInfo[] +} + /** - * RED FLAG 해소요청 - 구매기획 담당자에게 합의 요청 + * RED FLAG 해소요청 - Approval Saga를 통해 상신 */ -export async function requestRedFlagResolution(contractIds: number[]): Promise<{ - success: boolean - message: string - failed: number[] -}> { - try { - const session = await getServerSession(authOptions) - if (!session?.user) { - return { - success: false, - message: "인증이 필요합니다.", - failed: contractIds - } - } +export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> { + if (!contractIds || contractIds.length === 0) { + throw new Error("RED FLAG 해소요청을 위한 계약서를 선택해주세요.") + } - const currentUser = session.user - const userId = currentUser.id - const epId = currentUser.epId || "" - const emailAddress = currentUser.email || "" + const uniqueContractIds = Array.from(new Set(contractIds)) - if (!epId || !emailAddress) { - return { - success: false, - message: "사용자 정보가 불완전합니다. epId와 email이 필요합니다.", - failed: contractIds - } - } + const session = await getServerSession(authOptions) + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } - // 구매기획 담당자 조회 - const managerRow = await db - .select({ - purchasingManagerId: redFlagManagers.purchasingManagerId, - }) - .from(redFlagManagers) - .orderBy(redFlagManagers.createdAt) - .limit(1) + const currentUser = session.user + if (!currentUser.epId) { + throw new Error("Knox EP ID가 필요합니다.") + } - const purchasingManagerId = managerRow[0]?.purchasingManagerId - if (!purchasingManagerId) { - return { - success: false, - message: "구매기획 담당자가 설정되지 않았습니다.", - failed: contractIds - } - } + const currentUserId = Number(currentUser.id) + if (Number.isNaN(currentUserId)) { + throw new Error("유효한 사용자 정보가 필요합니다.") + } - // 구매기획 담당자 정보 조회 - const purchasingManager = await db - .select({ - id: users.id, - name: users.name, - email: users.email, - epId: users.epId, - }) - .from(users) - .where(eq(users.id, purchasingManagerId)) - .limit(1) + const purchasingManagerEpId = await getPurchasingManagerEpId() + if (!purchasingManagerEpId) { + throw new Error("구매기획 담당자의 EP ID가 설정되지 않았습니다.") + } - if (!purchasingManager[0] || !purchasingManager[0].epId || !purchasingManager[0].email) { - return { - success: false, - message: "구매기획 담당자 정보를 찾을 수 없습니다.", - failed: contractIds - } - } + const contractSummaries = await fetchContractsWithFlags(uniqueContractIds) + if (contractSummaries.length === 0) { + throw new Error("선택한 계약서에 RED FLAG가 존재하지 않습니다.") + } - const pm = purchasingManager[0] - - // 각 계약서에 대해 RED FLAG 해소요청 처리 - const failed: number[] = [] - - for (const contractId of contractIds) { - try { - // 계약서 정보 조회 - const contractInfo = await db - .select({ - id: basicContract.id, - vendorId: basicContract.vendorId, - templateId: basicContract.templateId, - vendorName: vendors.vendorName, - vendorCode: vendors.vendorCode, - }) - .from(basicContract) - .leftJoin(vendors, eq(basicContract.vendorId, vendors.id)) - .where(eq(basicContract.id, contractId)) - .limit(1) - - if (!contractInfo[0]) { - failed.push(contractId) - continue - } - - const contract = contractInfo[0] - - // RED FLAG 발생 여부 확인 - const triggeredFlags = await getTriggeredRedFlagQuestions(contractId) - if (triggeredFlags.length === 0) { - // RED FLAG가 없는 경우는 스킵 - continue - } - - // 이미 해소요청이 진행 중인지 확인 - const existingResponse = await db - .select() - .from(complianceResponses) - .where(eq(complianceResponses.basicContractId, contractId)) - .limit(1) - - if (existingResponse[0]?.redFlagResolutionApprovalId) { - // 이미 해소요청이 진행 중 - continue - } - - // 합의 요청 본문 생성 - const triggeredQuestionsText = triggeredFlags - .map((flag, idx) => `${idx + 1}. ${flag.questionText}`) - .join("\n") - - const contents = ` -RED FLAG 해소요청 - -계약서 ID: ${contractId} -업체명: ${contract.vendorName || "정보 없음"} -업체코드: ${contract.vendorCode || "정보 없음"} - -발생한 RED FLAG 질문: -${triggeredQuestionsText} - -위 RED FLAG에 대한 해소를 요청드립니다. -합의해 주시면 RED FLAG가 해제됩니다. - `.trim() - - const subject = `[RED FLAG 해소요청] ${contract.vendorName || "협력업체"} - 계약서 ID: ${contractId}` - - // 결재 경로 생성 - // 기안자: 현재 사용자 - const drafterLine: ApprovalLine = await createApprovalLine( - { epId, emailAddress }, - "0", // 기안 - "1" - ) - - // 합의자: 구매기획 담당자 - const approverLine: ApprovalLine = await createApprovalLine( - { epId: pm.epId, emailAddress: pm.email }, - "2", // 합의 - "2" - ) - - const approvalLines = [drafterLine, approverLine] - - // 결재 상신 요청 생성 - const approvalRequest = await createSubmitApprovalRequest( - contents, - subject, - approvalLines, - { - contentsType: "TEXT", - docSecuType: "PERSONAL", - notifyOption: "0", - urgYn: "N", - importantYn: "N", - } - ) - - // 결재 상신 - const approvalResponse = await submitApproval( - approvalRequest, - { - userId, - epId, - emailAddress, - } - ) - - if (approvalResponse.result === "success") { - // compliance_responses 업데이트 (red_flag_resolution_approval_id 저장) - if (existingResponse[0]) { - await db - .update(complianceResponses) - .set({ - redFlagResolutionApprovalId: approvalResponse.data.apInfId, - updatedAt: new Date(), - }) - .where(eq(complianceResponses.id, existingResponse[0].id)) - } else { - // compliance_response가 없는 경우 생성 (템플릿 ID는 계약서에서 가져와야 함) - // 이 경우는 실제로는 발생하지 않을 수 있지만, 안전을 위해 처리 - console.warn(`Compliance response not found for contract ${contractId}`) - } - } else { - failed.push(contractId) - } - } catch (error) { - console.error(`Error processing contract ${contractId}:`, error) - failed.push(contractId) - } - } + const validContractIds = contractSummaries.map((contract) => contract.contractId) - revalidatePath("/evcp/basic-contract") + // 중복 해소요청 방지 (진행 중인 결재가 있는지 확인) + const responses = await db + .select({ + basicContractId: complianceResponses.basicContractId, + redFlagResolvedAt: complianceResponses.redFlagResolvedAt, + redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId, + }) + .from(complianceResponses) + .where(inArray(complianceResponses.basicContractId, validContractIds)) - if (failed.length === 0) { - return { - success: true, - message: `${contractIds.length}건의 RED FLAG 해소요청이 완료되었습니다.`, - failed: [] - } - } else if (failed.length < contractIds.length) { - return { - success: true, - message: `${contractIds.length - failed.length}건 성공, ${failed.length}건 실패`, - failed - } - } else { - return { - success: false, - message: "모든 RED FLAG 해소요청이 실패했습니다.", - failed - } - } - } catch (error) { - console.error("RED FLAG 해소요청 오류:", error) - return { - success: false, - message: `RED FLAG 해소요청 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, - failed: contractIds - } + const missingResponses = validContractIds.filter( + (contractId) => !responses.some((response) => response.basicContractId === contractId) + ) + + if (missingResponses.length > 0) { + throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.") } -} -/** - * RED FLAG 해소 처리 (합의 완료 시 호출) - */ -export async function resolveRedFlag(contractId: number, approvalId: string): Promise<{ - success: boolean - message: string -}> { - try { - // compliance_responses 조회 - const response = await db - .select() - .from(complianceResponses) - .where( - and( - eq(complianceResponses.basicContractId, contractId), - eq(complianceResponses.redFlagResolutionApprovalId, approvalId) - ) - ) - .limit(1) - - if (!response[0]) { - return { - success: false, - message: "해소요청 정보를 찾을 수 없습니다.", - } + const blockedContracts = responses + .filter((response) => response.redFlagResolutionApprovalId && !response.redFlagResolvedAt) + .map((response) => response.basicContractId) + + if (blockedContracts.length > 0) { + const blockedSummaries = contractSummaries + .filter((contract) => blockedContracts.includes(contract.contractId)) + .map((contract) => contract.vendorName ?? `계약 ${contract.contractId}`) + + const preview = + blockedSummaries.length > 2 + ? `${blockedSummaries.slice(0, 2).join(", ")} 외 ${blockedSummaries.length - 2}건` + : blockedSummaries.join(", ") + + throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`) + } + + const now = new Date() + const variables = await buildTemplateVariables(contractSummaries, { + requesterName: currentUser.name || currentUser.email || "요청자", + requestedAt: now, + }) + + const title = buildApprovalTitle(contractSummaries) + + const saga = new ApprovalSubmissionSaga( + "compliance_red_flag_resolution", + { + contractIds: validContractIds, + requestedBy: currentUserId, + requestedAt: now.toISOString(), + }, + { + title, + description: "컴플라이언스 Red Flag 해소요청", + templateName: "컴플라이언스 Red Flag 해소요청", + variables, + approvers: [purchasingManagerEpId], + currentUser: { + id: currentUserId, + epId: currentUser.epId, + email: currentUser.email ?? undefined, + }, } + ) + + const result = await saga.execute() - // RED FLAG 해제 처리 + if (result.status === "pending_approval") { await db .update(complianceResponses) .set({ - redFlagResolvedAt: new Date(), + redFlagResolutionApprovalId: result.approvalId, + redFlagResolvedAt: null, updatedAt: new Date(), }) - .where(eq(complianceResponses.id, response[0].id)) + .where(inArray(complianceResponses.basicContractId, validContractIds)) - revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + } - return { - success: true, - message: "RED FLAG가 해제되었습니다.", - } - } catch (error) { - console.error("RED FLAG 해제 오류:", error) - return { - success: false, - message: `RED FLAG 해제 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, - } + return result +} + +/** + * RED FLAG 해소 처리 (승인 후 실행) + */ +export async function resolveRedFlag( + contractId: number, + options: { approvalId?: string; revalidate?: boolean } = {} +): Promise<{ success: boolean; message: string }> { + const conditions = [eq(complianceResponses.basicContractId, contractId)] + if (options.approvalId) { + conditions.push(eq(complianceResponses.redFlagResolutionApprovalId, options.approvalId)) + } + + const [updated] = await db + .update(complianceResponses) + .set({ + redFlagResolvedAt: new Date(), + updatedAt: new Date(), + }) + .where(and(...conditions)) + .returning({ id: complianceResponses.id }) + + if (!updated) { + throw new Error("해소요청 정보를 찾을 수 없습니다.") + } + + if (options.revalidate !== false) { + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + } + + return { + success: true, + message: "RED FLAG가 해제되었습니다.", } } +async function getPurchasingManagerEpId(): Promise<string | null> { + const [manager] = await db + .select({ + purchasingManagerId: redFlagManagers.purchasingManagerId, + }) + .from(redFlagManagers) + .orderBy(redFlagManagers.createdAt) + .limit(1) + + if (!manager?.purchasingManagerId) { + return null + } + + const [user] = await db + .select({ + epId: users.epId, + }) + .from(users) + .where(eq(users.id, manager.purchasingManagerId)) + .limit(1) + + return user?.epId ?? null +} + +async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { + const contracts = await db + .select({ + contractId: basicContract.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + templateName: basicContractTemplates.templateName, + createdAt: basicContract.createdAt, + }) + .from(basicContract) + .leftJoin(vendors, eq(basicContract.vendorId, vendors.id)) + .leftJoin(basicContractTemplates, eq(basicContract.templateId, basicContractTemplates.id)) + .where(inArray(basicContract.id, contractIds)) + + const withFlags = await Promise.all( + contracts.map(async (contract) => { + const triggeredFlags = await getTriggeredRedFlagQuestions(contract.contractId) + return { + ...contract, + triggeredFlags, + } + }) + ) + + return withFlags.filter((contract) => contract.triggeredFlags.length > 0) +} + +async function buildTemplateVariables( + contracts: ContractSummary[], + meta: { requesterName: string; requestedAt: Date } +): Promise<Record<string, string>> { + const summaryRows = contracts.map((contract) => ({ + contractId: contract.contractId, + vendorName: contract.vendorName ?? "-", + templateName: contract.templateName ?? "-", + redFlagCount: contract.triggeredFlags.length, + })) + + const summaryTable = await htmlTableConverter(summaryRows, [ + { key: "contractId", label: "계약 ID" }, + { key: "vendorName", label: "업체명" }, + { key: "templateName", label: "템플릿" }, + { key: "redFlagCount", label: "RED FLAG 수" }, + ]) + + const detailSections = await Promise.all( + contracts.map(async (contract) => { + const questionList = contract.triggeredFlags.map((flag, index) => { + const prefix = flag.questionNumber || `${index + 1}` + return `${prefix}. ${flag.questionText}` + }) + + const listHtml = await htmlListConverter(questionList) + return ` + <div style="margin-bottom: 24px;"> + <div style="font-weight:600;margin-bottom:8px;"> + 계약 ID: ${contract.contractId} / ${contract.vendorName ?? "-"} + </div> + <div>${listHtml}</div> + </div> + ` + }) + ) + + const detailHtml = detailSections.join("") + const formattedDate = new Intl.DateTimeFormat("ko-KR", { + dateStyle: "medium", + timeStyle: "short", + }).format(meta.requestedAt) + + return { + 요청자이름: meta.requesterName, + 요청일시: formattedDate, + 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.", + RedFlag요약테이블: summaryTable, + RedFlag상세내역: detailHtml, + } +} + +function buildApprovalTitle(contracts: ContractSummary[]) { + if (contracts.length === 0) return "컴플라이언스 Red Flag 해소요청" + const firstVendor = contracts[0].vendorName ?? `계약 ${contracts[0].contractId}` + + if (contracts.length === 1) { + return `Red Flag 해소요청 - ${firstVendor}` + } + + return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건` +} |
