diff options
| -rw-r--r-- | lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx | 124 | ||||
| -rw-r--r-- | lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx | 151 | ||||
| -rw-r--r-- | lib/tech-vendors/service.ts | 757 |
3 files changed, 1022 insertions, 10 deletions
diff --git a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx index 84228a54..7b81967e 100644 --- a/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx +++ b/lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx @@ -10,10 +10,13 @@ import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button"
import { TechVendorContact } from "@/db/schema/techVendors"
import { AddContactDialog } from "./add-contact-dialog"
-import {
- importTechVendorContacts,
- generateContactImportTemplate,
- parseContactImportFile
+import {
+ importTechVendorContacts,
+ generateContactImportTemplate,
+ parseContactImportFile,
+ importTechVendorDistinctContacts,
+ generateDistinctContactImportTemplate,
+ parseDistinctContactImportFile
} from "@/lib/tech-vendors/service"
interface TechVendorContactsTableToolbarActionsProps {
@@ -24,6 +27,7 @@ interface TechVendorContactsTableToolbarActionsProps { export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechVendorContactsTableToolbarActionsProps) {
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
+ const distinctFileInputRef = React.useRef<HTMLInputElement>(null)
// 파일이 선택되었을 때 처리
async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
@@ -99,6 +103,11 @@ export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechV fileInputRef.current?.click()
}
+ function handleDistinctImportClick() {
+ // Distinct Import용 숨겨진 <input type="file" /> 요소를 클릭
+ distinctFileInputRef.current?.click()
+ }
+
async function handleTemplateDownload() {
try {
const templateBlob = await generateContactImportTemplate()
@@ -115,6 +124,89 @@ export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechV }
}
+ // Distinct Import 파일 처리 함수
+ async function onDistinctFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ try {
+ // Excel 파일 파싱 (새로운 함수 사용)
+ const distinctContactData = await parseDistinctContactImportFile(file)
+
+ if (distinctContactData.length === 0) {
+ toast.error("유효한 데이터가 없습니다. 템플릿 형식을 확인해주세요.")
+ return
+ }
+
+ // 서버로 데이터 전송 (새로운 함수 사용)
+ const result = await importTechVendorDistinctContacts(distinctContactData)
+
+ if (result.successCount > 0) {
+ toast.success(`${result.successCount}개 담당자가 성공적으로 추가되었습니다.`)
+ }
+
+ if (result.failedRows.length > 0) {
+ toast.error(`${result.failedRows.length}개 행에서 오류가 발생했습니다.`)
+
+ // 에러 데이터를 Excel로 다운로드
+ const errorWorkbook = new ExcelJS.Workbook()
+ const errorWorksheet = errorWorkbook.addWorksheet("오류내역")
+
+ // 헤더 추가
+ errorWorksheet.columns = [
+ { header: "행번호", key: "row", width: 10 },
+ { header: "벤더이름", key: "vendorName", width: 20 },
+ { header: "이메일", key: "email", width: 25 },
+ { header: "오류내용", key: "error", width: 80, style: { alignment: { wrapText: true }, font: { color: { argb: "FFFF0000" } } } },
+ ]
+
+ // 오류 데이터 추가
+ result.failedRows.forEach(failedRow => {
+ errorWorksheet.addRow({
+ row: failedRow.row,
+ error: failedRow.error,
+ vendorName: failedRow.vendorName,
+ email: failedRow.email,
+ })
+ })
+
+ const buffer = await errorWorkbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "distinct-contact-import-errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ }
+
+ } catch (error) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+ console.error("Distinct Import error:", error)
+ }
+ }
+
+ async function handleDistinctTemplateDownload() {
+ try {
+ const templateBlob = await generateDistinctContactImportTemplate()
+ const url = URL.createObjectURL(templateBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "tech-vendor-distinct-contacts-template.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ toast.success("Distinct Import 템플릿이 다운로드되었습니다.")
+ } catch (error) {
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다.")
+ console.error("Distinct Template download error:", error)
+ }
+ }
+
return (
<div className="flex items-center gap-2">
@@ -123,7 +215,13 @@ export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechV {/** 템플릿 다운로드 버튼 */}
<Button variant="outline" size="sm" className="gap-2" onClick={handleTemplateDownload}>
<Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">템플릿</span>
+ <span className="hidden sm:inline">기본 템플릿</span>
+ </Button>
+
+ {/** Distinct Import 템플릿 다운로드 버튼 */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleDistinctTemplateDownload}>
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Distinct 템플릿</span>
</Button>
{/** Import 버튼 (파일 업로드) */}
@@ -131,6 +229,13 @@ export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechV <Upload className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Import</span>
</Button>
+
+ {/** Distinct Import 버튼 (대표 이메일과 다른 담당자 추가) */}
+ <Button variant="outline" size="sm" className="gap-2" onClick={handleDistinctImportClick}>
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Distinct Import</span>
+ </Button>
+
{/*
실제로는 숨겨진 input과 연결:
- accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용
@@ -143,6 +248,15 @@ export function TechVendorContactsTableToolbarActions({ table, vendorId }: TechV onChange={onFileChange}
/>
+ {/** Distinct Import용 숨겨진 input */}
+ <input
+ ref={distinctFileInputRef}
+ type="file"
+ accept=".xlsx,.xls"
+ className="hidden"
+ onChange={onDistinctFileChange}
+ />
+
{/** Export 버튼 */}
<Button
variant="outline"
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx index 371f88f9..bed65727 100644 --- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx +++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import type { Table } from "@tanstack/react-table" -import { Plus, Trash2, Upload, Download } from "lucide-react" +import { Plus, Trash2, Upload, Download, Users } from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -26,7 +26,14 @@ import { generatePossibleItemsImportTemplate, generatePossibleItemsErrorExcel, type PossibleItemImportData, - type PossibleItemErrorData + type PossibleItemErrorData, + // Contact Possible Import 관련 함수들 + parseContactPossibleItemsImportFile, + importContactPossibleItemsFromExcel, + generateContactPossibleItemsImportTemplate, + generateContactPossibleItemsErrorExcel, + type ContactPossibleItemImportData, + type ContactPossibleItemErrorData } from "../service" interface PossibleItemsTableToolbarActionsProps { @@ -45,7 +52,9 @@ export function PossibleItemsTableToolbarActions({ const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) const [isDeleting, setIsDeleting] = React.useState(false) const [isImporting, setIsImporting] = React.useState(false) + const [isContactImporting, setIsContactImporting] = React.useState(false) const fileInputRef = React.useRef<HTMLInputElement>(null) + const contactFileInputRef = React.useRef<HTMLInputElement>(null) const selectedRows = table.getFilteredSelectedRowModel().rows @@ -163,6 +172,98 @@ export function PossibleItemsTableToolbarActions({ } } + // Contact Possible Import용 템플릿 다운로드 핸들러 + async function handleContactTemplateDownload() { + try { + const templateBlob = await generateContactPossibleItemsImportTemplate() + const url = window.URL.createObjectURL(templateBlob) + const link = document.createElement("a") + link.href = url + link.download = "담당자별_아이템매핑_템플릿.xlsx" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다") + } + } + + // Contact Possible Import용 파일 선택 핸들러 + function handleContactFileSelect() { + contactFileInputRef.current?.click() + } + + // Contact Possible Import 핸들러 + async function handleContactFileImport(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + if (!file) return + + // 파일 타입 검증 + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error("Excel 파일(.xlsx 또는 .xls)만 업로드 가능합니다") + return + } + + setIsContactImporting(true) + try { + // Excel 파일 파싱 (새로운 함수 사용) + const importData: ContactPossibleItemImportData[] = await parseContactPossibleItemsImportFile(file) + + if (importData.length === 0) { + toast.error("업로드할 데이터가 없습니다") + return + } + + // 데이터 import 실행 (새로운 함수 사용) + const result = await importContactPossibleItemsFromExcel(importData) + console.log(result) + // 결과 메시지 생성 + const successMessage = `${result.successCount}개의 아이템 매핑이 성공적으로 등록되었습니다` + const failMessage = result.failedRows.length > 0 + ? `, ${result.failedRows.length}개의 매핑 등록 실패` + : "" + + toast.success(successMessage + failMessage) + + // 실패한 행이 있는 경우 에러 파일 다운로드 + if (result.failedRows.length > 0) { + const errorData: ContactPossibleItemErrorData[] = result.failedRows.map(failedRow => ({ + contactEmail: failedRow.contactEmail, + itemCode: Array.isArray(failedRow.itemCode) ? failedRow.itemCode.join(', ') : failedRow.itemCode, + error: failedRow.error, + })) + + const errorBlob = await generateContactPossibleItemsErrorExcel(errorData) + const url = window.URL.createObjectURL(errorBlob) + const link = document.createElement("a") + link.href = url + link.download = "contact_possible_items_import_에러.xlsx" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.error("에러 내역 파일이 다운로드되었습니다") + } + + // 파일 입력 초기화 + if (contactFileInputRef.current) { + contactFileInputRef.current.value = "" + } + + // 데이터 새로고침 + onRefresh?.() + + } catch (error) { + console.error("Contact Possible Import error:", error) + toast.error(error instanceof Error ? error.message : "데이터 등록 중 오류가 발생했습니다") + } finally { + setIsContactImporting(false) + } + } + return ( <> <div className="flex items-center gap-2"> @@ -201,7 +302,42 @@ export function PossibleItemsTableToolbarActions({ </TooltipContent> </Tooltip> - {/* 숨겨진 파일 입력 */} + {/* Contact Possible Import 템플릿 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={handleContactTemplateDownload} + > + <Download className="mr-2 h-4 w-4" /> + 담당자 템플릿 + </Button> + </TooltipTrigger> + <TooltipContent> + 담당자별 아이템 매핑 템플릿 다운로드 + </TooltipContent> + </Tooltip> + + {/* Contact Possible Import 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={handleContactFileSelect} + disabled={isContactImporting} + > + <Users className="mr-2 h-4 w-4" /> + {isContactImporting ? "등록 중..." : "Contact Import"} + </Button> + </TooltipTrigger> + <TooltipContent> + 담당자별 아이템 매핑 등록 + </TooltipContent> + </Tooltip> + + {/* 숨겨진 파일 입력들 */} <input ref={fileInputRef} type="file" @@ -210,6 +346,15 @@ export function PossibleItemsTableToolbarActions({ className="hidden" /> + {/* Contact Possible Import용 숨겨진 파일 입력 */} + <input + ref={contactFileInputRef} + type="file" + accept=".xlsx,.xls" + onChange={handleContactFileImport} + className="hidden" + /> + {/* 아이템 추가 버튼 주석처리 */} <Button variant="outline" diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index c5926e8e..72f8632d 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -39,6 +39,7 @@ import path from "path"; import { sql } from "drizzle-orm"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; import { deleteFile, saveDRMFile } from "../file-stroage"; +import { techSalesContactPossibleItems } from "@/db/schema"; /* ----------------------------------------------------- 1) 조회 관련 @@ -2729,7 +2730,7 @@ export async function importTechVendorContacts( // 3. 연락처 생성 await db.insert(techVendorContacts).values({ vendorId: vendor.id, - contactName: row.contactName, + contactName: row.contactName || null, contactPosition: row.contactPosition || null, contactEmail: row.contactEmail, contactPhone: row.contactPhone || null, @@ -2765,7 +2766,7 @@ export async function generateContactImportTemplate(): Promise<Blob> { // 헤더 설정 worksheet.columns = [ { header: "벤더대표이메일*", key: "vendorEmail", width: 25 }, - { header: "담당자명*", key: "contactName", width: 20 }, + { header: "담당자명", key: "contactName", width: 20 }, { header: "직책", key: "contactPosition", width: 15 }, { header: "담당자이메일*", key: "contactEmail", width: 25 }, { header: "담당자연락처", key: "contactPhone", width: 15 }, @@ -3363,4 +3364,756 @@ export async function generatePossibleItemsErrorExcel(errors: PossibleItemErrorD return new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }) +} + +/* ----------------------------------------------------- + Distinct Contact Import 관련 함수들 +----------------------------------------------------- */ + +export interface DistinctContactImportData { + vendorName: string + email: string +} + +export interface DistinctContactImportResult { + success: boolean + totalRows: number + successCount: number + failedRows: Array<{ + row: number + error: string + vendorName: string + email: string + }> +} + +/** + * Distinct Contact Import용 템플릿 생성 (벤더명, 이메일만) + */ +export async function generateDistinctContactImportTemplate(): Promise<Blob> { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("벤더담당자_템플릿") + + // 헤더 설정 + worksheet.columns = [ + { header: "벤더이름*", key: "vendorName", width: 25 }, + { header: "이메일*", key: "email", width: 30 }, + ] + + // 헤더 스타일 설정 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0E0E0" }, + } + + // 예시 데이터 추가 + worksheet.addRow({ + vendorName: "ABB", + email: "dong-rak.cho@kr.abb.com", + }) + + worksheet.addRow({ + vendorName: "ABB", + email: "woo-jin.joo@kr.abb.com", + }) + + worksheet.addRow({ + vendorName: "삼성중공업", + email: "contact@samsung.com", + }) + + // 설명 시트 추가 + const infoSheet = workbook.addWorksheet("설명") + infoSheet.getColumn(1).width = 50 + infoSheet.getColumn(2).width = 100 + + infoSheet.addRow(["Distinct Contact Import 사용 방법"]) + infoSheet.addRow(["1. 벤더이름", "벤더의 이름 (필수)"]) + infoSheet.addRow(["2. 이메일", "담당자 이메일 주소 (필수)"]) + infoSheet.addRow([]) + infoSheet.addRow(["기능 설명"]) + infoSheet.addRow(["• 벤더이름과 이메일을 입력하면 자동으로 벤더를 찾습니다"]) + infoSheet.addRow(["• 벤더 대표 이메일과 다른 이메일인 경우에만 담당자로 추가됩니다"]) + infoSheet.addRow(["• 벤더 대표 이메일과 같은 이메일인 경우는 건너뜁니다"]) + infoSheet.addRow(["• 이미 등록된 담당자 이메일은 중복 등록되지 않습니다"]) + infoSheet.addRow([]) + infoSheet.addRow(["예시"]) + infoSheet.addRow(["ABB 벤더의 대표 이메일이 dong-rak.cho@kr.abb.com 인 경우:"]) + infoSheet.addRow([" - dong-rak.cho@kr.abb.com 입력 시: 건너뜀 (대표 이메일과 동일)"]) + infoSheet.addRow([" - woo-jin.joo@kr.abb.com 입력 시: 담당자로 추가됨"]) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) +} + +/** + * Distinct Contact Import용 Excel 파일 파싱 + */ +export async function parseDistinctContactImportFile(file: File): Promise<DistinctContactImportData[]> { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다.") + } + + const data: DistinctContactImportData[] = [] + + worksheet.eachRow((row, index) => { + // 헤더 행 건너뛰기 (1행) + if (index === 1) return + + const values = row.values as (string | null)[] + if (!values || values.length < 2) return + + const vendorName = values[1]?.toString().trim() + const email = values[2]?.toString().trim() + + // 필수 필드 검증 + if (!vendorName || !email) { + return + } + + data.push({ + vendorName, + email, + }) + }) + + return data +} + +/** + * 벤더명으로 벤더 찾기 함수 (대소문자 구분 없이) + */ +async function findVendorByName(vendorName: string) { + const vendor = await db + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + email: techVendors.email, + }) + .from(techVendors) + .where(ilike(techVendors.vendorName, `%${vendorName}%`)) + .limit(1) + + return vendor[0] || null +} + +/** + * 벤더의 기본 담당자(대표 이메일) 조회 + */ +async function getVendorPrimaryContact(vendorId: number) { + const contact = await db + .select({ + contactName: techVendorContacts.contactName, + contactEmail: techVendorContacts.contactEmail, + }) + .from(techVendorContacts) + .where( + and( + eq(techVendorContacts.vendorId, vendorId), + eq(techVendorContacts.contactEmail, techVendors.email) // 기본 담당자 체크 + ) + ) + .innerJoin(techVendors, eq(techVendorContacts.vendorId, techVendors.id)) + .limit(1) + + return contact[0] || null +} + +/** + * Distinct Contact Import 메인 함수 + */ +export async function importTechVendorDistinctContacts( + data: DistinctContactImportData[] +): Promise<DistinctContactImportResult> { + const result: DistinctContactImportResult = { + success: true, + totalRows: data.length, + successCount: 0, + failedRows: [], + } + + for (let i = 0; i < data.length; i++) { + const row = data[i] + const rowNumber = i + 1 + + try { + // 1. 벤더명으로 벤더 찾기 + if (!row.vendorName || !row.vendorName.trim()) { + result.failedRows.push({ + row: rowNumber, + error: "벤더이름은 필수입니다.", + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + const vendor = await findVendorByName(row.vendorName.trim()) + if (!vendor) { + result.failedRows.push({ + row: rowNumber, + error: `벤더이름 '${row.vendorName}'을(를) 찾을 수 없습니다.`, + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + // 2. 이메일 검증 + if (!row.email || !row.email.trim()) { + result.failedRows.push({ + row: rowNumber, + error: "이메일은 필수입니다.", + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + // 이메일 형식 검증 (기본적인 검증) + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(row.email.trim())) { + result.failedRows.push({ + row: rowNumber, + error: "올바른 이메일 형식이 아닙니다.", + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + const email = row.email.trim() + + // 3. 벤더 대표 이메일과 비교 + if (email === vendor.email) { + result.failedRows.push({ + row: rowNumber, + error: "벤더 대표 이메일과 동일하여 건너뜁니다.", + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + // 4. 기존 담당자 중복 체크 + const existingContact = await db + .select() + .from(techVendorContacts) + .where( + and( + eq(techVendorContacts.vendorId, vendor.id), + eq(techVendorContacts.contactEmail, email) + ) + ) + .limit(1) + + if (existingContact.length > 0) { + result.failedRows.push({ + row: rowNumber, + error: "이미 등록된 담당자 이메일입니다.", + vendorName: row.vendorName, + email: row.email, + }) + continue + } + + // 5. 기본 담당자 확인 (대표 이메일이 기본 담당자로 등록되어 있는지) + const primaryContact = await getVendorPrimaryContact(vendor.id) + + // 6. 담당자 추가 + await db.insert(techVendorContacts).values({ + vendorId: vendor.id, + contactName: `${row.vendorName} 담당자`, // 벤더명 + 담당자 + contactPosition: null, + contactEmail: email, + contactPhone: null, + contactCountry: null, + isPrimary: false, + }) + + result.successCount++ + } catch (error) { + result.failedRows.push({ + row: rowNumber, + error: error instanceof Error ? error.message : "알 수 없는 오류", + vendorName: row.vendorName, + email: row.email, + }) + } + } + + // 캐시 무효화 + revalidateTag("tech-vendor-contacts") + + return result +} + +/* ----------------------------------------------------- + Contact Possible Items Import 관련 함수들 +----------------------------------------------------- */ + +export interface ContactPossibleItemImportData { + contactEmail: string + itemCodes: string[] // 쉼표로 분리된 아이템코드 배열 +} + +export interface ContactPossibleItemImportResult { + success: boolean + totalRows: number + successCount: number + failedRows: Array<{ + row: number + error: string + contactEmail: string + itemCode: string + }> +} + +export interface ContactPossibleItemErrorData { + contactEmail: string + itemCode: string + error: string +} + +/** + * Contact Possible Items Import용 템플릿 생성 (이메일, 아이템코드) + */ +export async function generateContactPossibleItemsImportTemplate(): Promise<Blob> { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("담당자별_아이템매핑_템플릿") + + // 헤더 설정 + worksheet.columns = [ + { header: "담당자이메일*", key: "contactEmail", width: 30 }, + { header: "아이템코드*", key: "itemCode", width: 20 }, + ] + + // 헤더 스타일 설정 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0E0E0" }, + } + + // 예시 데이터 추가 + worksheet.addRow({ + contactEmail: "dong-rak.cho@kr.abb.com", + itemCode: "ITEM001,ITEM002", + }) + + worksheet.addRow({ + contactEmail: "woo-jin.joo@kr.abb.com", + itemCode: "TOP001,TOP002,TOP003", + }) + + worksheet.addRow({ + contactEmail: "contact@samsung.com", + itemCode: "HULL001", + }) + + worksheet.addRow({ + contactEmail: "test@example.com", + itemCode: "BF8101,BF6101,BF8401,BF8201", + }) + + // 설명 시트 추가 + const infoSheet = workbook.addWorksheet("설명") + infoSheet.getColumn(1).width = 50 + infoSheet.getColumn(2).width = 100 + + infoSheet.addRow(["Contact Possible Items Import 사용 방법"]) + infoSheet.addRow(["1. 담당자이메일", "등록된 담당자의 이메일 주소 (필수)"]) + infoSheet.addRow(["2. 아이템코드", "기술영업 아이템 코드 (필수)"]) + infoSheet.addRow([]) + infoSheet.addRow(["기능 설명"]) + infoSheet.addRow(["• 담당자 이메일로 등록된 담당자를 찾습니다"]) + infoSheet.addRow(["• 담당자가 속한 벤더의 타입을 확인합니다"]) + infoSheet.addRow(["• 벤더 타입별로 해당 아이템 테이블에서 아이템코드를 검색합니다"]) + infoSheet.addRow(["• 찾은 아이템들을 벤더의 possible items에 추가합니다"]) + infoSheet.addRow(["• 추가된 possible item과 담당자를 연결합니다"]) + infoSheet.addRow([]) + infoSheet.addRow(["예시"]) + infoSheet.addRow(["ABB 벤더의 담당자 dong-rak.cho@kr.abb.com이 ITEM001,ITEM002를 담당하는 경우:"]) + infoSheet.addRow([" - 해당 담당자의 벤더 타입을 확인 (조선, 해양TOP 등)"]) + infoSheet.addRow([" - 해당 타입의 아이템 테이블에서 ITEM001과 ITEM002를 각각 검색"]) + infoSheet.addRow([" - 찾은 아이템들을 벤더의 possible items에 추가 (중복 제외)"]) + infoSheet.addRow([" - 추가된 아이템들과 담당자를 연결"]) + infoSheet.addRow([]) + infoSheet.addRow(["다중 아이템코드 지원"]) + infoSheet.addRow(["• 아이템코드 필드에 쉼표(,)로 구분하여 여러 아이템코드를 입력할 수 있습니다"]) + infoSheet.addRow(["• 예: ITEM001,ITEM002,BF8101,BF6101"]) + infoSheet.addRow(["• 각 아이템코드는 개별적으로 처리되며, 실패한 코드만 에러로 기록됩니다"]) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) +} + +/** + * Contact Possible Items Import용 Excel 파일 파싱 + */ +export async function parseContactPossibleItemsImportFile(file: File): Promise<ContactPossibleItemImportData[]> { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.worksheets[0] + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다.") + } + + const data: ContactPossibleItemImportData[] = [] + + worksheet.eachRow((row, index) => { + // 헤더 행 건너뛰기 (1행) + if (index === 1) return + + const values = row.values as (string | null)[] + if (!values || values.length < 2) return + + const contactEmail = values[1]?.toString().trim() + const itemCodeStr = values[2]?.toString().trim() + + // 필수 필드 검증 + if (!contactEmail || !itemCodeStr) { + return + } + + // 아이템코드를 쉼표로 분리하고 공백 제거 + const itemCodes = itemCodeStr.split(',').map(code => code.trim()).filter(code => code.length > 0) + + if (itemCodes.length === 0) { + return + } + + data.push({ + contactEmail, + itemCodes, + }) + }) + + return data +} + +/** + * 이메일로 담당자 찾기 함수 + */ +async function findContactByEmail(email: string) { + const contact = await db + .select({ + id: techVendorContacts.id, + contactName: techVendorContacts.contactName, + contactEmail: techVendorContacts.contactEmail, + vendorId: techVendorContacts.vendorId, + }) + .from(techVendorContacts) + .where(eq(techVendorContacts.contactEmail, email)) + .limit(1) + + return contact[0] || null +} + +/** + * 벤더의 타입별 아이템 찾기 함수 + */ +async function findItemsByVendorType(vendorId: number, itemCode: string) { + // 벤더 정보 조회로 타입 확인 + const vendor = await db.query.techVendors.findFirst({ + where: eq(techVendors.id, vendorId), + columns: { + techVendorType: true + } + }) + + if (!vendor) { + throw new Error("벤더를 찾을 수 없습니다.") + } + + const foundItems = [] + + // 벤더 타입 파싱 - 콤마로 구분된 문자열을 배열로 변환 + let vendorTypes: string[] = [] + if (typeof vendor.techVendorType === 'string') { + vendorTypes = vendor.techVendorType.split(',').map(type => type.trim()).filter(type => type.length > 0) + } else { + vendorTypes = [vendor.techVendorType] + } + + // 각 벤더 타입별로 아이템 검색 + for (const vendorType of vendorTypes) { + switch (vendorType) { + case "조선": + const shipItems = await db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + }) + .from(itemShipbuilding) + .where(eq(itemShipbuilding.itemCode, itemCode)) + + foundItems.push(...shipItems.map(item => ({ ...item, itemType: "SHIP" }))) + break + + case "해양TOP": + const topItems = await db + .select({ + id: itemOffshoreTop.id, + itemCode: itemOffshoreTop.itemCode, + }) + .from(itemOffshoreTop) + .where(eq(itemOffshoreTop.itemCode, itemCode)) + + foundItems.push(...topItems.map(item => ({ ...item, itemType: "TOP" }))) + break + + case "해양HULL": + const hullItems = await db + .select({ + id: itemOffshoreHull.id, + itemCode: itemOffshoreHull.itemCode, + }) + .from(itemOffshoreHull) + .where(eq(itemOffshoreHull.itemCode, itemCode)) + + foundItems.push(...hullItems.map(item => ({ ...item, itemType: "HULL" }))) + break + } + } + + return foundItems +} + +/** + * 벤더의 possible items에 아이템 추가 함수 (중복 체크 포함) + */ +async function addVendorPossibleItems(vendorId: number, items: Array<{id: number, itemType: string}>) { + const addedItems = [] + + for (const item of items) { + // 중복 체크 + let existingItem = null + + if (item.itemType === "SHIP") { + existingItem = await db.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.shipbuildingItemId, item.id) + ) + }) + } else if (item.itemType === "TOP") { + existingItem = await db.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreTopItemId, item.id) + ) + }) + } else if (item.itemType === "HULL") { + existingItem = await db.query.techVendorPossibleItems.findFirst({ + where: and( + eq(techVendorPossibleItems.vendorId, vendorId), + eq(techVendorPossibleItems.offshoreHullItemId, item.id) + ) + }) + } + + if (!existingItem) { + // 새 아이템 추가 + const insertData: { + vendorId: number + shipbuildingItemId?: number + offshoreTopItemId?: number + offshoreHullItemId?: number + } = { + vendorId, + } + + if (item.itemType === "SHIP") { + insertData.shipbuildingItemId = item.id + } else if (item.itemType === "TOP") { + insertData.offshoreTopItemId = item.id + } else if (item.itemType === "HULL") { + insertData.offshoreHullItemId = item.id + } + + const [newItem] = await db + .insert(techVendorPossibleItems) + .values(insertData) + .returning() + + addedItems.push(newItem) + } + } + + return addedItems +} + +/** + * Contact Possible Items Import 메인 함수 + */ +export async function importContactPossibleItemsFromExcel( + data: ContactPossibleItemImportData[] +): Promise<ContactPossibleItemImportResult> { + const result: ContactPossibleItemImportResult = { + success: true, + totalRows: data.length, + successCount: 0, + failedRows: [], + } + + for (let i = 0; i < data.length; i++) { + const row = data[i] + const rowNumber = i + 1 + + try { + // 1. 담당자 이메일로 담당자 찾기 + if (!row.contactEmail || !row.contactEmail.trim()) { + result.failedRows.push({ + row: rowNumber, + error: "담당자 이메일은 필수입니다.", + contactEmail: row.contactEmail, + itemCode: row.itemCode, + }) + continue + } + + const contact = await findContactByEmail(row.contactEmail.trim()) + if (!contact) { + result.failedRows.push({ + row: rowNumber, + error: `담당자 이메일 '${row.contactEmail}'을(를) 찾을 수 없습니다.`, + contactEmail: row.contactEmail, + itemCode: row.itemCode, + }) + continue + } + + // 2. 아이템 코드 배열 검증 + if (!row.itemCodes || row.itemCodes.length === 0) { + result.failedRows.push({ + row: rowNumber, + error: "아이템 코드는 필수입니다.", + contactEmail: row.contactEmail, + itemCode: row.itemCodes.join(', '), + }) + continue + } + + // 3. 각 아이템코드에 대해 처리 + for (const itemCode of row.itemCodes) { + try { + // 벤더 타입별로 아이템 검색 + const foundItems = await findItemsByVendorType(contact.vendorId, itemCode) + + if (foundItems.length === 0) { + result.failedRows.push({ + row: rowNumber, + error: `아이템 코드 '${itemCode}'을(를) 벤더 타입에서 찾을 수 없습니다.`, + contactEmail: row.contactEmail, + itemCode: itemCode, + }) + continue + } + + // 4. 벤더의 possible items에 아이템 추가 (중복 체크 포함) + const addedItems = await addVendorPossibleItems(contact.vendorId, foundItems) + + if (addedItems.length === 0) { + result.failedRows.push({ + row: rowNumber, + error: `아이템 코드 '${itemCode}'은(는) 이미 등록되어 있습니다.`, + contactEmail: row.contactEmail, + itemCode: itemCode, + }) + continue + } + + // 5. 추가된 아이템들을 담당자와 연결 + for (const addedItem of addedItems) { + await db.insert(techSalesContactPossibleItems).values({ + contactId: contact.id, + vendorPossibleItemId: addedItem.id, + }) + } + + result.successCount += addedItems.length + } catch (error) { + result.failedRows.push({ + row: rowNumber, + error: `아이템 코드 '${itemCode}' 처리 중 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`, + contactEmail: row.contactEmail, + itemCode: itemCode, + }) + } + } + console.log(result) + } catch (error) { + result.failedRows.push({ + row: rowNumber, + error: error instanceof Error ? error.message : "알 수 없는 오류", + contactEmail: row.contactEmail, + itemCode: row.itemCodes.join(', '), + }) + } + } + + // 캐시 무효화 + revalidateTag("tech-vendor-possible-items") + revalidateTag("tech-sales-contact-possible-items") + + return result +} + +/** + * Contact Possible Items Import용 에러 엑셀 파일 생성 + */ +export async function generateContactPossibleItemsErrorExcel(errors: Array<{ + contactEmail: string + itemCode: string + error: string +}>): Promise<Blob> { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Import_에러_내역") + + // 헤더 설정 + worksheet.columns = [ + { header: "담당자이메일", key: "contactEmail", width: 30 }, + { header: "아이템코드", key: "itemCode", width: 20 }, + { header: "에러내용", key: "error", width: 80, style: { alignment: { wrapText: true } } }, + ] + + // 헤더 스타일 설정 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + } + + // 에러 데이터 추가 + errors.forEach(error => { + worksheet.addRow({ + contactEmail: error.contactEmail, + itemCode: error.itemCode, + error: error.error, + }) + }) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) }
\ No newline at end of file |
