summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-24 06:03:15 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-24 06:03:15 +0000
commita55198e00ff982f6310a08c76a0502c90dd7d859 (patch)
tree94dd9816e18c63333e30b1ac8d14d3100f0e175e /lib
parentd79f56ae5a9e5f72781f78fe0399018cfac44081 (diff)
(임수민) 준법 Red Flag 결재 수정
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx4
-rw-r--r--lib/compliance/approval-actions.ts179
-rw-r--r--lib/compliance/approval-handlers.ts183
-rw-r--r--lib/compliance/red-flag-resolution.ts247
4 files changed, 371 insertions, 242 deletions
diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
index 6bf0dfb1..8185e33e 100644
--- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
+++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx
@@ -1411,7 +1411,7 @@ export function BasicContractSignViewer({
{file.name}
</span>
{isSurveyTab && !surveyData.completed && (
- <Badge variant="destructive" className="ml-1 h-4 px-1.5 text-xs bg-amber-500 text-white">
+ <Badge variant="destructive" className="ml-1 h-4 px-1.5 text-xs bg-red-500 text-white transition-none hover:bg-red-500">
필수
</Badge>
)}
@@ -1640,7 +1640,7 @@ export function BasicContractSignViewer({
{file.name}
</span>
{isSurveyTab && !surveyData.completed && (
- <Badge variant="destructive" className="ml-1 h-4 px-1.5 text-xs bg-amber-500 text-white">
+ <Badge variant="destructive" className="ml-1 h-4 px-1.5 text-xs bg-red-500 text-white transition-none hover:bg-red-500">
필수
</Badge>
)}
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 423f5a46..63057523 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,122 +21,12 @@ type ContractSummary = {
/**
* RED FLAG 해소요청 - Approval Saga를 통해 상신
+ *
+ * @deprecated 이 함수는 호환성을 위해 유지됩니다.
+ * 새로운 코드는 `requestRedFlagResolutionWithApproval`을 사용하세요.
*/
export async function requestRedFlagResolution(contractIds: number[]): Promise<ApprovalResult> {
- if (!contractIds || contractIds.length === 0) {
- throw new Error("RED FLAG 해소요청을 위한 계약서를 선택해주세요.")
- }
-
- const uniqueContractIds = Array.from(new Set(contractIds))
-
- const session = await getServerSession(authOptions)
- if (!session?.user) {
- throw new Error("인증이 필요합니다.")
- }
-
- const currentUser = session.user
- if (!currentUser.epId) {
- throw new Error("Knox EP ID가 필요합니다.")
- }
-
- const currentUserId = Number(currentUser.id)
- if (Number.isNaN(currentUserId)) {
- throw new Error("유효한 사용자 정보가 필요합니다.")
- }
-
- const purchasingManagerEpId = await getPurchasingManagerEpId()
- if (!purchasingManagerEpId) {
- throw new Error("구매기획 담당자의 EP ID가 설정되지 않았습니다.")
- }
-
- const contractSummaries = await fetchContractsWithFlags(uniqueContractIds)
- if (contractSummaries.length === 0) {
- throw new Error("선택한 계약서에 RED FLAG가 존재하지 않습니다.")
- }
-
- const validContractIds = contractSummaries.map((contract) => contract.contractId)
-
- // 중복 해소요청 방지 (진행 중인 결재가 있는지 확인)
- const responses = await db
- .select({
- basicContractId: complianceResponses.basicContractId,
- redFlagResolvedAt: complianceResponses.redFlagResolvedAt,
- redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId,
- })
- .from(complianceResponses)
- .where(inArray(complianceResponses.basicContractId, validContractIds))
-
- const missingResponses = validContractIds.filter(
- (contractId) => !responses.some((response) => response.basicContractId === contractId)
- )
-
- if (missingResponses.length > 0) {
- throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.")
- }
-
- 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()
-
- 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
+ return await requestRedFlagResolutionWithApproval({ contractIds })
}
/**
@@ -179,31 +65,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,
@@ -230,65 +95,45 @@ async function fetchContractsWithFlags(contractIds: number[]): Promise<ContractS
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>
- `
+/**
+ * RED FLAG 해소요청 검증 (중복 요청 방지)
+ */
+export async function validateRedFlagResolutionRequest(
+ validContractIds: number[],
+ contractSummaries: ContractSummary[]
+): Promise<void> {
+ // 중복 해소요청 방지 (진행 중인 결재가 있는지 확인)
+ const responses = await db
+ .select({
+ basicContractId: complianceResponses.basicContractId,
+ redFlagResolvedAt: complianceResponses.redFlagResolvedAt,
+ redFlagResolutionApprovalId: complianceResponses.redFlagResolutionApprovalId,
})
- )
+ .from(complianceResponses)
+ .where(inArray(complianceResponses.basicContractId, validContractIds))
- const detailHtml = detailSections.join("")
- const formattedDate = new Intl.DateTimeFormat("ko-KR", {
- dateStyle: "medium",
- timeStyle: "short",
- }).format(meta.requestedAt)
+ const missingResponses = validContractIds.filter(
+ (contractId) => !responses.some((response) => response.basicContractId === contractId)
+ )
- return {
- 요청자이름: meta.requesterName,
- 요청일시: formattedDate,
- 요청사유: "컴플라이언스 Red Flag 해소를 위해 구매기획 합의를 요청드립니다.",
- RedFlag요약테이블: summaryTable,
- RedFlag상세내역: detailHtml,
+ if (missingResponses.length > 0) {
+ throw new Error("준법 응답 정보를 찾을 수 없는 계약서가 포함되어 있습니다.")
}
-}
-function buildApprovalTitle(contracts: ContractSummary[]) {
- if (contracts.length === 0) return "컴플라이언스 Red Flag 해소요청"
- const firstVendor = contracts[0].vendorName ?? `계약 ${contracts[0].contractId}`
+ const blockedContracts = responses
+ .filter((response) => response.redFlagResolutionApprovalId && !response.redFlagResolvedAt)
+ .map((response) => response.basicContractId)
- if (contracts.length === 1) {
- return `Red Flag 해소요청 - ${firstVendor}`
- }
+ if (blockedContracts.length > 0) {
+ const blockedSummaries = contractSummaries
+ .filter((contract) => blockedContracts.includes(contract.contractId))
+ .map((contract) => contract.vendorName ?? `계약 ${contract.contractId}`)
- return `Red Flag 해소요청 - ${firstVendor} 외 ${contracts.length - 1}건`
+ const preview =
+ blockedSummaries.length > 2
+ ? `${blockedSummaries.slice(0, 2).join(", ")} 외 ${blockedSummaries.length - 2}건`
+ : blockedSummaries.join(", ")
+
+ throw new Error(`이미 해소요청이 진행 중인 계약서가 있습니다: ${preview}`)
+ }
}