summaryrefslogtreecommitdiff
path: root/lib/vendors
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendors')
-rw-r--r--lib/vendors/approval-actions.ts177
-rw-r--r--lib/vendors/blacklist-check.ts170
-rw-r--r--lib/vendors/table/approve-vendor-dialog.tsx185
3 files changed, 453 insertions, 79 deletions
diff --git a/lib/vendors/approval-actions.ts b/lib/vendors/approval-actions.ts
index 69d09caa..1ce96078 100644
--- a/lib/vendors/approval-actions.ts
+++ b/lib/vendors/approval-actions.ts
@@ -2,9 +2,132 @@
import { ApprovalSubmissionSaga } from '@/lib/approval'
import type { ApprovalConfig } from '@/lib/approval/types'
+import { htmlTableConverter, htmlDescriptionList } from '@/lib/approval'
import db from '@/db/db'
-import { vendors } from '@/db/schema/vendors'
-import { inArray } from 'drizzle-orm'
+import { vendors, vendorTypes } from '@/db/schema/vendors'
+import { inArray, eq } from 'drizzle-orm'
+
+/**
+ * 벤더 승인 결재 템플릿 변수 생성
+ *
+ * @param vendorIds - 벤더 ID 목록
+ * @param currentUserEmail - 현재 사용자 이메일
+ * @returns 템플릿 변수 객체 및 벤더 정보
+ */
+export async function prepareVendorApprovalVariables(
+ vendorIds: number[],
+ currentUserEmail?: string
+) {
+ // 벤더 정보 조회
+ const vendorRecords = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ email: vendors.email,
+ status: vendors.status,
+ taxId: vendors.taxId,
+ country: vendors.country,
+ representativeName: vendors.representativeName,
+ phone: vendors.phone,
+ website: vendors.website,
+ representativeEmail: vendors.representativeEmail,
+ postalCode: vendors.postalCode,
+ address: vendors.address,
+ addressDetail: vendors.addressDetail,
+ corporateRegistrationNumber: vendors.corporateRegistrationNumber,
+ businessSize: vendors.businessSize,
+ vendorTypeId: vendors.vendorTypeId,
+ vendorTypeName: vendorTypes.nameKo,
+ })
+ .from(vendors)
+ .leftJoin(vendorTypes, eq(vendors.vendorTypeId, vendorTypes.id))
+ .where(inArray(vendors.id, vendorIds))
+
+ if (vendorRecords.length === 0) {
+ throw new Error(`벤더를 찾을 수 없습니다: ${vendorIds.join(', ')}`)
+ }
+
+ // PENDING_REVIEW 상태가 아닌 벤더 확인
+ const invalidVendors = vendorRecords.filter(v => v.status !== 'PENDING_REVIEW')
+ if (invalidVendors.length > 0) {
+ throw new Error(
+ `가입 신청 중(PENDING_REVIEW) 상태의 벤더만 승인할 수 있습니다. ` +
+ `잘못된 상태: ${invalidVendors.map(v => `${v.vendorName}(${v.status})`).join(', ')}`
+ )
+ }
+
+ // 업체규모 매핑
+ const businessSizeMap: Record<string, string> = {
+ 'A': '대기업',
+ 'B': '중견기업',
+ 'C': '중소기업',
+ 'D': '소기업',
+ }
+
+ // 벤더 목록 테이블 생성
+ const vendorListTable = await htmlTableConverter(
+ vendorRecords.map(v => ({
+ vendorName: v.vendorName || '-',
+ representativeName: v.representativeName || '-',
+ vendorType: v.vendorTypeName || '-',
+ businessSize: v.businessSize ? businessSizeMap[v.businessSize] || v.businessSize : '-',
+ phone: v.phone || '-',
+ email: v.representativeEmail || '-',
+ })),
+ [
+ { key: 'vendorName', label: '업체명' },
+ { key: 'representativeName', label: '대표자명' },
+ { key: 'vendorType', label: '업체유형' },
+ { key: 'businessSize', label: '기업규모' },
+ { key: 'phone', label: '전화번호' },
+ { key: 'email', label: '이메일' },
+ ]
+ )
+
+ // 벤더별 상세 정보
+ const vendorDetailsHtml = (
+ await Promise.all(
+ vendorRecords.map(async (v) => {
+ const details = await htmlDescriptionList([
+ { label: '업체명', value: v.vendorName || '-' },
+ { label: '대표자명', value: v.representativeName || '-' },
+ { label: '업체유형', value: v.vendorTypeName || '-' },
+ { label: '기업규모', value: v.businessSize ? businessSizeMap[v.businessSize] || v.businessSize : '-' },
+ { label: '국가', value: v.country || '-' },
+ { label: '우편번호', value: v.postalCode || '-' },
+ { label: '주소', value: v.address || '-' },
+ { label: '상세주소', value: v.addressDetail || '-' },
+ { label: '전화번호', value: v.phone || '-' },
+ { label: '이메일', value: v.representativeEmail || '-' },
+ { label: '홈페이지', value: v.website || '-' },
+ { label: '사업자등록번호', value: v.taxId || '-' },
+ { label: '법인등록번호', value: v.corporateRegistrationNumber || '-' },
+ ])
+ return `<div style="margin-bottom: 30px;"><h3 style="font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #333;">${v.vendorName}</h3>${details}</div>`
+ })
+ )
+ ).join('')
+
+ const variables: Record<string, string> = {
+ '업체수': String(vendorRecords.length),
+ '업체목록': vendorRecords.map(v => v.vendorName).join(', '),
+ '업체목록테이블': vendorListTable,
+ '업체상세정보': vendorDetailsHtml,
+ '요청일': new Date().toLocaleDateString('ko-KR', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ }),
+ '요청자': currentUserEmail || '시스템',
+ }
+
+ return {
+ variables,
+ vendorRecords,
+ vendorNames: vendorRecords.map(v => v.vendorName).join(', '),
+ }
+}
/**
* 벤더 가입 승인 결재 상신
@@ -33,49 +156,15 @@ export async function approveVendorsWithApproval(input: {
throw new Error('승인할 벤더를 선택해주세요.')
}
- // 2. 벤더 정보 조회
- const vendorRecords = await db
- .select({
- id: vendors.id,
- vendorName: vendors.vendorName,
- vendorCode: vendors.vendorCode,
- email: vendors.email,
- status: vendors.status,
- taxId: vendors.taxId,
- country: vendors.country,
- })
- .from(vendors)
- .where(inArray(vendors.id, input.vendorIds))
-
- if (vendorRecords.length === 0) {
- throw new Error(`벤더를 찾을 수 없습니다: ${input.vendorIds.join(', ')}`)
- }
-
- // 3. PENDING_REVIEW 상태가 아닌 벤더 확인
- const invalidVendors = vendorRecords.filter(v => v.status !== 'PENDING_REVIEW')
- if (invalidVendors.length > 0) {
- throw new Error(
- `가입 신청 중(PENDING_REVIEW) 상태의 벤더만 승인할 수 있습니다. ` +
- `잘못된 상태: ${invalidVendors.map(v => `${v.vendorName}(${v.status})`).join(', ')}`
- )
- }
-
- console.log(`[Vendor Approval Action] ${vendorRecords.length}개 벤더 조회 완료`)
-
- // 4. 템플릿 변수 준비 (TODO: 실제 템플릿에 맞게 수정 필요)
- const variables: Record<string, string> = {
- // TODO: 다음 대화에서 제공될 템플릿에 맞게 변수 매핑
- '업체수': String(vendorRecords.length),
- '업체목록': vendorRecords.map(v =>
- `${v.vendorName} (${v.vendorCode || '코드 미할당'})`
- ).join('\n'),
- '요청일': new Date().toLocaleDateString('ko-KR'),
- '요청자': input.currentUser.email || 'Unknown',
- }
+ // 2. 템플릿 변수 준비
+ const { variables, vendorRecords, vendorNames } = await prepareVendorApprovalVariables(
+ input.vendorIds,
+ input.currentUser.email
+ )
- console.log(`[Vendor Approval Action] 템플릿 변수 준비 완료`)
+ console.log(`[Vendor Approval Action] ${vendorRecords.length}개 벤더 조회 및 템플릿 변수 준비 완료`)
- // 5. 결재 상신 (Saga 패턴)
+ // 3. 결재 상신 (Saga 패턴)
const saga = new ApprovalSubmissionSaga(
'vendor_approval', // 핸들러 타입 (handlers-registry에 등록될 키)
{
@@ -84,7 +173,7 @@ export async function approveVendorsWithApproval(input: {
},
{
title: `벤더 가입 승인 요청 - ${vendorRecords.length}개 업체`,
- description: `${vendorRecords.map(v => v.vendorName).join(', ')} 의 가입을 승인합니다.`,
+ description: `${vendorNames} 의 가입을 승인합니다.`,
templateName: '벤더 가입 승인 요청',
variables,
approvers: input.approvers,
diff --git a/lib/vendors/blacklist-check.ts b/lib/vendors/blacklist-check.ts
new file mode 100644
index 00000000..c72bb1bf
--- /dev/null
+++ b/lib/vendors/blacklist-check.ts
@@ -0,0 +1,170 @@
+"use server";
+
+import crypto from 'crypto';
+import { oracleKnex } from '@/lib/oracle-db/db';
+
+/**
+ * 문자열을 SHA-1으로 해시하여 대문자로 반환
+ */
+function sha1Hash(text: string): string {
+ return crypto
+ .createHash('sha1')
+ .update(text, 'utf8')
+ .digest('hex')
+ .toUpperCase();
+}
+
+/**
+ * 생년월일에서 숫자만 추출하고 YYMMDD 형식(6자리)로 변환
+ * 예: "1999-01-01" -> "990101"
+ * 예: "99-01-01" -> "990101"
+ * 예: "1988-02-06" -> "880206"
+ */
+function processBirthDate(birthDate: string | null | undefined): string {
+ if (!birthDate) {
+ throw new Error('생년월일 정보가 없습니다.');
+ }
+
+ // 숫자만 추출
+ const numbersOnly = birthDate.replace(/\D/g, '');
+
+ if (numbersOnly.length === 6) {
+ // 6자리(YYMMDD)면 그대로 사용
+ return numbersOnly;
+ } else if (numbersOnly.length === 8) {
+ // 8자리(YYYYMMDD)면 앞 2자리 제거하여 YYMMDD로 변환
+ return numbersOnly.substring(2);
+ } else {
+ throw new Error(`생년월일 형식이 올바르지 않습니다. (입력값: ${birthDate}, 숫자 길이: ${numbersOnly.length})`);
+ }
+}
+
+/**
+ * 블랙리스트 검사 결과 타입
+ */
+export interface BlacklistCheckResult {
+ isBlacklisted: boolean;
+ message: string;
+ count?: number;
+}
+
+/**
+ * 단일 벤더의 블랙리스트 여부 확인
+ */
+export async function checkVendorBlacklist(
+ representativeName: string | null | undefined,
+ representativeBirth: string | null | undefined
+): Promise<BlacklistCheckResult> {
+ try {
+ // 필수 정보 검증
+ if (!representativeName || !representativeBirth) {
+ return {
+ isBlacklisted: false,
+ message: '대표자 이름 또는 생년월일 정보가 없어 블랙리스트 검사를 진행할 수 없습니다.',
+ };
+ }
+
+ // 이름 해시값 생성
+ const nameHash = sha1Hash(representativeName);
+
+ // 생년월일 처리 (YYMMDD 6자리로 변환)
+ let birthProcessed: string;
+ try {
+ birthProcessed = processBirthDate(representativeBirth);
+ } catch (error) {
+ return {
+ isBlacklisted: false,
+ message: error instanceof Error ? error.message : '생년월일 처리 중 오류가 발생했습니다.',
+ };
+ }
+
+ // YYMMDD 전체를 해시 계산
+ // 예: "880206" → SHA1("880206")
+ const birthHash = sha1Hash(birthProcessed);
+
+ console.log('🔍 [블랙리스트 검사]', {
+ input: representativeBirth,
+ processed: birthProcessed,
+ nameHash,
+ birthHash
+ });
+
+ // Oracle DB 조회
+ const result = await oracleKnex
+ .select(oracleKnex.raw('COUNT(*) as cnt'))
+ .from('SHIVND.AMG0070')
+ .where('NM', nameHash)
+ .andWhere('BRDT', birthHash)
+ .first() as unknown as { cnt?: number; CNT?: number } | undefined;
+
+ const count = Number(result?.cnt || result?.CNT || 0);
+ const isBlacklisted = count > 0;
+
+ if (isBlacklisted) {
+ return {
+ isBlacklisted: true,
+ message: '블랙리스트에 등록된 대표자입니다. 가입 승인을 진행할 수 없습니다.',
+ count,
+ };
+ }
+
+ return {
+ isBlacklisted: false,
+ message: '블랙리스트 검사 통과',
+ count: 0,
+ };
+ } catch (error) {
+ console.error('블랙리스트 검사 오류:', error);
+ throw new Error('블랙리스트 검사 중 오류가 발생했습니다.');
+ }
+}
+
+/**
+ * 여러 벤더의 블랙리스트 여부 일괄 확인
+ */
+export async function checkVendorsBlacklist(
+ vendors: Array<{
+ id: string;
+ name: string;
+ representativeName: string | null;
+ representativeBirth: string | null;
+ }>
+): Promise<{
+ success: boolean;
+ blacklistedVendors: Array<{ id: string; name: string; message: string }>;
+ checkedCount: number;
+}> {
+ const blacklistedVendors: Array<{ id: string; name: string; message: string }> = [];
+
+ for (const vendor of vendors) {
+ try {
+ const result = await checkVendorBlacklist(
+ vendor.representativeName,
+ vendor.representativeBirth
+ );
+
+ if (result.isBlacklisted) {
+ blacklistedVendors.push({
+ id: vendor.id,
+ name: vendor.name,
+ message: result.message,
+ });
+ }
+ } catch (error) {
+ // 개별 벤더 검사 실패 시에도 계속 진행
+ console.error(`벤더 ${vendor.name} 블랙리스트 검사 실패:`, error);
+ blacklistedVendors.push({
+ id: vendor.id,
+ name: vendor.name,
+ message: '블랙리스트 검사 중 오류가 발생했습니다.',
+ });
+ }
+ }
+
+ return {
+ success: blacklistedVendors.length === 0,
+ blacklistedVendors,
+ checkedCount: vendors.length,
+ };
+}
+
diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx
index 786399a4..fea5a006 100644
--- a/lib/vendors/table/approve-vendor-dialog.tsx
+++ b/lib/vendors/table/approve-vendor-dialog.tsx
@@ -29,8 +29,10 @@ import {
} from "@/components/ui/drawer"
import { Vendor } from "@/db/schema/vendors"
import { rejectVendors } from "../service"
-import { approveVendorsWithApproval } from "../approval-actions"
+import { approveVendorsWithApproval, prepareVendorApprovalVariables } from "../approval-actions"
import { useSession } from "next-auth/react"
+import { checkVendorsBlacklist } from "../blacklist-check"
+import { ApprovalPreviewDialog } from "@/lib/approval/client"
interface VendorDecisionDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -50,6 +52,17 @@ export function VendorDecisionDialog({
const isDesktop = useMediaQuery("(min-width: 640px)")
const { data: session } = useSession()
+ // 결재 미리보기 다이얼로그 상태
+ const [showPreview, setShowPreview] = React.useState(false)
+ const [previewData, setPreviewData] = React.useState<{
+ variables: Record<string, string>
+ title: string
+ description: string
+ } | null>(null)
+
+ /**
+ * 승인 버튼 클릭 - 미리보기 다이얼로그 열기
+ */
function onApprove() {
if (!session?.user?.id) {
toast.error("사용자 인증 정보를 찾을 수 없습니다.")
@@ -63,37 +76,97 @@ export function VendorDecisionDialog({
startApproveTransition(async () => {
try {
- console.log("🔍 [DEBUG] 결재 상신 시작 - vendors:", vendors.map(v => ({ id: v.id, vendorName: v.vendorName, email: v.email })));
- console.log("🔍 [DEBUG] 세션 정보:", { userId: session.user.id, epId: session.user.epId });
+ // 1. 블랙리스트 검사 (최우선 처리)
+ const vendorsToCheck = vendors.map(v => ({
+ id: String(v.id),
+ name: v.vendorName || '',
+ representativeName: v.representativeName,
+ representativeBirth: v.representativeBirth,
+ }));
+
+ const blacklistCheckResult = await checkVendorsBlacklist(vendorsToCheck);
- const result = await approveVendorsWithApproval({
- vendorIds: vendors.map((vendor) => vendor.id),
- currentUser: {
- id: Number(session.user.id),
- epId: session.user.epId as string, // 위에서 검증했으므로 타입 단언
- email: session.user.email || undefined,
- },
- // TODO: 필요시 approvers 배열 추가
- // approvers: ['EP001', 'EP002'],
- })
-
- if (!result.success) {
- console.error("🚨 [DEBUG] 결재 상신 에러:", result.message);
- toast.error(result.message || "결재 상신에 실패했습니다.")
- return
+ if (!blacklistCheckResult.success) {
+ // 블랙리스트에 있는 벤더 목록 표시
+ const blacklistedNames = blacklistCheckResult.blacklistedVendors
+ .map(v => `• ${v.name}: ${v.message}`)
+ .join('\n');
+
+ toast.error(
+ `문제가 있는 데이터가 있습니다:\n${blacklistedNames}`,
+ { duration: 10000 }
+ );
+ return;
}
- console.log("✅ [DEBUG] 결재 상신 성공:", result);
- props.onOpenChange?.(false)
- toast.success(`결재가 상신되었습니다. (결재ID: ${result.approvalId})`)
- onSuccess?.()
+ // 2. 템플릿 변수 준비
+ const { variables, vendorRecords, vendorNames } = await prepareVendorApprovalVariables(
+ vendors.map(v => v.id),
+ session.user.email || undefined
+ );
+
+ // 3. 미리보기 데이터 설정
+ setPreviewData({
+ variables,
+ title: `벤더 가입 승인 요청 - ${vendorRecords.length}개 업체`,
+ description: `${vendorNames} 의 가입을 승인합니다.`,
+ });
+
+ // 4. 미리보기 다이얼로그 열기
+ setShowPreview(true);
+
} catch (error) {
- console.error("🚨 [DEBUG] 예상치 못한 에러:", error);
- toast.error("예상치 못한 오류가 발생했습니다.")
+ console.error("🚨 [Vendor Decision] 미리보기 준비 실패:", error);
+ toast.error(error instanceof Error ? error.message : "미리보기를 준비하는 중 오류가 발생했습니다.")
}
})
}
+ /**
+ * 결재 미리보기에서 확인 클릭 - 실제 결재 상신
+ */
+ async function handleApprovalConfirm(approvalData: {
+ approvers: string[]
+ title: string
+ description?: string
+ }) {
+ if (!session?.user?.id || !session?.user?.epId) {
+ toast.error("사용자 인증 정보가 없습니다.")
+ return
+ }
+
+ try {
+ const result = await approveVendorsWithApproval({
+ vendorIds: vendors.map((vendor) => vendor.id),
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ email: session.user.email || undefined,
+ },
+ approvers: approvalData.approvers, // 미리보기에서 설정한 결재선
+ })
+
+ if (!result.success) {
+ console.error("🚨 [Vendor Decision] 결재 상신 에러:", result.message);
+ toast.error(result.message || "결재 상신에 실패했습니다.")
+ return
+ }
+
+ console.log("✅ [Vendor Decision] 결재 상신 성공:", result);
+
+ // 다이얼로그 모두 닫기
+ setShowPreview(false)
+ props.onOpenChange?.(false)
+
+ toast.success(`결재가 상신되었습니다. (결재ID: ${result.approvalId})`)
+ onSuccess?.()
+
+ } catch (error) {
+ console.error("🚨 [Vendor Decision] 예상치 못한 에러:", error);
+ toast.error("예상치 못한 오류가 발생했습니다.")
+ }
+ }
+
function onReject() {
if (!session?.user?.id) {
toast.error("사용자 인증 정보를 찾을 수 없습니다.")
@@ -129,16 +202,17 @@ export function VendorDecisionDialog({
if (isDesktop) {
return (
- <Dialog {...props}>
- {showTrigger ? (
- <DialogTrigger asChild>
- <Button variant="outline" size="sm" className="gap-2">
- <Check className="size-4" aria-hidden="true" />
- 가입 결정 ({vendors.length})
- </Button>
- </DialogTrigger>
- ) : null}
- <DialogContent className="max-w-2xl">
+ <>
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Check className="size-4" aria-hidden="true" />
+ 가입 결정 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>협력업체 가입 결정</DialogTitle>
<DialogDescription>
@@ -199,11 +273,32 @@ export function VendorDecisionDialog({
</DialogFooter>
</DialogContent>
</Dialog>
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {previewData && session?.user?.epId && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="벤더 가입 승인 요청"
+ variables={previewData.variables}
+ title={previewData.title}
+ description={previewData.description}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ />
+ )}
+ </>
)
}
return (
- <Drawer {...props}>
+ <>
+ <Drawer {...props}>
{showTrigger ? (
<DrawerTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
@@ -272,5 +367,25 @@ export function VendorDecisionDialog({
</DrawerFooter>
</DrawerContent>
</Drawer>
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {previewData && session?.user?.epId && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="벤더 가입 승인 요청"
+ variables={previewData.variables}
+ title={previewData.title}
+ description={previewData.description}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalConfirm}
+ />
+ )}
+ </>
)
} \ No newline at end of file