diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-06 17:44:59 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-06 17:44:59 +0900 |
| commit | 08b73d56c2d887931cecdf2b0af6b277381763e6 (patch) | |
| tree | e2a1e466445c718dad79c100241048684b8a1923 /lib/vendors | |
| parent | ba43cd261d10c6b0c5218a9da3f946993b21de6e (diff) | |
(김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
Diffstat (limited to 'lib/vendors')
| -rw-r--r-- | lib/vendors/approval-actions.ts | 177 | ||||
| -rw-r--r-- | lib/vendors/blacklist-check.ts | 170 | ||||
| -rw-r--r-- | lib/vendors/table/approve-vendor-dialog.tsx | 185 |
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 |
