summaryrefslogtreecommitdiff
path: root/lib/compliance/red-flag-resolution.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/compliance/red-flag-resolution.ts')
-rw-r--r--lib/compliance/red-flag-resolution.ts529
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}건`
+}