diff options
Diffstat (limited to 'lib/tech-vendors/service.ts')
| -rw-r--r-- | lib/tech-vendors/service.ts | 478 |
1 files changed, 478 insertions, 0 deletions
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts index 4eba6b2b..f5380889 100644 --- a/lib/tech-vendors/service.ts +++ b/lib/tech-vendors/service.ts @@ -2784,4 +2784,482 @@ export async function parseContactImportFile(file: File): Promise<ImportContactD }) return data +} + +// ================================================ +// Possible Items Excel Import 관련 함수들 +// ================================================ + +export interface PossibleItemImportData { + vendorEmail: string + itemCode: string + itemType: "조선" | "해양TOP" | "해양HULL" +} + +export interface PossibleItemImportResult { + success: boolean + totalRows: number + successCount: number + failedRows: Array<{ + row: number + error: string + vendorEmail: string + itemCode: string + itemType: "조선" | "해양TOP" | "해양HULL" + }> +} + +export interface PossibleItemErrorData { + vendorEmail: string + itemCode: string + itemType: string + error: string +} + +export interface FoundItem { + id: number + itemCode: string | null + workType: string | null + itemList: string | null + shipTypes: string | null + itemType: "SHIP" | "TOP" | "HULL" +} + +/** + * 벤더 이메일로 벤더 찾기 (possible items import용) + */ +async function findVendorByEmail(email: string) { + const vendor = await db + .select({ + id: techVendors.id, + vendorName: techVendors.vendorName, + email: techVendors.email, + techVendorType: techVendors.techVendorType, + }) + .from(techVendors) + .where(eq(techVendors.email, email)) + .limit(1) + + return vendor[0] || null +} + +/** + * 아이템 타입과 코드로 아이템 찾기 + * 조선의 경우 같은 아이템 코드에 선종이 다른 여러 레코드가 있을 수 있으므로 배열로 반환 + */ +async function findItemByCodeAndType(itemCode: string, itemType: "조선" | "해양TOP" | "해양HULL"): Promise<FoundItem[]> { + try { + switch (itemType) { + case "조선": + const shipItems = await db + .select({ + id: itemShipbuilding.id, + itemCode: itemShipbuilding.itemCode, + workType: itemShipbuilding.workType, + itemList: itemShipbuilding.itemList, + shipTypes: itemShipbuilding.shipTypes, + }) + .from(itemShipbuilding) + .where(eq(itemShipbuilding.itemCode, itemCode)) + + return shipItems.length > 0 + ? shipItems.map(item => ({ ...item, itemType: "SHIP" as const })) + : [] + + case "해양TOP": + const topItems = await db + .select({ + id: itemOffshoreTop.id, + itemCode: itemOffshoreTop.itemCode, + workType: itemOffshoreTop.workType, + itemList: itemOffshoreTop.itemList, + shipTypes: sql<string>`null`.as("shipTypes"), + }) + .from(itemOffshoreTop) + .where(eq(itemOffshoreTop.itemCode, itemCode)) + + return topItems.length > 0 + ? topItems.map(item => ({ ...item, itemType: "TOP" as const })) + : [] + + case "해양HULL": + const hullItems = await db + .select({ + id: itemOffshoreHull.id, + itemCode: itemOffshoreHull.itemCode, + workType: itemOffshoreHull.workType, + itemList: itemOffshoreHull.itemList, + shipTypes: sql<string>`null`.as("shipTypes"), + }) + .from(itemOffshoreHull) + .where(eq(itemOffshoreHull.itemCode, itemCode)) + + return hullItems.length > 0 + ? hullItems.map(item => ({ ...item, itemType: "HULL" as const })) + : [] + + default: + return [] + } + } catch (error) { + console.error("Error finding item:", error) + return [] + } +} + +/** + * tech-vendor-possible-items에 중복 데이터 확인 + * 여러 아이템 ID를 한 번에 확인할 수 있도록 수정 + */ +async function checkPossibleItemDuplicate(vendorId: number, items: FoundItem[]) { + try { + if (items.length === 0) return [] + + const shipIds = items.filter(item => item.itemType === "SHIP").map(item => item.id) + const topIds = items.filter(item => item.itemType === "TOP").map(item => item.id) + const hullIds = items.filter(item => item.itemType === "HULL").map(item => item.id) + + const whereConditions = [eq(techVendorPossibleItems.vendorId, vendorId)] + const orConditions = [] + + if (shipIds.length > 0) { + orConditions.push(inArray(techVendorPossibleItems.shipbuildingItemId, shipIds)) + } + if (topIds.length > 0) { + orConditions.push(inArray(techVendorPossibleItems.offshoreTopItemId, topIds)) + } + if (hullIds.length > 0) { + orConditions.push(inArray(techVendorPossibleItems.offshoreHullItemId, hullIds)) + } + + if (orConditions.length > 0) { + whereConditions.push(or(...orConditions)) + } + + const existing = await db + .select({ + id: techVendorPossibleItems.id, + shipbuildingItemId: techVendorPossibleItems.shipbuildingItemId, + offshoreTopItemId: techVendorPossibleItems.offshoreTopItemId, + offshoreHullItemId: techVendorPossibleItems.offshoreHullItemId, + }) + .from(techVendorPossibleItems) + .where(and(...whereConditions)) + + return existing + } catch (error) { + console.error("Error checking duplicate:", error) + return [] + } +} + +/** + * possible items Excel 파일에서 데이터 파싱 + */ +export async function parsePossibleItemsImportFile(file: File): Promise<PossibleItemImportData[]> { + 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: PossibleItemImportData[] = [] + + worksheet.eachRow((row, index) => { + // 헤더 행 건너뛰기 (1행) + if (index === 1) return + + const values = row.values as (string | null)[] + if (!values || values.length < 3) return + + const vendorEmail = values[1]?.toString().trim() + const itemCode = values[2]?.toString().trim() + const itemType = values[3]?.toString().trim() + + // 필수 필드 검증 + if (!vendorEmail || !itemCode || !itemType) { + return + } + + // 아이템 타입 검증 및 변환 + let validatedItemType: "조선" | "해양TOP" | "해양HULL" | null = null + if (itemType === "조선") { + validatedItemType = "조선" + } else if (itemType === "해양TOP") { + validatedItemType = "해양TOP" + } else if (itemType === "해양HULL") { + validatedItemType = "해양HULL" + } + + if (!validatedItemType) { + return + } + + data.push({ + vendorEmail, + itemCode, + itemType: validatedItemType, + }) + }) + + return data +} + +/** + * possible items 일괄 import + */ +export async function importPossibleItemsFromExcel( + data: PossibleItemImportData[] +): Promise<PossibleItemImportResult> { + const result: PossibleItemImportResult = { + 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.vendorEmail || !row.vendorEmail.trim()) { + result.failedRows.push({ + row: rowNumber, + error: "벤더 이메일은 필수입니다.", + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + continue + } + + const vendor = await findVendorByEmail(row.vendorEmail.trim()) + if (!vendor) { + result.failedRows.push({ + row: rowNumber, + error: `벤더 이메일 '${row.vendorEmail}'을(를) 찾을 수 없습니다.`, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + continue + } + + // 2. 아이템 코드로 아이템 찾기 + if (!row.itemCode || !row.itemCode.trim()) { + result.failedRows.push({ + row: rowNumber, + error: "아이템 코드는 필수입니다.", + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + continue + } + + const items = await findItemByCodeAndType(row.itemCode.trim(), row.itemType) + if (!items || items.length === 0) { + result.failedRows.push({ + row: rowNumber, + error: `아이템 코드 '${row.itemCode}'을(를) '${row.itemType}' 타입에서 찾을 수 없습니다.`, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + continue + } + + // 3. 중복 데이터 확인 (모든 아이템에 대해) + const existingItems = await checkPossibleItemDuplicate(vendor.id, items) + + // 중복되지 않은 아이템들만 필터링 + const nonDuplicateItems = items.filter(item => { + const existingItem = existingItems.find(existing => { + if (item.itemType === "SHIP") { + return existing.shipbuildingItemId === item.id + } else if (item.itemType === "TOP") { + return existing.offshoreTopItemId === item.id + } else if (item.itemType === "HULL") { + return existing.offshoreHullItemId === item.id + } + return false + }) + return !existingItem + }) + + if (nonDuplicateItems.length === 0) { + result.failedRows.push({ + row: rowNumber, + error: "모든 아이템이 이미 등록되어 있습니다.", + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + continue + } + + // 4. tech-vendor-possible-items에 데이터 삽입 (중복되지 않은 아이템들만) + for (const item of nonDuplicateItems) { + const insertData: { + vendorId: number + shipbuildingItemId?: number + offshoreTopItemId?: number + offshoreHullItemId?: number + } = { + vendorId: vendor.id, + } + + 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 + } + + await db.insert(techVendorPossibleItems).values(insertData) + result.successCount++ + } + + // 부분 성공/실패 처리: 일부 아이템만 등록된 경우 + const duplicateCount = items.length - nonDuplicateItems.length + if (duplicateCount > 0) { + result.failedRows.push({ + row: rowNumber, + error: `${duplicateCount}개 아이템이 중복되어 제외되었습니다.`, + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType as "조선" | "해양TOP" | "해양HULL", + }) + } + + } catch (error) { + result.failedRows.push({ + row: rowNumber, + error: error instanceof Error ? error.message : "알 수 없는 오류", + vendorEmail: row.vendorEmail, + itemCode: row.itemCode, + itemType: row.itemType, + }) + } + } + + // 캐시 무효화 + revalidateTag("tech-vendor-possible-items") + + return result +} + +/** + * possible items 템플릿 Excel 파일 생성 + */ +export async function generatePossibleItemsImportTemplate(): Promise<Blob> { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("벤더_Possible_Items_템플릿") + + // 헤더 설정 + worksheet.columns = [ + { header: "벤더이메일*", key: "vendorEmail", width: 25 }, + { header: "아이템코드*", key: "itemCode", width: 20 }, + { header: "아이템타입*", key: "itemType", width: 15 }, + ] + + // 헤더 스타일 설정 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE0E0E0" }, + } + + // 예시 데이터 추가 + worksheet.addRow({ + vendorEmail: "vendor@example.com", + itemCode: "ITEM001", + itemType: "조선", + }) + + worksheet.addRow({ + vendorEmail: "vendor@example.com", + itemCode: "TOP001", + itemType: "해양TOP", + }) + + worksheet.addRow({ + vendorEmail: "vendor@example.com", + itemCode: "HULL001", + itemType: "해양HULL", + }) + + // 설명 시트 추가 + const infoSheet = workbook.addWorksheet("설명") + infoSheet.getColumn(1).width = 50 + infoSheet.getColumn(2).width = 100 + + infoSheet.addRow(["템플릿 사용 방법"]) + infoSheet.addRow(["1. 벤더이메일", "벤더의 이메일 주소 (필수)"]) + infoSheet.addRow(["2. 아이템코드", "아이템 코드 (필수)"]) + infoSheet.addRow(["3. 아이템타입", "조선, 해양TOP, 해양HULL 중 하나 (필수)"]) + infoSheet.addRow([]) + infoSheet.addRow(["중요 안내"]) + infoSheet.addRow(["• 조선 아이템의 경우", "같은 아이템 코드라도 선종이 다른 여러 레코드가 있을 수 있습니다."]) + infoSheet.addRow(["• 조선 아이템 등록 시", "아이템 코드 하나로 선종이 다른 모든 레코드가 자동으로 등록됩니다."]) + infoSheet.addRow(["• 해양TOP/HULL의 경우", "아이템 코드 하나에 하나의 레코드만 존재합니다."]) + infoSheet.addRow([]) + infoSheet.addRow(["주의사항"]) + infoSheet.addRow(["• 벤더이메일은 시스템에 등록된 이메일이어야 합니다."]) + infoSheet.addRow(["• 아이템코드는 해당 타입의 아이템 테이블에 존재해야 합니다."]) + infoSheet.addRow(["• 이미 등록된 아이템-벤더 조합은 중복 등록되지 않습니다."]) + infoSheet.addRow(["• 조선 아이템의 경우 일부 선종만 중복인 경우 나머지 선종은 등록됩니다."]) + + const buffer = await workbook.xlsx.writeBuffer() + return new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) +} + +/** + * possible items 에러 Excel 파일 생성 + */ +export async function generatePossibleItemsErrorExcel(errors: PossibleItemErrorData[]): Promise<Blob> { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("Import_에러_내역") + + // 헤더 설정 + worksheet.columns = [ + { header: "벤더이메일", key: "vendorEmail", width: 25 }, + { header: "아이템코드", key: "itemCode", width: 20 }, + { header: "아이템타입", key: "itemType", width: 15 }, + { header: "에러내용", key: "error", width: 50 }, + ] + + // 헤더 스타일 설정 + const headerRow = worksheet.getRow(1) + headerRow.font = { bold: true } + headerRow.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFCCCC" }, + } + + // 에러 데이터 추가 + errors.forEach(error => { + worksheet.addRow({ + vendorEmail: error.vendorEmail, + itemCode: error.itemCode, + itemType: error.itemType, + 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 |
