summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--lib/tech-vendors/contacts-table/contact-table-toolbar-actions.tsx124
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx151
-rw-r--r--lib/tech-vendors/service.ts757
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