diff options
Diffstat (limited to 'lib/compliance')
| -rw-r--r-- | lib/compliance/approval-actions.ts | 179 | ||||
| -rw-r--r-- | lib/compliance/approval-handlers.ts | 183 | ||||
| -rw-r--r-- | lib/compliance/red-flag-resolution.ts | 44 |
3 files changed, 334 insertions, 72 deletions
diff --git a/lib/compliance/approval-actions.ts b/lib/compliance/approval-actions.ts new file mode 100644 index 00000000..3cded178 --- /dev/null +++ b/lib/compliance/approval-actions.ts @@ -0,0 +1,179 @@ +/** + * RED FLAG 해소요청 결재 서버 액션 + * + * ✅ 베스트 프랙티스: + * - 'use server' 지시어 포함 (서버 액션) + * - UI에서 호출하는 진입점 함수들 + * - ApprovalSubmissionSaga를 사용하여 결재 프로세스 시작 + * - 템플릿 변수 준비 및 입력 검증 + * - 핸들러(Internal)에는 최소 데이터만 전달 + */ + +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import type { ApprovalResult } from '@/lib/approval/types'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/app/api/auth/[...nextauth]/route'; +import { mapRedFlagResolutionToTemplateVariables, getPurchasingManagerEpId } from './approval-handlers'; +import { fetchContractsWithFlags, validateRedFlagResolutionRequest } from './red-flag-resolution'; +import db from '@/db/db'; +import { inArray } from 'drizzle-orm'; +import { complianceResponses } from '@/db/schema/compliance'; +import { revalidatePath } from 'next/cache'; +import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils'; + +/** + * 결재를 거쳐 RED FLAG 해소요청을 상신하는 서버 액션 + * + * ✅ 사용법 (클라이언트 컴포넌트에서): + * ```typescript + * const result = await requestRedFlagResolutionWithApproval({ + * contractIds: [1, 2, 3], + * }); + * + * if (result.status === 'pending_approval') { + * toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + * } + * ``` + */ +export async function requestRedFlagResolutionWithApproval(data: { + contractIds: number[]; +}): Promise<ApprovalResult> { + debugLog('[RedFlagResolutionApproval] RED FLAG 해소요청 결재 서버 액션 시작', { + contractCount: data.contractIds.length, + }); + + // 1. 입력 검증 + if (!data.contractIds || data.contractIds.length === 0) { + debugError('[RedFlagResolutionApproval] 계약서 ID 없음'); + throw new Error('RED FLAG 해소요청을 위한 계약서를 선택해주세요.'); + } + + const uniqueContractIds = Array.from(new Set(data.contractIds)); + + // 2. 세션 및 사용자 정보 확인 + const session = await getServerSession(authOptions); + if (!session?.user) { + debugError('[RedFlagResolutionApproval] 인증되지 않은 사용자'); + throw new Error('인증이 필요합니다.'); + } + + const currentUser = session.user; + if (!currentUser.epId) { + debugError('[RedFlagResolutionApproval] Knox EP ID 없음'); + throw new Error('Knox EP ID가 필요합니다.'); + } + + const currentUserId = Number(currentUser.id); + if (Number.isNaN(currentUserId)) { + debugError('[RedFlagResolutionApproval] 유효하지 않은 사용자 ID'); + throw new Error('유효한 사용자 정보가 필요합니다.'); + } + + // 3. 구매기획 담당자 EP ID 조회 + const purchasingManagerEpId = await getPurchasingManagerEpId(); + if (!purchasingManagerEpId || purchasingManagerEpId.trim() === '') { + debugError('[RedFlagResolutionApproval] 구매기획 담당자 EP ID 없음'); + throw new Error('구매기획 담당자의 EP ID가 설정되지 않았습니다. 준법서약 관리 페이지에서 레드플래그 담당자를 설정해주세요.'); + } + + const trimmedEpId = purchasingManagerEpId.trim(); + debugLog('[RedFlagResolutionApproval] 구매기획 담당자 EP ID', { epId: trimmedEpId }); + + // 4. 계약서 및 RED FLAG 확인 + const contractSummaries = await fetchContractsWithFlags(uniqueContractIds); + if (contractSummaries.length === 0) { + debugError('[RedFlagResolutionApproval] RED FLAG가 있는 계약서 없음'); + throw new Error('선택한 계약서에 RED FLAG가 존재하지 않습니다.'); + } + + const validContractIds = contractSummaries.map((contract) => contract.contractId); + debugLog('[RedFlagResolutionApproval] 처리할 계약서', { count: validContractIds.length, ids: validContractIds }); + + // 5. 중복 해소요청 방지 검증 + await validateRedFlagResolutionRequest(validContractIds, contractSummaries); + + // 6. 템플릿 변수 매핑 + debugLog('[RedFlagResolutionApproval] 템플릿 변수 매핑 시작'); + const requestedAt = new Date(); + const variables = await mapRedFlagResolutionToTemplateVariables(contractSummaries, { + requesterName: currentUser.name || currentUser.email || '요청자', + requestedAt, + }); + debugLog('[RedFlagResolutionApproval] 템플릿 변수 매핑 완료', { + variableKeys: Object.keys(variables), + }); + + // 7. 결재 제목 생성 + const title = buildApprovalTitle(contractSummaries); + + // 8. 결재 워크플로우 시작 (Saga 패턴) + debugLog('[RedFlagResolutionApproval] ApprovalSubmissionSaga 생성'); + const saga = new ApprovalSubmissionSaga( + // actionType: 핸들러를 찾을 때 사용할 키 + 'compliance_red_flag_resolution', + + // actionPayload: 결재 승인 후 핸들러에 전달될 데이터 (최소 데이터만) + { + contractIds: validContractIds, + requestedBy: currentUserId, + requestedAt: requestedAt.toISOString(), + }, + + // approvalConfig: 결재 상신 정보 (템플릿 포함) + { + title, + description: '컴플라이언스 Red Flag 해소요청', + templateName: '컴플라이언스 Red Flag 해소요청', + variables, + approvers: [trimmedEpId], + currentUser: { + id: currentUserId, + epId: currentUser.epId, + email: currentUser.email ?? undefined, + }, + } + ); + + debugLog('[RedFlagResolutionApproval] Saga 실행 시작'); + const result = await saga.execute(); + + debugSuccess('[RedFlagResolutionApproval] 결재 워크플로우 완료', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + status: result.status, + }); + + // 9. 결재 상신 성공 시 compliance_responses 업데이트 + if (result.status === 'pending_approval') { + await db + .update(complianceResponses) + .set({ + redFlagResolutionApprovalId: result.approvalId, + redFlagResolvedAt: null, + updatedAt: new Date(), + }) + .where(inArray(complianceResponses.basicContractId, validContractIds)); + + await revalidatePath('/evcp/basic-contract'); + await revalidatePath('/evcp/compliance'); + } + + return result; +} + +/** + * 결재 제목 생성 + */ +function buildApprovalTitle(contracts: Array<{ contractId: number; vendorName: string | null }>): string { + 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}건`; +} + diff --git a/lib/compliance/approval-handlers.ts b/lib/compliance/approval-handlers.ts index 05f92a28..11f95a3c 100644 --- a/lib/compliance/approval-handlers.ts +++ b/lib/compliance/approval-handlers.ts @@ -1,64 +1,169 @@ "use server" -import db from "@/db/db" -import { and, inArray, isNull } from "drizzle-orm" -import { complianceResponses } from "@/db/schema/compliance" -import { resolveRedFlag } from "./red-flag-resolution" +import { resolveRedFlag, type ContractSummary } from "./red-flag-resolution" import { revalidatePath } from "next/cache" - -interface RedFlagResolutionPayload { - contractIds: number[] -} +import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils" +import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" +import db from "@/db/db" +import { eq } from "drizzle-orm" +import { redFlagManagers } from "@/db/schema/compliance" +import { users } from "@/db/schema" /** - * 결재 승인 후 RED FLAG 해제를 처리하는 핸들러 + * RED FLAG 해소 결재 승인 핸들러 * - * approval-workflow에서 자동으로 호출됩니다. + * 결재 승인 후 자동으로 호출되어 RED FLAG를 해제합니다. + * + * @param payload - 결재 상신 시 저장한 actionPayload */ -export async function resolveRedFlagAfterApproval(payload: RedFlagResolutionPayload) { - if (!payload?.contractIds || payload.contractIds.length === 0) { - return { - success: false, - message: "처리할 계약서가 없습니다.", +export async function resolveRedFlagAfterApproval(payload: { + contractIds: number[] + requestedBy: number + requestedAt: string +}) { + debugLog("[RedFlagResolutionHandler] RED FLAG 해소 결재 승인 핸들러 시작", payload) + + try { + if (!payload?.contractIds || payload.contractIds.length === 0) { + debugError("[RedFlagResolutionHandler] 계약서 ID가 없습니다", payload) + return { + success: false, + message: "처리할 계약서가 없습니다.", + } } - } - const uniqueContractIds = Array.from(new Set(payload.contractIds)) + const uniqueContractIds = Array.from(new Set(payload.contractIds)) + debugLog("[RedFlagResolutionHandler] 처리할 계약서 수", { count: uniqueContractIds.length }) - // 이미 해제된 계약을 제외한 대상을 조회 - const targets = await db - .select({ - basicContractId: complianceResponses.basicContractId, - approvalId: complianceResponses.redFlagResolutionApprovalId, - }) - .from(complianceResponses) - .where( - and( - inArray(complianceResponses.basicContractId, uniqueContractIds), - isNull(complianceResponses.redFlagResolvedAt) - ) + // 각 계약서에 대해 RED FLAG 해소 처리 + // approvalId는 resolveRedFlag 내부에서 조회하므로 여기서는 전달하지 않음 + const results = await Promise.allSettled( + uniqueContractIds.map(async (contractId) => { + const result = await resolveRedFlag(contractId, { + revalidate: false, + }) + return { contractId, result } + }) ) - if (targets.length === 0) { + const successful = results.filter((r) => r.status === "fulfilled").length + const failed = results.filter((r) => r.status === "rejected").length + + debugLog("[RedFlagResolutionHandler] 처리 결과", { + total: uniqueContractIds.length, + successful, + failed, + }) + + if (failed > 0) { + const errors = results + .filter((r) => r.status === "rejected") + .map((r) => (r as PromiseRejectedResult).reason) + debugError("[RedFlagResolutionHandler] 일부 계약서 처리 실패", errors) + } + + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + + debugSuccess("[RedFlagResolutionHandler] RED FLAG 해소 완료", { + successful, + failed, + }) + return { success: true, - message: "해제 대상이 없습니다.", + message: `${successful}개 계약서의 RED FLAG가 해제되었습니다.`, + updated: successful, } + } catch (error) { + debugError("[RedFlagResolutionHandler] RED FLAG 해소 처리 중 오류 발생", error) + throw error } +} - for (const target of targets) { - await resolveRedFlag(target.basicContractId, { - approvalId: target.approvalId ?? undefined, - revalidate: false, +/** + * 구매기획 담당자 EP ID 조회 + */ +export 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 } - await revalidatePath("/evcp/basic-contract") - await revalidatePath("/evcp/compliance") + const [user] = await db + .select({ + epId: users.epId, + }) + .from(users) + .where(eq(users.id, manager.purchasingManagerId)) + .limit(1) + + return user?.epId ?? null +} + +/** + * RED FLAG 해소요청 데이터를 결재 템플릿 변수로 매핑 + * + * @param contracts - RED FLAG가 있는 계약서 목록 + * @param meta - 요청자 정보 + * @returns 템플릿 변수 객체 (Record<string, string>) + */ +export async function mapRedFlagResolutionToTemplateVariables( + 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 { - success: true, - updated: targets.length, + 요청자이름: meta.requesterName, + 요청일시: formattedDate, + 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.", + RedFlag요약테이블: summaryTable, + RedFlag상세내역: detailHtml, } } diff --git a/lib/compliance/red-flag-resolution.ts b/lib/compliance/red-flag-resolution.ts index af69dbf4..47a805bb 100644 --- a/lib/compliance/red-flag-resolution.ts +++ b/lib/compliance/red-flag-resolution.ts @@ -2,19 +2,15 @@ import db from "@/db/db" import { and, eq, inArray } from "drizzle-orm" -import { complianceResponses, redFlagManagers } from "@/db/schema/compliance" +import { complianceResponses } from "@/db/schema/compliance" 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 { ApprovalSubmissionSaga } from "@/lib/approval" -import { htmlListConverter, htmlTableConverter } from "@/lib/approval/template-utils" -import type { ApprovalResult } from "@/lib/approval/types" import { revalidatePath } from "next/cache" +import { requestRedFlagResolutionWithApproval } from "./approval-actions" +import type { ApprovalResult } from "@/lib/approval/types" -type ContractSummary = { +export type ContractSummary = { contractId: number vendorName: string | null vendorCode: string | null @@ -25,6 +21,9 @@ type ContractSummary = { /** * RED FLAG 해소요청 - Approval Saga를 통해 상신 + * + * @deprecated 이 함수는 호환성을 위해 유지됩니다. + * 새로운 코드는 `requestRedFlagResolutionWithApproval`을 사용하세요. */ export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> { if (!contractIds || contractIds.length === 0) { @@ -180,31 +179,10 @@ export async function resolveRedFlag( } } -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[]> { +/** + * 계약서와 RED FLAG 정보를 함께 조회 + */ +export async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractSummary[]> { const contracts = await db .select({ contractId: basicContract.id, |
