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 | |
| parent | 204fbfb126daf057a4567f64cfb7ab03a5679e82 (diff) | |
(임수민) 준법 Red Flag 해제, 코멘트 수정
15 files changed, 929 insertions, 463 deletions
diff --git a/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx new file mode 100644 index 00000000..359efbed --- /dev/null +++ b/app/[lng]/evcp/(evcp)/(procurement)/basic-contract/compliance-comments/[id]/page.tsx @@ -0,0 +1,197 @@ +import * as React from "react"; +import { notFound } from "next/navigation"; +import Link from "next/link"; +import { eq } from "drizzle-orm"; + +import db from "@/db/db"; +import { basicContractView } from "@/db/schema"; +import { Shell } from "@/components/shell"; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, + BreadcrumbPage, +} from "@/components/ui/breadcrumb"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { AgreementCommentList } from "@/lib/basic-contract/agreement-comments/agreement-comment-list"; +import { formatDateTime } from "@/lib/utils"; +import { checkNegotiationStatus } from "@/lib/basic-contract/agreement-comments/actions"; +import { MessageCircle, Building2, FileText, Calendar, ArrowLeft } from "lucide-react"; + +interface ComplianceCommentsPageProps { + params: Promise<{ id: string }>; +} + +export const revalidate = 0; + +export default async function ComplianceCommentsPage(props: ComplianceCommentsPageProps) { + const params = await props.params; + const contractId = Number(params.id); + + if (Number.isNaN(contractId)) { + notFound(); + } + + const contract = await db + .select() + .from(basicContractView) + .where(eq(basicContractView.id, contractId)) + .limit(1) + .then((rows) => rows[0]); + + if (!contract) { + notFound(); + } + + const negotiationSummary = await checkNegotiationStatus(contractId); + + const negotiationStatusLabel = contract.negotiationCompletedAt + ? "협의 완료" + : negotiationSummary.hasComments + ? `협의 진행중 (${negotiationSummary.commentCount}건)` + : "협의 없음"; + + const negotiationBadgeClass = contract.negotiationCompletedAt + ? "bg-green-50 text-green-700 border-green-200" + : negotiationSummary.hasComments + ? "bg-orange-50 text-orange-700 border-orange-200" + : "bg-gray-50 text-gray-600 border-gray-200"; + + const templateLink = contract.templateId + ? `/evcp/basic-contract/${contract.templateId}` + : "/evcp/basic-contract"; + + return ( + <Shell className="gap-4"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <Breadcrumb> + <BreadcrumbList> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp">EVCP</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbLink href="/evcp/basic-contract">기본계약서/서약서 관리</BreadcrumbLink> + </BreadcrumbItem> + <BreadcrumbSeparator /> + <BreadcrumbItem> + <BreadcrumbPage> + {contract.vendorName ? `${contract.vendorName} 협의 코멘트` : "협의 코멘트"} + </BreadcrumbPage> + </BreadcrumbItem> + </BreadcrumbList> + </Breadcrumb> + + <Button asChild variant="outline" size="sm"> + <Link href={templateLink}> + <ArrowLeft className="h-4 w-4 mr-2" /> + 계약 상세로 돌아가기 + </Link> + </Button> + </div> + + <Card> + <CardHeader className="pb-3"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div className="space-y-1"> + <CardTitle className="flex items-center gap-2 text-lg"> + <FileText className="h-4 w-4 text-blue-500" /> + {contract.templateName || "기본계약서"} + </CardTitle> + <CardDescription> + 계약서 ID {contract.id} · 템플릿 ID {contract.templateId ?? "-"} + </CardDescription> + </div> + <Badge variant="outline" className={`flex items-center gap-1 ${negotiationBadgeClass}`}> + <MessageCircle className="h-3 w-3" /> + {negotiationStatusLabel} + </Badge> + </div> + </CardHeader> + <CardContent className="grid gap-4 md:grid-cols-2"> + <div className="space-y-3"> + <h4 className="text-sm font-semibold text-gray-700 flex items-center gap-2"> + <Building2 className="h-4 w-4 text-gray-500" /> + 협력업체 정보 + </h4> + <div className="rounded border bg-gray-50 p-3 text-sm space-y-1"> + <div className="flex justify-between"> + <span className="text-gray-500">업체명</span> + <span className="font-medium text-gray-900"> + {contract.vendorName || "미지정"} + </span> + </div> + <div className="flex justify-between"> + <span className="text-gray-500">업체코드</span> + <span className="font-mono text-gray-900"> + {contract.vendorCode || "-"} + </span> + </div> + <div className="flex justify-between"> + <span className="text-gray-500">이메일</span> + <span className="text-gray-900">{contract.vendorEmail || "-"}</span> + </div> + </div> + </div> + + <div className="space-y-3"> + <h4 className="text-sm font-semibold text-gray-700 flex items-center gap-2"> + <Calendar className="h-4 w-4 text-gray-500" /> + 진행 정보 + </h4> + <div className="rounded border bg-gray-50 p-3 text-sm space-y-1"> + <div className="flex justify-between"> + <span className="text-gray-500">요청일</span> + <span className="text-gray-900"> + {formatDateTime(contract.createdAt, "KR")} + </span> + </div> + <div className="flex justify-between"> + <span className="text-gray-500">서명 상태</span> + <span className="text-gray-900"> + {contract.vendorSignedAt ? "협력업체 서명완료" : "협력업체 서명대기"} + </span> + </div> + <div className="flex justify-between"> + <span className="text-gray-500">협의 완료일</span> + <span className="text-gray-900"> + {contract.negotiationCompletedAt + ? formatDateTime(contract.negotiationCompletedAt, "KR") + : "-"} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader className="pb-3"> + <div className="flex flex-wrap items-center justify-between gap-3"> + <div> + <CardTitle className="text-lg">협의 코멘트</CardTitle> + <CardDescription> + {contract.vendorName + ? `${contract.vendorName}과(와)의 협의 내용을 기록하고 공유합니다.` + : "협의 코멘트를 작성하고 상대방과 공유합니다."} + </CardDescription> + </div> + </div> + </CardHeader> + <CardContent className="h-[620px] pt-0"> + <AgreementCommentList + basicContractId={contractId} + currentUserType="SHI" + readOnly={false} + isNegotiationCompleted={!!contract.negotiationCompletedAt} + /> + </CardContent> + </Card> + </Shell> + ); +} + diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts index 7aec3ae5..5c173565 100644 --- a/lib/approval/handlers-registry.ts +++ b/lib/approval/handlers-registry.ts @@ -59,6 +59,10 @@ export async function initializeApprovalHandlers() { // 기술영업 RFQ 재발송 핸들러 등록 (결재 승인 후 실행될 함수 resendTechSalesRfqWithDrmInternal) registerActionHandler('tech_sales_rfq_resend_with_drm', resendTechSalesRfqWithDrmInternal); + // 8. 컴플라이언스 Red Flag 해소 핸들러 + const { resolveRedFlagAfterApproval } = await import('@/lib/compliance/approval-handlers'); + registerActionHandler('compliance_red_flag_resolution', resolveRedFlagAfterApproval); + // 8. 입찰초대 핸들러 const { requestBiddingInvitationInternal } = await import('@/lib/bidding/handlers'); // 입찰초대 핸들러 등록 (결재 승인 후 실행될 함수 requestBiddingInvitationInternal) diff --git a/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html b/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html new file mode 100644 index 00000000..ebff8de0 --- /dev/null +++ b/lib/approval/templates/컴플라이언스 Red Flag 해소요청.html @@ -0,0 +1,135 @@ +<div + style=" + max-width: 900px; + margin: 0 auto; + font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; + color: #1f2937; + line-height: 1.6; + " +> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 2px solid #0f172a; + " + > + <thead> + <tr> + <th + style=" + background-color: #0f172a; + color: #fff; + padding: 20px; + text-align: center; + font-size: 24px; + font-weight: 700; + " + > + 컴플라이언스 Red Flag 해소요청 + </th> + </tr> + </thead> + </table> + + <section style="margin-bottom: 24px;"> + <h3 + style=" + font-size: 18px; + font-weight: 600; + color: #0f172a; + margin-bottom: 12px; + " + > + ■ 요청자 정보 + </h3> + <table + style=" + width: 100%; + border-collapse: collapse; + border: 1px solid #cbd5f5; + " + > + <tbody> + <tr> + <td + style=" + width: 25%; + background-color: #f1f5f9; + font-weight: 600; + padding: 10px; + border: 1px solid #cbd5f5; + " + > + 요청자 + </td> + <td style="padding: 10px; border: 1px solid #e2e8f0;"> + {{요청자이름}} + </td> + </tr> + <tr> + <td + style=" + background-color: #f1f5f9; + font-weight: 600; + padding: 10px; + border: 1px solid #cbd5f5; + " + > + 요청일시 + </td> + <td style="padding: 10px; border: 1px solid #e2e8f0;"> + {{요청일시}} + </td> + </tr> + <tr> + <td + style=" + background-color: #f1f5f9; + font-weight: 600; + padding: 10px; + border: 1px solid #cbd5f5; + " + > + 요청 사유 + </td> + <td style="padding: 10px; border: 1px solid #e2e8f0;"> + {{요청사유}} + </td> + </tr> + </tbody> + </table> + </section> + + <section style="margin-bottom: 24px;"> + <h3 + style=" + font-size: 18px; + font-weight: 600; + color: #0f172a; + margin-bottom: 12px; + " + > + ■ 대상 계약 요약 + </h3> + {{RedFlag요약테이블}} + </section> + + <section> + <h3 + style=" + font-size: 18px; + font-weight: 600; + color: #0f172a; + margin-bottom: 12px; + " + > + ■ 상세 내역 + </h3> + <div style="border: 1px solid #e2e8f0; padding: 16px; border-radius: 8px;"> + {{RedFlag상세내역}} + </div> + </section> +</div> + diff --git a/lib/basic-contract/actions/check-red-flag-resolution.ts b/lib/basic-contract/actions/check-red-flag-resolution.ts index 84dcdf75..3ce21bde 100644 --- a/lib/basic-contract/actions/check-red-flag-resolution.ts +++ b/lib/basic-contract/actions/check-red-flag-resolution.ts @@ -2,96 +2,60 @@ import { BasicContractView } from "@/db/schema"; import { getComplianceResponseByBasicContractId } from "@/lib/compliance/services"; -import { syncSpecificApprovalStatusAction } from "@/lib/knox-api/approval/approval"; + +type RedFlagResolutionState = { + resolved: boolean; + resolvedAt: Date | null; + pendingApprovalId: string | null; +}; /** * 여러 계약서에 대한 RED FLAG 해제 상태를 한 번에 확인 */ export async function checkRedFlagResolutionForContracts( contracts: BasicContractView[] -): Promise<Record<number, { resolved: boolean; resolvedAt: Date | null }>> { - const result: Record<number, { resolved: boolean; resolvedAt: Date | null }> = {}; - +): Promise<Record<number, RedFlagResolutionState>> { + const result: Record<number, RedFlagResolutionState> = {}; + // 준법서약 템플릿인 계약서만 필터링 - const complianceContracts = contracts.filter(contract => - contract.templateName?.includes('준법') + const complianceContracts = contracts.filter((contract) => + contract.templateName?.includes("준법") ); - + if (complianceContracts.length === 0) { return result; } - // 1. 먼저 DB에서 현재 상태 조회 - const initialChecks = await Promise.all( + const checks = await Promise.all( complianceContracts.map(async (contract) => { try { const response = await getComplianceResponseByBasicContractId(contract.id); return { contractId: contract.id, - response + resolved: Boolean(response?.redFlagResolvedAt), + resolvedAt: response?.redFlagResolvedAt || null, + pendingApprovalId: response?.redFlagResolutionApprovalId ?? null, }; } catch (error) { console.error(`Error fetching compliance response for contract ${contract.id}:`, error); return { contractId: contract.id, - response: null - }; - } - }) - ); - - // 2. 진행 중인 결재(해소요청)가 있는지 확인하고 Knox 상태 동기화 - const pendingApprovalIds: string[] = []; - - initialChecks.forEach(check => { - const { response } = check; - // 해소요청은 했으나(approvalId 있음) 아직 해소되지 않은(resolvedAt 없음) 경우 - if (response?.redFlagResolutionApprovalId && !response.redFlagResolvedAt) { - pendingApprovalIds.push(response.redFlagResolutionApprovalId); - } - }); - - if (pendingApprovalIds.length > 0) { - try { - // Knox API를 통해 최신 결재 상태 동기화 - // 이 과정에서 결재가 완료되었다면 DB의 redFlagResolvedAt도 업데이트됨 (syncSpecificApprovalStatusAction 내부 로직) - await syncSpecificApprovalStatusAction(pendingApprovalIds); - } catch (error) { - console.error('Error syncing approval status:', error); - } - } - - // 3. 동기화 후 최종 상태 다시 확인 - // (동기화 과정에서 DB가 업데이트되었을 수 있으므로 다시 조회하거나, - // 성능을 위해 위에서 동기화된 건만 다시 조회하는 방식도 가능하지만, - // 여기서는 안전하게 다시 조회하는 방식을 택함) - const finalChecks = await Promise.all( - complianceContracts.map(async (contract) => { - try { - const response = await getComplianceResponseByBasicContractId(contract.id); - return { - contractId: contract.id, - resolved: response?.redFlagResolvedAt !== null && response?.redFlagResolvedAt !== undefined, - resolvedAt: response?.redFlagResolvedAt || null - }; - } catch (error) { - return { - contractId: contract.id, resolved: false, - resolvedAt: null + resolvedAt: null, + pendingApprovalId: null, }; } }) ); - - // 결과를 Record 형태로 변환 - finalChecks.forEach(check => { + + checks.forEach((check) => { result[check.contractId] = { resolved: check.resolved, - resolvedAt: check.resolvedAt + resolvedAt: check.resolvedAt, + pendingApprovalId: check.pendingApprovalId, }; }); - + return result; } diff --git a/lib/basic-contract/agreement-comments/actions.ts b/lib/basic-contract/agreement-comments/actions.ts index c4ded36e..2f60c3d8 100644 --- a/lib/basic-contract/agreement-comments/actions.ts +++ b/lib/basic-contract/agreement-comments/actions.ts @@ -2,7 +2,7 @@ import { revalidateTag } from "next/cache"; import db from "@/db/db"; -import { eq, and, desc } from "drizzle-orm"; +import { eq, and, desc, inArray, sql } from "drizzle-orm"; import { agreementComments, basicContract, vendors, users } from "@/db/schema"; import { saveFile, deleteFile } from "@/lib/file-stroage"; import { sendEmail } from "@/lib/mail/sendEmail"; @@ -32,6 +32,23 @@ export interface AgreementCommentData { updatedAt: Date; } +export interface AgreementCommentSummary { + hasComments: boolean; + commentCount: number; +} + +function getContractDocumentLabel(templateName?: string | null) { + if (!templateName) return "기본계약서"; + const normalized = templateName.toLowerCase(); + if (normalized.includes('준법')) { + return "준법서약"; + } + if (normalized.includes('gtc')) { + return "GTC 기본계약서"; + } + return templateName; +} + /** * 특정 기본계약서의 모든 코멘트 조회 */ @@ -496,6 +513,55 @@ export async function checkNegotiationStatus( } /** + * 다수의 계약서에 대한 협의 코멘트 상태 조회 + */ +export async function checkAgreementCommentsForContracts( + contracts: { id: number }[] +): Promise<Record<number, AgreementCommentSummary>> { + if (!contracts || contracts.length === 0) { + return {}; + } + + try { + const contractIds = contracts.map(contract => contract.id); + + const commentCounts = await db + .select({ + contractId: agreementComments.basicContractId, + count: sql<number>`count(*)`, + }) + .from(agreementComments) + .where( + and( + inArray(agreementComments.basicContractId, contractIds), + eq(agreementComments.isDeleted, false) + ) + ) + .groupBy(agreementComments.basicContractId); + + const countsMap = new Map<number, number>(); + commentCounts.forEach(({ contractId, count }) => { + countsMap.set(contractId, Number(count || 0)); + }); + + const result: Record<number, AgreementCommentSummary> = {}; + + contractIds.forEach((contractId) => { + const count = countsMap.get(contractId) ?? 0; + result[contractId] = { + hasComments: count > 0, + commentCount: count, + }; + }); + + return result; + } catch (error) { + console.error("협의 상태 조회 실패:", error); + return {}; + } +} + +/** * 이메일 알림 발송 */ async function sendCommentNotificationEmail(params: { @@ -509,6 +575,7 @@ async function sendCommentNotificationEmail(params: { attachmentCount?: number; }) { const { comment, contract, vendor, requester, templateName, authorType, authorName, attachmentCount = 0 } = params; + const documentTypeLabel = getContractDocumentLabel(templateName); // 수신자 결정 let recipientEmail: string | undefined; @@ -536,7 +603,7 @@ async function sendCommentNotificationEmail(params: { // 이메일 발송 await sendEmail({ to: recipientEmail, - subject: `[eVCP] GTC 기본계약서 협의 코멘트 제출 - ${templateName || '기본계약서'}`, + subject: `[eVCP] ${documentTypeLabel} 협의 코멘트 제출 - ${templateName || '기본계약서'}`, template: "agreement-comment-notification", context: { language: "ko", @@ -545,6 +612,7 @@ async function sendCommentNotificationEmail(params: { authorType: authorType === 'SHI' ? '삼성중공업' : '협력업체', comment: comment.comment, templateName: templateName || '기본계약서', + documentTypeLabel, vendorName: vendor?.vendorName || '', attachmentCount, contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, @@ -617,18 +685,20 @@ export async function completeNegotiation( .limit(1); templateName = template?.templateName || null; } + const documentTypeLabel = getContractDocumentLabel(templateName); // 이메일 알림 발송 try { if (requester) { await sendEmail({ to: requester.email || '', - subject: `[eVCP] GTC 기본계약서 협의 완료 - ${templateName || '기본계약서'}`, + subject: `[eVCP] ${documentTypeLabel} 협의 완료 - ${templateName || '기본계약서'}`, template: "negotiation-complete-notification", context: { language: "ko", recipientName: requester.name || "담당자", templateName: templateName || '기본계약서', + documentTypeLabel, vendorName: vendor?.vendorName || '', contractUrl: `${process.env.NEXT_PUBLIC_APP_URL}/evcp/basic-contract/${contract.id}`, systemUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://evcp.com', diff --git a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx index daa410f0..e62a6cb7 100644 --- a/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx +++ b/lib/basic-contract/status-detail/basic-contract-detail-table-toolbar-actions.tsx @@ -414,15 +414,17 @@ export function BasicContractDetailTableToolbarActions({ const contractIds = redFlagResolutionContracts.map(c => c.id) const result = await requestRedFlagResolution(contractIds) - if (result.success) { - toast.success(result.message) - table.toggleAllPageRowsSelected(false) - } else { - toast.error(result.message) - } + toast.success("RED FLAG 해소요청 결재가 상신되었습니다.", { + description: `결재 ID: ${result.approvalId}`, + }) + table.toggleAllPageRowsSelected(false) } catch (error) { console.error("RED FLAG 해소요청 오류:", error) - toast.error("RED FLAG 해소요청 중 오류가 발생했습니다") + toast.error( + error instanceof Error + ? error.message + : "RED FLAG 해소요청 중 오류가 발생했습니다." + ) } finally { setLoading(false) } diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx index b2c811fd..2ab39880 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-columns.tsx @@ -32,13 +32,21 @@ import { toast } from "sonner" import { useRouter } from "next/navigation" import { getComplianceResponseByBasicContractId } from "@/lib/compliance/services" -interface GetColumnsProps { +type RedFlagResolutionState = { + resolved: boolean + resolvedAt: Date | null + pendingApprovalId: string | null +} + +export interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>> gtcData: Record<number, { gtcDocumentId: number | null; hasComments: boolean }> isLoadingGtcData: boolean + agreementCommentData: Record<number, { hasComments: boolean; commentCount: number }> + isLoadingAgreementCommentData: boolean redFlagData: Record<number, boolean> isLoadingRedFlagData: boolean - redFlagResolutionData: Record<number, { resolved: boolean; resolvedAt: Date | null }> + redFlagResolutionData: Record<number, RedFlagResolutionState> isLoadingRedFlagResolutionData: boolean isComplianceTemplate: boolean router: NextRouter; @@ -61,6 +69,8 @@ export function getDetailColumns({ setRowAction, gtcData, isLoadingGtcData, + agreementCommentData, + isLoadingAgreementCommentData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, @@ -134,7 +144,7 @@ export function getDetailColumns({ } const handleResend = () => { - setRowAction({ type: "resend", row }) + setRowAction({ type: "resend", row } as DataTableRowAction<BasicContractView>) } return ( @@ -256,6 +266,19 @@ export function getDetailColumns({ </div> ); } + + if (resolution?.pendingApprovalId) { + return ( + <div className="text-sm"> + <Badge variant="secondary" className="font-medium"> + 해소요청 진행중 + </Badge> + <div className="text-xs text-gray-500 mt-1"> + 결재 ID: {resolution.pendingApprovalId.slice(-6)} + </div> + </div> + ); + } return ( <div className="text-sm text-gray-400">-</div> @@ -296,7 +319,10 @@ export function getDetailColumns({ const name = row.getValue("vendorName") as string | null const contract = row.original const isGTCTemplate = contract.templateName?.includes('GTC') + const isComplianceContract = contract.templateName?.includes('준법') const contractGtcData = gtcData[contract.id] + const complianceNegotiation = agreementCommentData[contract.id] + const isNegotiationCompleted = !!contract.negotiationCompletedAt const handleOpenGTC = (e: React.MouseEvent) => { e.stopPropagation() @@ -345,6 +371,48 @@ export function getDetailColumns({ )} </div> )} + {isComplianceContract && ( + <div className="flex items-center gap-1"> + {isLoadingAgreementCommentData ? ( + <Loader2 className="h-3 w-3 animate-spin text-gray-400" /> + ) : isNegotiationCompleted ? ( + <Badge + variant="outline" + className="text-xs bg-green-50 text-green-700 border-green-200" + > + <MessageCircle className="h-3 w-3 mr-1" /> + 협의 완료 + </Badge> + ) : complianceNegotiation?.hasComments ? ( + <Badge + variant="outline" + className="text-xs bg-orange-50 text-orange-700 border-orange-200" + title={`협의 코멘트 ${complianceNegotiation.commentCount}개`} + onClick={(event) => { + event.stopPropagation(); + if (typeof window === "undefined") return; + const params = new URLSearchParams(); + if (contract.templateId) { + params.set("templateId", contract.templateId.toString()); + } + if (contract.vendorId) { + params.set("vendorId", contract.vendorId.toString()); + } + if (contract.vendorName) { + params.set("vendorName", contract.vendorName); + } + const query = params.toString(); + const complianceUrl = `/evcp/basic-contract/compliance-comments/${contract.id}${query ? `?${query}` : ""}`; + window.open(complianceUrl, "_blank", "noopener,noreferrer"); + }} + style={{ cursor: "pointer" }} + > + <MessageCircle className="h-3 w-3 mr-1" /> + 협의 진행중 ({complianceNegotiation.commentCount}) + </Badge> + ) : null} + </div> + )} </div> ) }, diff --git a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx index 2d747c85..010b4713 100644 --- a/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx +++ b/lib/basic-contract/status-detail/basic-contracts-detail-table.tsx @@ -11,6 +11,7 @@ import type { import { getDetailColumns } from "./basic-contracts-detail-columns" import { getBasicContractsByTemplateId } from "@/lib/basic-contract/service" import { checkGTCCommentsForContracts } from "@/lib/basic-contract/actions/check-gtc-comments" +import { checkAgreementCommentsForContracts } from "@/lib/basic-contract/agreement-comments/actions" import { checkRedFlagsForContracts } from "@/lib/basic-contract/actions/check-red-flags" import { checkRedFlagResolutionForContracts } from "@/lib/basic-contract/actions/check-red-flag-resolution" import { BasicContractView } from "@/db/schema" @@ -35,20 +36,27 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac const [gtcData, setGtcData] = React.useState<Record<number, { gtcDocumentId: number | null; hasComments: boolean }>>({}) const [isLoadingGtcData, setIsLoadingGtcData] = React.useState(false) + // 협의 코멘트 상태 (준법 포함) 관리 + const [agreementCommentData, setAgreementCommentData] = React.useState<Record<number, { hasComments: boolean; commentCount: number }>>({}) + const [isLoadingAgreementCommentData, setIsLoadingAgreementCommentData] = React.useState(false) + // Red Flag data 상태 관리 const [redFlagData, setRedFlagData] = React.useState<Record<number, boolean>>({}) const [isLoadingRedFlagData, setIsLoadingRedFlagData] = React.useState(false) +type RedFlagResolutionState = { + resolved: boolean + resolvedAt: Date | null + pendingApprovalId: string | null +} + // Red Flag 해제 data 상태 관리 - const [redFlagResolutionData, setRedFlagResolutionData] = React.useState<Record<number, { resolved: boolean; resolvedAt: Date | null }>>({}) + const [redFlagResolutionData, setRedFlagResolutionData] = React.useState<Record<number, RedFlagResolutionState>>({}) const [isLoadingRedFlagResolutionData, setIsLoadingRedFlagResolutionData] = React.useState(false) const [{ data, pageCount }] = React.use(promises) const router = useRouter() - console.log(gtcData, "gtcData") - console.log(data, "data") - // GTC data 로딩 React.useEffect(() => { const loadGtcData = async () => { @@ -76,6 +84,32 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac loadGtcData(); }, [data]); + // 협의 코멘트 상태 로딩 (준법 포함) + React.useEffect(() => { + const loadAgreementComments = async () => { + if (!data || data.length === 0) return; + + const hasNegotiationTemplates = data.some(contract => + contract.templateName?.includes('준법') || contract.templateName?.includes('GTC') + ); + + if (!hasNegotiationTemplates) return; + + setIsLoadingAgreementCommentData(true); + try { + const results = await checkAgreementCommentsForContracts(data); + setAgreementCommentData(results); + } catch (error) { + console.error('협의 코멘트 정보를 불러오는데 실패했습니다:', error); + toast.error("협의 코멘트 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoadingAgreementCommentData(false); + } + }; + + loadAgreementComments(); + }, [data]); + // Red Flag data 로딩 React.useEffect(() => { const loadRedFlagData = async () => { @@ -141,6 +175,8 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac setRowAction, gtcData, isLoadingGtcData, + agreementCommentData, + isLoadingAgreementCommentData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, @@ -148,7 +184,7 @@ export function BasicContractsDetailTable({ templateId, promises }: BasicContrac isComplianceTemplate, router }), - [setRowAction, gtcData, isLoadingGtcData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, isLoadingRedFlagResolutionData, isComplianceTemplate, router] + [setRowAction, gtcData, isLoadingGtcData, agreementCommentData, isLoadingAgreementCommentData, redFlagData, isLoadingRedFlagData, redFlagResolutionData, isLoadingRedFlagResolutionData, isComplianceTemplate, router] ) const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [ diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 662d7ea9..e5aab10d 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -126,17 +126,18 @@ const canCompleteCurrentContract = React.useMemo(() => { const isComplianceTemplate = selectedContract.templateName?.includes('준법'); const isGTCTemplate = selectedContract.templateName?.includes('GTC'); + const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate; const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - // GTC 체크 수정 - const gtcStatus = gtcCommentStatus[contractId]; - const gtcCompleted = isGTCTemplate ? - (!gtcStatus?.hasComments || gtcStatus?.isComplete === true) : true; + const negotiationStatus = gtcCommentStatus[contractId]; + const negotiationCleared = requiresNegotiationComplete + ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) + : true; const signatureCompleted = signatureStatus[contractId] === true; - return surveyCompleted && gtcCompleted && signatureCompleted; + return surveyCompleted && negotiationCleared && signatureCompleted; }, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]); @@ -341,7 +342,11 @@ const canCompleteCurrentContract = React.useMemo(() => { const isComplianceTemplate = selectedContract.templateName?.includes('준법'); const isGTCTemplate = selectedContract.templateName?.includes('GTC'); const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - const gtcCompleted = isGTCTemplate ? (gtcCommentStatus[contractId]?.isComplete !== true) : true; + const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate; + const negotiationStatus = gtcCommentStatus[contractId]; + const negotiationCleared = requiresNegotiationComplete + ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) + : true; const signatureCompleted = signatureStatus[contractId] === true; if (!surveyCompleted) { @@ -353,7 +358,7 @@ const canCompleteCurrentContract = React.useMemo(() => { return; } - if (!gtcCompleted) { + if (!negotiationCleared) { toast({ title: "코멘트가 있어 서명할 수 없습니다.", description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.", @@ -705,9 +710,13 @@ const canCompleteCurrentContract = React.useMemo(() => { // 계약서별 완료 상태 확인 const isComplianceTemplate = contract.templateName?.includes('준법'); - const isGTCTemplate = contract.templateName?.includes('GTC'); + const isGTCTemplate = contract.templateName?.includes('GTC'); + const requiresNegotiation = isComplianceTemplate || isGTCTemplate; const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true; - const hasGtcCompleted = isGTCTemplate ? (gtcCommentStatus[contract.id]?.hasComments !== true) : true; + const negotiationStatus = gtcCommentStatus[contract.id]; + const hasNegotiationCompleted = requiresNegotiation + ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) + : true; const hasSignatureCompleted = signatureStatus[contract.id] === true; return ( @@ -776,9 +785,9 @@ const canCompleteCurrentContract = React.useMemo(() => { 설문 </span> )} - {isGTCTemplate && ( - <span className={`flex items-center ${hasGtcCompleted ? 'text-green-600' : 'text-red-600'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${hasGtcCompleted ? 'text-green-500' : 'text-red-500'}`} /> + {requiresNegotiation && ( + <span className={`flex items-center ${hasNegotiationCompleted ? 'text-green-600' : 'text-red-600'}`}> + <CheckCircle2 className={`h-3 w-3 mr-1 ${hasNegotiationCompleted ? 'text-green-500' : 'text-red-500'}`} /> 협의 </span> )} @@ -953,11 +962,20 @@ const canCompleteCurrentContract = React.useMemo(() => { 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'} </span> )} - {selectedContract.templateName?.includes('GTC') && ( - <span className={`flex items-center ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-600' : 'text-red-600'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? 'text-green-500' : 'text-red-500'}`} /> - 협의 {(gtcCommentStatus[selectedContract.id]?.isComplete === true) ? '완료' : - `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`} + {(selectedContract.templateName?.includes('GTC') || selectedContract.templateName?.includes('준법')) && ( + <span className={`flex items-center ${ + (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments + ? 'text-green-600' + : 'text-red-600' + }`}> + <CheckCircle2 className={`h-3 w-3 mr-1 ${ + (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments + ? 'text-green-500' + : 'text-red-500' + }`} /> + 협의 {(!gtcCommentStatus[selectedContract.id]?.hasComments || gtcCommentStatus[selectedContract.id]?.isComplete === true) + ? '완료' + : `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`} </span> )} <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}> diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 54e9c18c..6bf0dfb1 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -745,6 +745,7 @@ export function BasicContractSignViewer({ const isComplianceTemplate = mode === 'buyer' ? false : templateName.includes('준법'); const isNDATemplate = mode === 'buyer' ? false : (templateName.includes('비밀유지') || templateName.includes('NDA')); const isGTCTemplate = mode === 'buyer' ? false : templateName.includes('GTC'); + const hasNegotiationTab = mode === 'buyer' ? false : (isGTCTemplate || isComplianceTemplate); const allFiles: FileInfo[] = React.useMemo(() => { const files: FileInfo[] = []; @@ -780,7 +781,7 @@ export function BasicContractSignViewer({ }); } - if (isGTCTemplate) { + if (hasNegotiationTab) { files.push({ path: "", name: "협의 코멘트", @@ -789,7 +790,7 @@ export function BasicContractSignViewer({ } return files; - }, [filePath, additionalFiles, templateName, isComplianceTemplate, isGTCTemplate, mode]); + }, [filePath, additionalFiles, templateName, isComplianceTemplate, hasNegotiationTab, mode]); const cleanupHtmlStyle = () => { const elements = document.querySelectorAll('.Document_container'); @@ -982,7 +983,7 @@ export function BasicContractSignViewer({ if (!isWidgetSignature) { if (annot.Subject === 'Signature') { // 지정된 서명란 외 서명 → 즉시 삭제 - annotationManager.deleteAnnotation(annot, false); + annotationManager.deleteAnnotation(annot, { imported: false }); } continue; } @@ -994,7 +995,7 @@ export function BasicContractSignViewer({ if (name && allowed.length > 0 && !allowed.includes(name)) { // 우리가 만든 서명 필드가 아니면 막기 - annotationManager.deleteAnnotation(annot, false); + annotationManager.deleteAnnotation(annot, { imported: false }); continue; } @@ -1285,7 +1286,7 @@ export function BasicContractSignViewer({ return; } - if (isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) { + if (hasNegotiationTab && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) { toast({ title: "미해결 코멘트가 있어 서명할 수 없습니다.", description: "모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요.", @@ -1555,7 +1556,7 @@ export function BasicContractSignViewer({ {mode !== 'buyer' && isComplianceTemplate && ( <span className="block mt-1 text-amber-600">📋 준법 설문조사를 먼저 완료해주세요.</span> )} - {mode !== 'buyer' && isGTCTemplate && ( + {mode !== 'buyer' && hasNegotiationTab && ( <span className="block mt-1 text-blue-600">📋 협의 코멘트를 확인하고 모든 협의가 완료되었는지 확인해주세요.</span> )} {hasSignatureFields && !isAutoSignProcessing && ( @@ -1568,12 +1569,12 @@ export function BasicContractSignViewer({ ✅ {mode === 'buyer' ? '승인이' : '서명이'} 완료되었습니다. </span> )} - {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && ( + {mode !== 'buyer' && hasNegotiationTab && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && ( <span className="block mt-1 text-red-600"> ⚠️ {gtcCommentStatus.commentCount}개의 미해결 코멘트가 있어 서명할 수 없습니다. </span> )} - {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && ( + {mode !== 'buyer' && hasNegotiationTab && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && ( <span className="block mt-1 text-green-600"> ✅ 협의가 완료되어 서명 가능합니다. </span> diff --git a/lib/compliance/approval-handlers.ts b/lib/compliance/approval-handlers.ts new file mode 100644 index 00000000..05f92a28 --- /dev/null +++ b/lib/compliance/approval-handlers.ts @@ -0,0 +1,64 @@ +"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 { revalidatePath } from "next/cache" + +interface RedFlagResolutionPayload { + contractIds: number[] +} + +/** + * 결재 승인 후 RED FLAG 해제를 처리하는 핸들러 + * + * approval-workflow에서 자동으로 호출됩니다. + */ +export async function resolveRedFlagAfterApproval(payload: RedFlagResolutionPayload) { + if (!payload?.contractIds || payload.contractIds.length === 0) { + return { + success: false, + message: "처리할 계약서가 없습니다.", + } + } + + const uniqueContractIds = Array.from(new Set(payload.contractIds)) + + // 이미 해제된 계약을 제외한 대상을 조회 + const targets = await db + .select({ + basicContractId: complianceResponses.basicContractId, + approvalId: complianceResponses.redFlagResolutionApprovalId, + }) + .from(complianceResponses) + .where( + and( + inArray(complianceResponses.basicContractId, uniqueContractIds), + isNull(complianceResponses.redFlagResolvedAt) + ) + ) + + if (targets.length === 0) { + return { + success: true, + message: "해제 대상이 없습니다.", + } + } + + for (const target of targets) { + await resolveRedFlag(target.basicContractId, { + approvalId: target.approvalId ?? undefined, + revalidate: false, + }) + } + + await revalidatePath("/evcp/basic-contract") + await revalidatePath("/evcp/compliance") + + return { + success: true, + updated: targets.length, + } +} + 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}건` +} diff --git a/lib/knox-api/approval/approval.ts b/lib/knox-api/approval/approval.ts index 5599c066..51fb3f7f 100644 --- a/lib/knox-api/approval/approval.ts +++ b/lib/knox-api/approval/approval.ts @@ -4,7 +4,6 @@ import { getKnoxConfig, createJsonHeaders, createFormHeaders } from '../common'; import { randomUUID } from 'crypto'; import { saveApprovalToDatabase, deleteApprovalFromDatabase, upsertApprovalStatus } from './service'; import { debugLog, debugError } from '@/lib/debug-utils' -import { resolveRedFlag } from '@/lib/compliance/red-flag-resolution' // Knox API Approval 서버 액션들 // 가이드: lib/knox-api/approval/guide.html @@ -831,34 +830,6 @@ export async function syncApprovalStatusAction(): Promise<{ // upsert를 사용한 상태 업데이트 await upsertApprovalStatus(statusData.apInfId, statusData.status); updated++; - - // RED FLAG 해소 결재 완료 확인 및 처리 - if (['2', '5', '6'].includes(statusData.status)) { - // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리 - try { - const db = (await import('@/db/db')).default; - const { complianceResponses } = await import('@/db/schema/compliance'); - const { eq } = await import('drizzle-orm'); - - // 해당 결재 ID로 compliance response 조회 - const response = await db - .select({ - id: complianceResponses.id, - basicContractId: complianceResponses.basicContractId, - }) - .from(complianceResponses) - .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId)) - .limit(1); - - if (response[0]) { - // RED FLAG 해소 처리 - await resolveRedFlag(response[0].basicContractId, statusData.apInfId); - } - } catch (redFlagError) { - console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError); - // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음 - } - } } } catch (updateError) { console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError); @@ -945,37 +916,9 @@ export async function syncSpecificApprovalStatusAction( // 조회된 상태로 데이터베이스 업데이트 for (const statusData of statusResponse.data) { try { - // upsert를 사용한 상태 업데이트 - await upsertApprovalStatus(statusData.apInfId, statusData.status); - updated++; - - // RED FLAG 해소 결재 완료 확인 및 처리 - if (['2', '5', '6'].includes(statusData.status)) { - // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리 - try { - const db = (await import('@/db/db')).default; - const { complianceResponses } = await import('@/db/schema/compliance'); - const { eq } = await import('drizzle-orm'); - - // 해당 결재 ID로 compliance response 조회 - const response = await db - .select({ - id: complianceResponses.id, - basicContractId: complianceResponses.basicContractId, - }) - .from(complianceResponses) - .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId)) - .limit(1); - - if (response[0]) { - // RED FLAG 해소 처리 - await resolveRedFlag(response[0].basicContractId, statusData.apInfId); - } - } catch (redFlagError) { - console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError); - // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음 - } - } + // upsert를 사용한 상태 업데이트 + await upsertApprovalStatus(statusData.apInfId, statusData.status); + updated++; } catch (updateError) { console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError); failed.push(statusData.apInfId); @@ -1128,33 +1071,6 @@ export async function getApprovalLogsAction(): Promise<{ // 메모리상의 데이터도 업데이트 currentLog.status = statusData.status; updatedCount++; - - // RED FLAG 해소 결재 완료 확인 및 처리 - if (['2', '5', '6'].includes(statusData.status)) { - // 완결(2), 전결(5), 후완결(6) 상태인 경우 RED FLAG 해소 처리 - try { - const { complianceResponses } = await import('@/db/schema/compliance'); - const { eq } = await import('drizzle-orm'); - - // 해당 결재 ID로 compliance response 조회 - const response = await db - .select({ - id: complianceResponses.id, - basicContractId: complianceResponses.basicContractId, - }) - .from(complianceResponses) - .where(eq(complianceResponses.redFlagResolutionApprovalId, statusData.apInfId)) - .limit(1); - - if (response[0]) { - // RED FLAG 해소 처리 - await resolveRedFlag(response[0].basicContractId, statusData.apInfId); - } - } catch (redFlagError) { - console.error(`RED FLAG 해소 처리 실패 (${statusData.apInfId}):`, redFlagError); - // RED FLAG 해소 실패는 결재 상태 업데이트를 무효화하지 않음 - } - } } catch (updateError) { console.error(`결재상태 업데이트 실패 (${statusData.apInfId}):`, updateError); } diff --git a/lib/mail/templates/agreement-comment-notification.hbs b/lib/mail/templates/agreement-comment-notification.hbs index 67ccbdd4..9732af6c 100644 --- a/lib/mail/templates/agreement-comment-notification.hbs +++ b/lib/mail/templates/agreement-comment-notification.hbs @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>GTC 기본계약서 협의 코멘트 알림</title> + <title>{{documentTypeLabel}} 협의 코멘트 알림</title> <style> body { font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; @@ -99,7 +99,7 @@ <body> <div class="container"> <div class="header"> - <h1>📝 GTC 기본계약서 협의 알림</h1> + <h1>📝 {{documentTypeLabel}} 협의 알림</h1> </div> <div class="content"> @@ -107,7 +107,7 @@ <p> <strong class="highlight">{{authorType}}</strong>의 <strong>{{authorName}}</strong>님이 - GTC 기본계약서에 새로운 협의 코멘트를 작성했습니다. + {{documentTypeLabel}}에 새로운 협의 코멘트를 작성했습니다. </p> <div class="info-box"> diff --git a/lib/mail/templates/negotiation-complete-notification.hbs b/lib/mail/templates/negotiation-complete-notification.hbs index d82d312f..e5220a97 100644 --- a/lib/mail/templates/negotiation-complete-notification.hbs +++ b/lib/mail/templates/negotiation-complete-notification.hbs @@ -3,7 +3,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>GTC 기본계약서 협의 완료</title> + <title>{{documentTypeLabel}} 협의 완료</title> </head> <body style="margin: 0; padding: 0; font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; background-color: #f4f4f4;"> <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;"> @@ -27,7 +27,7 @@ </p> <p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #555555;"> - <strong>{{vendorName}}</strong>와(과)의 <strong>{{templateName}}</strong> 협의가 완료되었습니다. + <strong>{{vendorName}}</strong>와(과)의 <strong>{{documentTypeLabel}}</strong> 협의가 완료되었습니다. </p> <div style="background-color: #f8f9fa; border-left: 4px solid #28a745; padding: 15px; margin: 20px 0; border-radius: 4px;"> |
